Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1478 lines
60 KiB
Go

package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/astquery"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/gitcmd"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/llm"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/review"
"github.com/zzet/gortex/internal/semantic/lsp"
)
// registerReviewTools registers the review-engine tool group. Unlike most
// specialised tool groups, these tools are EAGER — their names live in
// hotEagerTools so they are published in the initial tools/list rather than
// hidden behind tools_search. A reviewing agent reaches for them on the first
// turn of a review task, so paying a discovery round-trip for them would be a
// regression. This group grows: it is the single registration site for the
// whole review surface, so later review tools append their addTool block here.
func (s *Server) registerReviewTools() {
s.addTool(
mcp.NewTool("sibling_diff_context",
mcp.WithDescription("Return the raw unified diff of the OTHER changed files in a changeset — the sibling changes a per-symbol or per-file review view filters out — prebuilt in one call. Enumerates the whole changeset (via the git diff against `base`/`scope`), drops the focus files, and returns each remaining file's raw diff ranked by relatedness to the focus (shared community/process → co-change → directory proximity). Pass `focus_files` (comma-separated changed file paths to exclude) and/or `focus_symbol_id` (a changed symbol whose file is the focus). Use to pull in the cross-file context a narrow review needs without issuing a diff call per file."),
mcp.WithString("base", mcp.Description("Base git ref (e.g. main). Selects the changeset as `git diff base...HEAD`. Alias for scope=compare + base_ref=base.")),
mcp.WithString("base_ref", mcp.Description("Base ref for scope=compare (default: main). `base` takes precedence when both are set.")),
mcp.WithString("scope", mcp.Description("Changeset scope: unstaged (default), staged, all, or compare. Ignored when `base` is set (forces compare).")),
mcp.WithString("repo", mcp.Description("Repository prefix to resolve the working tree (multi-repo mode).")),
mcp.WithString("focus_files", mcp.Description("Comma-separated changed file paths that are the focus — excluded from the returned siblings.")),
mcp.WithString("focus_file", mcp.Description("Single focus file path — excluded from the siblings (alias for focus_files with one entry).")),
mcp.WithString("focus_symbol_id", mcp.Description("A changed symbol's ID; its file becomes a focus file and is excluded from the siblings.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The lowest-ranked siblings are trimmed first; truncation metadata rides on the response. Omit for no cap.")),
mcp.WithNumber("max_tokens", mcp.Description("Token budget for the response — the lowest-ranked siblings are dropped first to fit.")),
),
s.handleSiblingDiffContext,
)
s.addTool(
mcp.NewTool("review",
mcp.WithDescription("Review a changeset and return line-anchored inline review comments plus a BLOCK/REVIEW/APPROVE verdict. Enumerates the changeset (git diff against `base`/`scope`, or a pasted unified `diff`), runs the deterministic correctness review rulepack over the changed files (graph-grounded to drop false positives), and — when `use_llm` is set and an LLM provider is configured — folds in LLM-found findings relocated to exact lines. Each finding is anchored to a `{file,line,severity,message,rule,category}` so it can be posted as an inline comment. Returns the verdict envelope (verdict + summary + per-file risk + the line-anchored comments)."),
mcp.WithString("base", mcp.Description("Base git ref (e.g. main). Selects the changeset as `git diff base...HEAD`. Alias for scope=compare + base_ref=base.")),
mcp.WithString("base_ref", mcp.Description("Base ref for scope=compare (default: main). `base` takes precedence when both are set.")),
mcp.WithString("scope", mcp.Description("Changeset scope: unstaged (default), staged, all, or compare. Ignored when `base` or `diff` is set.")),
mcp.WithString("diff", mcp.Description("Raw unified-diff text to review off-disk (the pasted-diff path). When set, no git command runs and `scope`/`base` are ignored.")),
mcp.WithString("repo", mcp.Description("Repository prefix to resolve the working tree (multi-repo mode).")),
mcp.WithBoolean("use_llm", mcp.Description("Engage the LLM review phase (graph-grounded rulepack findings always run). Requires a configured LLM provider; ignored when none is available. Default: false.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list (comments) is trimmed first; truncation metadata rides on the response. Omit for no cap.")),
mcp.WithNumber("max_tokens", mcp.Description("Token budget for the response and the internal review pack handed to the LLM.")),
),
s.handleReview,
)
s.addTool(
mcp.NewTool("review_pack",
mcp.WithDescription("Run the whole PR-review gate set over a changeset and fold the result into ONE packaged envelope. Composes the deterministic graph-grounded review (verdict + line-anchored findings), per-symbol semantic classification (feature/fix/refactor/test/config), per-file risk ranking, contract-impact + guard/architecture checks, and the impacted test targets — then derives a concrete `verification_command` to run and a privacy-safe risk receipt. The verdict is the worst-of the review report, upgraded to BLOCK when a contract breaks or a guard/architecture rule is violated. Pass `base`/`scope` to select the changeset (or a pasted `diff`); `include_pack` adds the tiered diff-hunk pack; `scrub` strips any path/symbol/email from the receipt. Use as the single entrypoint for an AST-grounded PR review."),
mcp.WithString("base", mcp.Description("Base git ref (e.g. main). Selects the changeset as `git diff base...HEAD`. Alias for scope=compare + base_ref=base.")),
mcp.WithString("base_ref", mcp.Description("Base ref for scope=compare (default: main). `base` takes precedence when both are set.")),
mcp.WithString("scope", mcp.Description("Changeset scope: unstaged (default), staged, all, or compare. Ignored when `base` or `diff` is set.")),
mcp.WithString("diff", mcp.Description("Raw unified-diff text to review off-disk (the pasted-diff path). When set, no git command runs and `scope`/`base` are ignored. Contract/guard/test gates that need indexed symbols are skipped for a pasted diff.")),
mcp.WithString("repo", mcp.Description("Repository prefix to resolve the working tree (multi-repo mode).")),
mcp.WithBoolean("use_llm", mcp.Description("Engage the LLM review phase (graph-grounded rulepack findings always run). Requires a configured LLM provider; ignored when none is available. Default: false.")),
mcp.WithBoolean("include_pack", mcp.Description("Include the tiered review pack (changed symbols as diff hunks, direct callers as full source, the rest as an outline). Default: false.")),
mcp.WithBoolean("scrub", mcp.Description("Sanitize the risk receipt so no path-like, symbol-ID-like, or email-like value can leak — safe to share cross-org. Default: false.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed first; truncation metadata rides on the response. Omit for no cap.")),
mcp.WithNumber("max_tokens", mcp.Description("Token budget for the response and the internal review pack handed to the LLM.")),
),
s.handleReviewPack,
)
s.addTool(
mcp.NewTool("suppress_finding",
mcp.WithDescription("Durably silence a review finding as a false positive — or list / un-suppress existing suppressions — for the current repository. A suppressed finding is identified by a stable key over its rule, category, symbol, file, and (when supplied) the flagged line's source text, so it stays suppressed after the file shifts the finding to a different line. Every subsequent `review` / `review_pack` run drops a suppressed finding (counted in the gate's `identity_suppressed` stat). This is a permanent, per-repo never-flag-again list (sidecar-backed, survives restarts) — distinct from a development memory or a feedback signal. Pass `action:add` with the finding's `identity_key` (preferred) or the `rule`/`category`/`symbol_id`/`file`/`source_line` fields to derive it; `action:list` to see what is suppressed; `action:remove` with an `identity_key` to un-suppress."),
mcp.WithString("action", mcp.Description("add (default), list, or remove.")),
mcp.WithString("identity_key", mcp.Description("The finding's stable identity key (from a prior review's gate or a list call). Required for remove; preferred for add. When absent on add, the key is derived from rule/category/symbol_id/file/source_line.")),
mcp.WithString("rule", mcp.Description("Detector / rule name of the finding (used to derive identity_key on add).")),
mcp.WithString("category", mcp.Description("Finding category (used to derive identity_key on add).")),
mcp.WithString("symbol_id", mcp.Description("The flagged symbol's ID (used to derive identity_key on add).")),
mcp.WithString("file", mcp.Description("The flagged file path (used to derive identity_key on add).")),
mcp.WithNumber("line", mcp.Description("The flagged line number. Recorded for context; NOT part of the identity, so the suppression survives line drift.")),
mcp.WithString("source_line", mcp.Description("The flagged line's source text. Folded (trimmed) into the identity so the suppression is drift-stable and does not over-suppress sibling findings on the same symbol.")),
mcp.WithString("reason", mcp.Description("Why this finding is a false positive (stored alongside the suppression).")),
mcp.WithString("author", mcp.Description("Who suppressed it (stored alongside the suppression).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes (list action). Omit for no cap.")),
),
s.handleSuppressFinding,
)
s.addTool(
mcp.NewTool("post_review",
mcp.WithDescription("Post review findings as inline comments on a GitHub PR / GitLab MR. Each finding is mapped to a RIGHT-side (new-code) inline comment anchored to its file + line (multi-line findings carry a start_line < line range), batched into one review. Every comment body is run through a secret-redaction pass BEFORE any payload is built or any request is sent — a body that quotes an inline credential (API key, token, PEM block, password assignment) is redacted (or, by default, the whole finding is skipped) so a secret never egresses. Posting to a public or fork PR is opt-in: pass confirm_public:true (or set review.post.allow_public) or the post is refused. Pass findings as a JSON array (the gated findings from a prior `review` / `review_pack` call); when omitted the deterministic review rulepack runs over the changeset / pasted diff. dry_run:true returns the would-post (already-redacted) payloads without any network call."),
mcp.WithNumber("number", mcp.Required(), mcp.Description("The PR / MR number to post comments on.")),
mcp.WithString("repo", mcp.Description("Repository prefix to resolve the working tree + token (multi-repo mode).")),
mcp.WithString("findings", mcp.Description("JSON array of review findings to post (from a prior review / review_pack call). When omitted, the review rulepack runs over the changeset to produce findings.")),
mcp.WithString("diff", mcp.Description("Raw unified-diff text to review off-disk when deriving findings (the pasted-diff path). Ignored when `findings` is supplied.")),
mcp.WithString("base", mcp.Description("Base git ref (e.g. main) selecting the changeset when deriving findings. Alias for scope=compare + base_ref=base.")),
mcp.WithString("base_ref", mcp.Description("Base ref for scope=compare (default: main) when deriving findings.")),
mcp.WithString("scope", mcp.Description("Changeset scope when deriving findings: unstaged (default), staged, all, or compare.")),
mcp.WithString("summary", mcp.Description("Top-level review summary body posted alongside the inline comments.")),
mcp.WithString("owner", mcp.Description("Repository owner (for the posted review URL).")),
mcp.WithString("repo_name", mcp.Description("Repository name (for the posted review URL).")),
mcp.WithString("provider", mcp.Description("Forge backend: github (default).")),
mcp.WithBoolean("public", mcp.Description("The target PR is on a public or fork repo (world-readable). When true, posting requires confirm_public / review.post.allow_public. Default: false.")),
mcp.WithBoolean("confirm_public", mcp.Description("Confirm posting to a public / fork PR (world-readable comments). Default: false — without it a public target is refused.")),
mcp.WithBoolean("refuse_on_secret", mcp.Description("Skip a finding whose body still quoted a secret rather than posting a redacted version. Default: true.")),
mcp.WithBoolean("dry_run", mcp.Description("Build and return the would-post (already-redacted) payloads without any network call. Default: false.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. Omit for no cap.")),
),
s.handlePostReview,
)
}
// reviewSuppressions returns the active repo's durable suppression store and its
// per-repo key for the review gate. Both are zero-valued (nil store, empty key)
// when InitSuppressions has not run — the review flow tolerates that and
// suppresses nothing.
func (s *Server) reviewSuppressions() (*review.SuppressionStore, string) {
if s.suppressions == nil {
return nil, ""
}
return s.suppressions.Store(), s.suppressions.RepoKey()
}
// handleSuppressFinding records, lists, or removes a durable false-positive
// suppression for the current repository. The add action derives the finding's
// stable identity key from the supplied fields (or takes an explicit
// identity_key); list returns every suppression most-recently-hit first; remove
// deletes one by identity key. The suppression is honoured by every subsequent
// review / review_pack run via the gate.
func (s *Server) handleSuppressFinding(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.suppressions == nil {
return mcp.NewToolResultError("suppression store not initialised"), nil
}
store := s.suppressions.Store()
repoKey := s.suppressions.RepoKey()
action := strings.ToLower(strings.TrimSpace(req.GetString("action", "add")))
switch action {
case "list":
rows, err := store.List(repoKey)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list suppressions: %v", err)), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"suppressions": rows,
"total": len(rows),
})
case "remove", "delete", "unsuppress":
key := strings.TrimSpace(req.GetString("identity_key", ""))
if key == "" {
return mcp.NewToolResultError("suppress_finding remove requires identity_key"), nil
}
if err := store.Unsuppress(repoKey, key); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("remove suppression: %v", err)), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "removed",
"identity_key": key,
})
case "add", "suppress", "":
f := review.Finding{
IdentityKey: strings.TrimSpace(req.GetString("identity_key", "")),
Rule: strings.TrimSpace(req.GetString("rule", "")),
Category: strings.TrimSpace(req.GetString("category", "")),
SymbolID: strings.TrimSpace(req.GetString("symbol_id", "")),
File: strings.TrimSpace(req.GetString("file", "")),
Line: req.GetInt("line", 0),
SourceLine: req.GetString("source_line", ""),
}
// Require enough to derive a meaningful identity: either an explicit
// key, or at least a rule plus a symbol/file to anchor it.
if f.IdentityKey == "" && f.Rule == "" && f.SymbolID == "" && f.File == "" {
return mcp.NewToolResultError("suppress_finding add requires identity_key, or at least rule + symbol_id/file to derive one"), nil
}
if f.IdentityKey == "" {
f.IdentityKey = review.IdentityKey(f)
}
reason := strings.TrimSpace(req.GetString("reason", ""))
author := strings.TrimSpace(req.GetString("author", ""))
if err := store.Suppress(repoKey, f, reason, author); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("suppress finding: %v", err)), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "suppressed",
"identity_key": f.IdentityKey,
"rule": f.Rule,
"category": f.Category,
"symbol_id": f.SymbolID,
"file": f.File,
})
default:
return mcp.NewToolResultError(fmt.Sprintf("unknown action %q (want add, list, or remove)", action)), nil
}
}
// inlineComment is one line-anchored review finding projected onto the inline
// review-comment shape: the file + new-side line it anchors to, its severity,
// the short message, the rule/detector that produced it, and its category. It is
// the unit a reviewing agent (or a forge poster one layer up) attaches to a PR.
type inlineComment struct {
File string `json:"file"`
Line int `json:"line"`
Severity string `json:"severity"`
Message string `json:"message"`
Rule string `json:"rule"`
Category string `json:"category"`
Source string `json:"source,omitempty"`
}
// siblingDiffRow is one related-but-filtered-out changed file: its repo-relative
// path, the relation that ranks it against the focus, the relatedness score, and
// the raw unified diff text of just that file's hunks.
type siblingDiffRow struct {
File string `json:"file"`
Relation string `json:"relation"`
Score float64 `json:"score"`
Diff string `json:"diff"`
}
// handleSiblingDiffContext enumerates the changeset, drops the focus files, and
// returns each remaining changed file's raw diff ranked by relatedness to the
// focus. Relatedness is community/process sharing → co-change → directory
// proximity; budget trims the lowest-ranked rows first.
func (s *Server) handleSiblingDiffContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("no graph available — index a repo first"), nil
}
scope, baseRef := siblingDiffScope(req)
repo := strings.TrimSpace(req.GetString("repo", ""))
repoRoot, repoPrefix := s.diffRepoScope(ctx, repo)
if repoRoot == "" {
return mcp.NewToolResultError("could not resolve a repository root for the changeset diff"), nil
}
// Enumerate the whole changeset.
diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Resolve the focus set: explicit focus_files / focus_file plus the file
// of any focus_symbol_id.
focus := s.resolveFocusFiles(req)
focusList := make([]string, 0, len(focus))
for f := range focus {
focusList = append(focusList, f)
}
sort.Strings(focusList)
// Build a deduplicated, focus-excluded sibling set out of the changed files.
seen := map[string]bool{}
var siblings []siblingDiffRow
for _, f := range diff.ChangedFiles {
f = filepath.Clean(f)
if f == "" || f == "." || focus[f] || seen[f] {
continue
}
seen[f] = true
raw, derr := s.rawFileDiff(ctx, repoRoot, scope, baseRef, f)
if derr != nil || strings.TrimSpace(raw) == "" {
continue
}
relation, score := s.siblingRelation(f, focusList)
siblings = append(siblings, siblingDiffRow{
File: f,
Relation: relation,
Score: score,
Diff: raw,
})
}
// Rank: highest relatedness first, ties broken by path for determinism.
sort.SliceStable(siblings, func(i, j int) bool {
if siblings[i].Score != siblings[j].Score {
return siblings[i].Score > siblings[j].Score
}
return siblings[i].File < siblings[j].File
})
payload := siblingDiffPayload(focusList, siblings)
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeSiblingDiffContext(payload))
}
if s.isTOON(ctx, req) {
return returnTOON(payload)
}
return s.respondJSONOrTOON(ctx, req, payload)
}
// siblingDiffScope resolves the (scope, baseRef) pair from the request. `base`
// is a convenience alias that forces compare scope against that ref.
func siblingDiffScope(req mcp.CallToolRequest) (scope, baseRef string) {
base := strings.TrimSpace(req.GetString("base", ""))
if base != "" {
return "compare", base
}
scope = req.GetString("scope", "unstaged")
baseRef = req.GetString("base_ref", "main")
return scope, baseRef
}
// resolveFocusFiles collects the focus file set from focus_files / focus_file
// (paths) and focus_symbol_id (the symbol's file). Paths are cleaned so they
// join the MapGitDiff ChangedFiles keys.
func (s *Server) resolveFocusFiles(req mcp.CallToolRequest) map[string]bool {
focus := map[string]bool{}
add := func(p string) {
p = filepath.Clean(strings.TrimSpace(p))
if p != "" && p != "." {
focus[p] = true
}
}
for _, p := range strings.Split(req.GetString("focus_files", ""), ",") {
add(p)
}
if ff := req.GetString("focus_file", ""); ff != "" {
add(ff)
}
if id := strings.TrimSpace(req.GetString("focus_symbol_id", "")); id != "" {
if n := s.graph.GetNode(id); n != nil && n.FilePath != "" {
add(n.FilePath)
}
}
return focus
}
// siblingRelation classifies and scores how a candidate sibling file relates to
// the focus set. The strongest applicable relation wins:
//
// community → the sibling shares a graph community with a focus symbol
// process → the sibling shares a process with a focus symbol
// cochange → the sibling historically changes alongside a focus file
// directory → the sibling lives in (or near) a focus file's directory
// none → unrelated by any signal
//
// Score is a coarse band per relation (plus a co-change magnitude bump) so the
// ranking is deterministic and the bands stay separable.
func (s *Server) siblingRelation(file string, focusFiles []string) (string, float64) {
if len(focusFiles) == 0 {
return "none", 0
}
// community / process sharing, evaluated symbol-wise across both sides.
communities := s.getCommunities()
processes := s.getProcesses()
focusCommunities := map[string]bool{}
focusProcesses := map[string]bool{}
for _, ff := range focusFiles {
for _, n := range s.graph.GetFileNodes(ff) {
if communities != nil {
if c, ok := communities.NodeToComm[n.ID]; ok && c != "" {
focusCommunities[c] = true
}
}
if processes != nil {
for _, p := range processes.NodeToProcs[n.ID] {
if p != "" {
focusProcesses[p] = true
}
}
}
}
}
if communities != nil && len(focusCommunities) > 0 {
for _, n := range s.graph.GetFileNodes(file) {
if c, ok := communities.NodeToComm[n.ID]; ok && focusCommunities[c] {
return "community", 100
}
}
}
if processes != nil && len(focusProcesses) > 0 {
for _, n := range s.graph.GetFileNodes(file) {
for _, p := range processes.NodeToProcs[n.ID] {
if focusProcesses[p] {
return "process", 80
}
}
}
}
// co-change: historical commit overlap between the sibling and a focus file.
bestCo := 0.0
for _, ff := range focusFiles {
if sc, ok := s.coChangeScores(ff)[file]; ok && sc > bestCo {
bestCo = sc
}
}
if bestCo > 0 {
// Keep co-change strictly below process so the bands don't collide.
return "cochange", 40 + clampScore(bestCo, 39)
}
// directory proximity: same directory (or an ancestor of one) as a focus
// file. The deeper the shared prefix the higher the score.
bestDir := 0.0
for _, ff := range focusFiles {
if d := dirProximity(file, ff); d > bestDir {
bestDir = d
}
}
if bestDir > 0 {
return "directory", bestDir
}
return "none", 0
}
// dirProximity scores directory closeness in (0,20]. Same directory scores
// highest; a shared parent directory scores by the number of shared leading
// path segments. Returns 0 when the files share no directory prefix.
func dirProximity(a, b string) float64 {
da := strings.Split(filepath.ToSlash(filepath.Dir(a)), "/")
db := strings.Split(filepath.ToSlash(filepath.Dir(b)), "/")
shared := 0
for shared < len(da) && shared < len(db) && da[shared] == db[shared] {
if da[shared] == "." || da[shared] == "" {
break
}
shared++
}
if shared == 0 {
return 0
}
if len(da) == len(db) && shared == len(da) {
// Exact same directory.
return 20
}
score := float64(shared)
if score > 19 {
score = 19
}
return score
}
// clampScore caps v into [0, max].
func clampScore(v, max float64) float64 {
if v < 0 {
return 0
}
if v > max {
return max
}
return v
}
// rawFileDiff returns the raw unified diff text (context-bearing) for a single
// changed file within the changeset. It runs the same git-diff selection as
// MapGitDiff narrowed to one pathspec, so the per-file diff joins the enumerated
// changeset exactly.
func (s *Server) rawFileDiff(ctx context.Context, repoRoot, scope, baseRef, file string) (string, error) {
args := siblingDiffArgs(scope, baseRef)
args = append(args, "--", file)
// Route through gitcmd (the concurrency-gated chokepoint). Use the handler
// ctx so the subprocess is cancelled on session teardown; if it carries no
// deadline, bound it at 30s. Run returns raw stdout (no trailing trim) so
// the parsed diff is byte-identical to the pre-gitcmd output.
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
}
out, err := gitcmd.Run(ctx, repoRoot, args...)
if err != nil {
// An empty diff for a path is not an error (e.g. mode-only change).
if len(out) == 0 {
return "", nil
}
return "", fmt.Errorf("git diff for %s failed: %w", file, err)
}
return string(out), nil
}
// siblingDiffArgs mirrors the analysis diff-arg selection but emits a context
// window (unified=3) so the raw sibling diff carries readable surrounding lines.
func siblingDiffArgs(scope, baseRef string) []string {
// analysis.GitDiffArgs pins the a/ b/ header prefixes the diff parsers
// anchor on (diff.mnemonicPrefix / diff.noprefix would zero them out).
return analysis.GitDiffArgs(scope, baseRef, 3)
}
// siblingDiffPayload projects the ranked siblings onto the wire shape.
// truncated is always false here — the byte/token budget applied downstream
// stamps its own truncation flag when it trims rows.
func siblingDiffPayload(focus []string, siblings []siblingDiffRow) map[string]any {
if focus == nil {
focus = []string{}
}
rows := make([]map[string]any, 0, len(siblings))
for _, sib := range siblings {
rows = append(rows, map[string]any{
"file": sib.File,
"relation": sib.Relation,
"score": sib.Score,
"diff": sib.Diff,
})
}
return map[string]any{
"focus": focus,
"siblings": rows,
"total": len(rows),
"truncated": false,
}
}
// handleReview enumerates a changeset, runs the graph-grounded review rulepack
// over the changed files, optionally folds in LLM findings, and returns the
// resulting ReviewReport projected onto line-anchored inline comments plus the
// verdict envelope.
func (s *Server) handleReview(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("no graph available — index a repo first"), nil
}
diffText := strings.TrimSpace(req.GetString("diff", ""))
scope, baseRef := siblingDiffScope(req)
repo := strings.TrimSpace(req.GetString("repo", ""))
repoRoot, repoPrefix := s.diffRepoScope(ctx, repo)
// An on-disk review needs a working tree; a pasted-diff review does not.
if repoRoot == "" && diffText == "" {
return mcp.NewToolResultError("could not resolve a repository root for the changeset diff"), nil
}
// Compute the deterministic rulepack matches over the changed files, and the
// per-changed-symbol impact map, from the on-disk changeset. For a pasted
// diff there is no git changeset to scan, so both stay empty and review.Run
// degrades to the diff-window substrate.
var (
rulepack []astquery.Match
impact map[string]*analysis.ImpactResult
changedFiles []string
)
if diffText == "" {
diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
allowedRepos, err := s.resolveRepoFilter(ctx, req)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, allowedRepos)
impact = s.reviewImpact(diff.ChangedSymbols)
changedFiles = diff.ChangedFiles
}
// LLM seam: a closure over the optional LLM service's Generate, gated on the
// caller's use_llm and the service actually being enabled. nil disables the
// LLM phases entirely — review.Run then carries only rulepack findings.
useLLM := requestBoolDefault(req, "use_llm", false)
gen := s.reviewLLMGenWithUsage(useLLM)
suppStore, suppRepoKey := s.reviewSuppressions()
report, err := review.RunWithUsage(ctx, s.graph, gen, s.reviewPricing(), review.Options{
RepoRoot: repoRoot,
RepoPrefix: repoPrefix,
CoverageKnown: s.coverageKnownForDiff(repoPrefix, changedFiles),
Scope: scope,
BaseRef: baseRef,
Diff: diffText,
RulepackMatches: rulepack,
Impact: impact,
Rules: s.reviewRuleResolver(repoRoot),
Config: s.reviewConfig(repo),
UseLLM: useLLM && gen != nil,
TokenBudget: intArg(req.GetArguments(), "max_tokens", 0),
Suppressions: suppStore,
RepoKey: suppRepoKey,
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
payload := reviewPayload(report)
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeReview(payload))
}
if s.isTOON(ctx, req) {
return returnTOON(payload)
}
return s.respondJSONOrTOON(ctx, req, payload)
}
// reviewRulepackMatches runs the graph-grounded review detector bundle over just
// the changed files and returns the surviving matches. It mirrors the analyze
// review path (DetectorsByCategory("review") + GroundReviewMatches) but narrows
// the AST targets to the changeset so the review tool only flags changed code.
func (s *Server) reviewRulepackMatches(ctx context.Context, changedFiles []string, allowedRepos map[string]bool) []astquery.Match {
bundle := astquery.DetectorsByCategory("review")
if len(bundle) == 0 {
return nil
}
allTargets, err := s.buildASTTargets("", "", allowedRepos)
if err != nil || len(allTargets) == 0 {
return nil
}
// Narrow to the changed-file set (graph-relative paths) so the rulepack only
// scans the changeset, not the whole repository.
changed := make(map[string]bool, len(changedFiles))
for _, f := range changedFiles {
f = filepath.Clean(strings.TrimSpace(f))
if f != "" && f != "." {
changed[f] = true
}
}
if len(changed) == 0 {
return nil
}
targets := make([]astquery.Target, 0, len(allTargets))
for _, t := range allTargets {
if changed[filepath.Clean(t.GraphPath)] {
targets = append(targets, t)
}
}
if len(targets) == 0 {
return nil
}
fileSymbols := s.buildFileSymbolIndex(targets)
lookup := func(graphPath string, line int) (string, string) {
idx := fileSymbols[graphPath]
if idx == nil {
return "", ""
}
return idx.find(line)
}
var collected []astquery.Match
for _, d := range bundle {
res, runErr := astquery.Run(ctx, astquery.Options{
Detector: d.Name,
Targets: targets,
SymbolLookup: lookup,
Resolver: astquery.DefaultLanguageResolver,
Limit: 5000,
ExcludeTests: true,
})
if runErr != nil {
continue
}
collected = append(collected, res.Matches...)
}
// Graph-grounding post-pass: drop the N+1 / check-then-act rows the resolved
// call / loop metadata refutes. This is the same FP-reduction the analyze
// review path applies.
return review.GroundReviewMatches(s.graph, collected)
}
// reviewImpact builds the per-changed-symbol blast-radius map review.Run uses to
// rank per-file risk. A symbol whose impact analysis is empty is omitted.
// testPatternsByExt maps a source-file extension to the path fragments its
// covering tests conventionally carry. Only languages with a recognizable
// convention are probed; a diff touching none of them reads as
// coverage-unknown.
var testPatternsByExt = map[string][]string{
".go": {"_test.go"},
".ts": {".test.ts", ".spec.ts", "__tests__/"},
".tsx": {".test.ts", ".spec.ts", "__tests__/"},
".js": {".test.js", ".spec.js", "__tests__/"},
".jsx": {".test.js", ".spec.js", "__tests__/"},
".py": {"test_"},
}
// testFragmentsIndexed reports, per test-path fragment, whether the repo's
// graph carries any non-file symbol whose path contains it. One scan,
// cached for the daemon's lifetime — the index's exclude set only changes
// with a reindex.
func (s *Server) testFragmentsIndexed(repoPrefix string) map[string]bool {
if v, ok := s.testIndexProbe.Load(repoPrefix); ok {
return v.(map[string]bool)
}
fragments := map[string]bool{}
for _, pats := range testPatternsByExt {
for _, p := range pats {
fragments[p] = false
}
}
var nodes []*graph.Node
if repoPrefix != "" {
nodes = s.graph.GetRepoNodes(repoPrefix)
} else {
nodes = s.graph.AllNodes()
}
remaining := len(fragments)
for _, n := range nodes {
if n == nil || n.Kind == graph.KindFile || !analysis.IsTestFile(n.FilePath) {
continue
}
for p, seen := range fragments {
if !seen && strings.Contains(n.FilePath, p) {
fragments[p] = true
remaining--
}
}
if remaining == 0 {
break
}
}
s.testIndexProbe.Store(repoPrefix, fragments)
return fragments
}
// coverageKnownForDiff reports whether the graph can attest test coverage
// for this changeset: every changed file whose language has a test
// convention must have that convention present in the index. An index
// config that excludes a language's test files (e.g. "**/*_test.go") makes
// "no covering test" blindness, not a finding — the review then says
// "coverage unknown" instead of "untested".
func (s *Server) coverageKnownForDiff(repoPrefix string, changedFiles []string) bool {
fragments := s.testFragmentsIndexed(repoPrefix)
sawCode := false
for _, f := range changedFiles {
pats := testPatternsByExt[strings.ToLower(filepath.Ext(f))]
if len(pats) == 0 {
continue
}
sawCode = true
ok := false
for _, p := range pats {
if fragments[p] {
ok = true
break
}
}
if !ok {
return false
}
}
return sawCode
}
func (s *Server) reviewImpact(changed []analysis.ChangedSymbol) map[string]*analysis.ImpactResult {
if len(changed) == 0 {
return nil
}
communities := s.getCommunities()
processes := s.getProcesses()
out := make(map[string]*analysis.ImpactResult, len(changed))
for _, cs := range changed {
if cs.ID == "" {
continue
}
if ir := analysis.AnalyzeImpact(s.graph, []string{cs.ID}, communities, processes); ir != nil {
out[cs.ID] = ir
}
}
if len(out) == 0 {
return nil
}
return out
}
// reviewLLMGenWithUsage returns the usage-aware LLM seam the review flow runs
// through so the report carries a per-review CostBreakdown: a closure over the
// optional LLM service's usage-aware Generate, or nil when the LLM phase is not
// engaged (caller opted out, no service, or the service is disabled). A nil
// seam disables the MAIN/RELOCATE phases, and still yields a zero
// (un-Estimated) cost block from RunWithUsage.
func (s *Server) reviewLLMGenWithUsage(useLLM bool) review.LLMGenWithUsage {
if !useLLM {
return nil
}
// Test-only seam: a non-nil usage-aware override stands in for the real
// provider so the cost-bearing review path can be driven without a backend.
if s.reviewLLMGenWithUsageOverride != nil {
return s.reviewLLMGenWithUsageOverride()
}
// Backward-compatible test seam: a test that only set the plain
// reviewLLMGenOverride (no usage) still drives the LLM phase — adapt it up
// to the usage-aware shape, reporting zero usage (so the cost block is a
// zero, un-Estimated breakdown, exactly as a no-usage provider would).
if s.reviewLLMGenOverride != nil {
plain := s.reviewLLMGenOverride()
if plain == nil {
return nil
}
return func(ctx context.Context, prompt string, maxTokens int) (string, llm.TokenUsage, error) {
text, err := plain(ctx, prompt, maxTokens)
return text, llm.TokenUsage{}, err
}
}
if s.llmService == nil || !s.llmService.Enabled() {
return nil
}
return func(ctx context.Context, prompt string, maxTokens int) (string, llm.TokenUsage, error) {
return s.llmService.GenerateWithUsage(ctx, prompt, maxTokens)
}
}
// reviewPricing resolves the rate card the review cost block prices token usage
// against: the test override when set, else the active LLM provider's pricing,
// else a zero card (a zero USD estimate, never an omitted cost block).
func (s *Server) reviewPricing() llm.ProviderPricing {
if s.reviewPricingOverride != nil {
return *s.reviewPricingOverride
}
if s.llmService != nil {
return s.llmService.Pricing()
}
return llm.ProviderPricing{}
}
// reviewConfig loads the repo's `review:` block (the gate / depth / rule
// knobs). A nil config manager — the legacy / test-without-config path —
// yields a zero ReviewConfig, which is a pass-through: the gate drops nothing
// and the depth classifier falls back to its built-in default ladder, so
// today's behaviour is preserved exactly.
func (s *Server) reviewConfig(repo string) config.ReviewConfig {
if s.configManager == nil {
return config.ReviewConfig{}
}
cfg := s.configManager.GetRepoConfig(repo)
if cfg == nil {
return config.ReviewConfig{}
}
return cfg.Review
}
// reviewRuleResolver builds the 4-layer review rule resolver rooted at the
// repo. An empty repoRoot skips the repo-local / project layers, leaving the
// global + embedded layers — so the resolver always resolves (the embedded
// `**` catch-all guarantees it). A construction error (a malformed rule file)
// degrades to nil: the flow then carries no per-file rule grounding rather than
// failing the whole review.
func (s *Server) reviewRuleResolver(repoRoot string) *review.RuleResolver {
resolver, err := review.NewRuleResolver("", repoRoot)
if err != nil {
return nil
}
return resolver
}
// reviewPayload projects a ReviewReport onto the review tool's wire shape: the
// verdict envelope plus the line-anchored inline comments derived from the
// report's findings.
func reviewPayload(report *review.ReviewReport) map[string]any {
commentRows := make([]map[string]any, 0)
fileRisk := make([]map[string]any, 0)
verdict := ""
summary := ""
depth := ""
var stats any = map[string]any{}
var cost map[string]any
if report != nil {
verdict = string(report.Verdict)
summary = report.Summary
stats = report.Stats
depth = report.Depth
cost = reviewCostMap(report.Cost)
for _, f := range report.Findings {
line := f.Line
if line == 0 {
line = f.StartLine
}
row := map[string]any{
"file": f.File,
"line": line,
"severity": string(f.Severity),
"message": f.Message,
"rule": f.Rule,
"category": f.Category,
"source": f.Source,
}
// Expose the stable identity key so an agent can suppress this exact
// finding via suppress_finding without re-deriving it.
if f.IdentityKey != "" {
row["identity_key"] = f.IdentityKey
}
commentRows = append(commentRows, row)
}
for _, fr := range report.FileRisk {
fileRisk = append(fileRisk, map[string]any{
"file": fr.File,
"risk": fr.Risk,
"findings": fr.Findings,
"affected": fr.Affected,
"symbols": fr.Symbols,
"uncovered": fr.Uncovered,
})
}
}
payload := map[string]any{
"verdict": verdict,
"summary": summary,
"comments": commentRows,
"file_risk": fileRisk,
"total": len(commentRows),
"stats": stats,
"depth": depth,
}
// The gate suppression summary rides on stats.Gate; lift it to the top
// level too so an agent can read "N findings suppressed" without decoding
// the whole stats block.
if report != nil {
payload["gate"] = report.Stats.Gate
}
// The per-review cost block is present only when the review ran through the
// usage-aware seam (it always does now), so include it whenever non-nil.
if cost != nil {
payload["cost"] = cost
}
return payload
}
// reviewCostMap projects a CostBreakdown onto the review response's wire shape:
// the token split, the USD estimate, whether the estimate is grounded in a real
// usage report, and the LLM wall-clock. A nil cost (the deterministic-only Run
// path) yields nil so the cost key is simply omitted.
func reviewCostMap(cost *review.CostBreakdown) map[string]any {
if cost == nil {
return nil
}
return map[string]any{
"input_tokens": cost.InputTokens,
"output_tokens": cost.OutputTokens,
"cache_read_tokens": cost.CacheReadTokens,
"cache_write_tokens": cost.CacheWriteTokens,
"usd": cost.USD,
"estimated": cost.Estimated,
"elapsed_ms": cost.ElapsedMs,
}
}
// classifiedSymbol is one changed symbol with its coarse change class
// (feature/fix/refactor/test/config) and its impact-derived risk tier.
type classifiedSymbol struct {
ID string `json:"id"`
Name string `json:"name"`
Class string `json:"class"`
Risk string `json:"risk"`
}
// reviewEnvelope is the single packaged PR-review result: the worst-of verdict
// (review report, upgraded by contract/guard breaks), the per-symbol semantic
// classification, the per-file risk ranking, the line-anchored findings, the
// contract + guard gate results, the impacted test targets, the concrete
// verification command, the privacy-safe risk receipt, and an optional tiered
// review pack.
type reviewEnvelope struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
ChangedSymbols []classifiedSymbol `json:"changed_symbols"`
FileRisk []review.FileRisk `json:"file_risk"`
Findings []inlineComment `json:"findings"`
Contracts *contractImpact `json:"contracts,omitempty"`
Guards []analysis.GuardViolation `json:"guards"`
HighRiskPreviews []reviewPreview `json:"high_risk_previews,omitempty"`
TestTargets []string `json:"test_targets"`
VerificationCommand string `json:"verification_command"`
Receipt analysis.ReviewReceipt `json:"receipt"`
Pack *review.ReviewPack `json:"pack,omitempty"`
// Depth is the adaptive review depth the changeset classified into
// (quick | standard | deep) — set from the review report.
Depth string `json:"depth"`
// Gate is the confidence / severity / category / cap suppression summary
// the gate produced over the merged findings.
Gate review.GateStats `json:"gate"`
// Cost is the per-review token + USD accounting. Nil only when no
// usage-aware seam ran (cannot happen on the live path now).
Cost *review.CostBreakdown `json:"cost,omitempty"`
}
// reviewPreview is the cost-bounded speculative-edit preview run for a single
// high-risk (depth-1-heavy) changed symbol: the broken callers/implementors and
// the impact rollup the simulation produced from a no-op rewrite of the symbol's
// own range.
type reviewPreview struct {
SymbolID string `json:"symbol_id"`
BrokenCallers int `json:"broken_callers"`
BrokenImplementors int `json:"broken_implementors"`
Summary string `json:"summary"`
}
// highRiskD1Threshold is the direct-dependent (depth-1) count at or above which a
// changed symbol is "high-risk" and earns a cost-bounded preview_edit simulation.
// Keeping the bound high means the speculative pass runs only for the few symbols
// a heavily-depended-on change touches, never the whole changeset.
const highRiskD1Threshold = 5
// handleReviewPack runs the full PR-review gate set over a changeset and folds
// the result into one packaged envelope. It composes the existing handlers /
// analysis — it never re-implements a gate:
//
// MapGitDiff → the changed symbols + files
// AnalyzeImpact → per-symbol blast radius (risk ranking + d=1 high-risk set)
// review.Run → the verdict report (findings + per-file risk + summary)
// computeContractImpact → contract-boundary breaks
// EvaluateGuards/Architecture → guard + layering violations
// get-test-targets walk → the impacted test files → verification_command
// buildSimulation → a cost-bounded preview for the high-risk d=1 symbols only
// ScorePRRisk/BuildReviewReceipt → the privacy-safe receipt (scrub passthrough)
//
// The verdict is the review report's worst-of, upgraded to BLOCK when a contract
// breaks or any guard/architecture rule is violated (mirroring the contract-risk
// upgrade the enhanced change-impact handler applies).
func (s *Server) handleReviewPack(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("no graph available — index a repo first"), nil
}
diffText := strings.TrimSpace(req.GetString("diff", ""))
scope, baseRef := siblingDiffScope(req)
repo := strings.TrimSpace(req.GetString("repo", ""))
repoRoot, repoPrefix := s.diffRepoScope(ctx, repo)
if repoRoot == "" && diffText == "" {
return mcp.NewToolResultError("could not resolve a repository root for the changeset diff"), nil
}
// Enumerate the changeset (on-disk path only — a pasted diff has no graph
// changeset, so the gates that need indexed symbols are skipped).
var (
diff *analysis.DiffResult
rulepack []astquery.Match
impact map[string]*analysis.ImpactResult
ids []string
)
if diffText == "" {
d, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff = d
allowedRepos, err := s.resolveRepoFilter(ctx, req)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, allowedRepos)
impact = s.reviewImpact(diff.ChangedSymbols)
for _, cs := range diff.ChangedSymbols {
if cs.ID != "" {
ids = append(ids, cs.ID)
}
}
}
// The review report (deterministic rulepack always; LLM phase gated).
useLLM := requestBoolDefault(req, "use_llm", false)
gen := s.reviewLLMGenWithUsage(useLLM)
suppStore, suppRepoKey := s.reviewSuppressions()
report, err := review.RunWithUsage(ctx, s.graph, gen, s.reviewPricing(), review.Options{
RepoRoot: repoRoot,
RepoPrefix: repoPrefix,
CoverageKnown: diff != nil && s.coverageKnownForDiff(repoPrefix, diff.ChangedFiles),
Scope: scope,
BaseRef: baseRef,
Diff: diffText,
RulepackMatches: rulepack,
Impact: impact,
Rules: s.reviewRuleResolver(repoRoot),
Config: s.reviewConfig(repo),
UseLLM: useLLM && gen != nil,
TokenBudget: intArg(req.GetArguments(), "max_tokens", 0),
Suppressions: suppStore,
RepoKey: suppRepoKey,
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Gate: contract-boundary impact.
contracts := s.computeContractImpact(ids)
// Gate: guard + architecture rules.
var guards []analysis.GuardViolation
if len(ids) > 0 && (len(s.guardRules) > 0 || !s.architecture.IsEmpty()) {
guards = analysis.EvaluateGuards(s.graph, s.guardRules, ids)
guards = append(guards, analysis.EvaluateArchitecture(s.graph, s.architecture, ids)...)
}
// Verdict = the review report's worst-of, upgraded to BLOCK on any contract
// break or guard/architecture violation.
verdict := report.Verdict
if (contracts != nil && contracts.Breaking > 0) || len(guards) > 0 {
verdict = review.VerdictBlock
}
// Per-symbol semantic classification, graph-grounded on the diff-hunk text.
changedSyms := s.classifyChangedSymbols(diff, impact)
// Impacted test targets → the concrete verification command.
testTargets := s.reviewTestTargets(ctx, ids)
verCmd := review.VerificationCommand(testTargets, reviewPackLang(diff))
// Cost bound: run a speculative preview_edit for the high-risk (d=1-heavy)
// changed symbols only — never the whole changeset.
previews := s.highRiskPreviews(ctx, diff, impact)
// Privacy-safe risk receipt over the whole changeset.
scrub := requestBoolDefault(req, "scrub", false)
receipt := s.reviewReceipt(ids, diff, contracts, guards, scrub)
env := reviewEnvelope{
Verdict: string(verdict),
Summary: report.Summary,
ChangedSymbols: changedSyms,
FileRisk: report.FileRisk,
Findings: reviewFindingsToComments(report),
Contracts: contracts,
Guards: guards,
HighRiskPreviews: previews,
TestTargets: testTargets,
VerificationCommand: verCmd,
Receipt: receipt,
Depth: report.Depth,
Gate: report.Stats.Gate,
Cost: report.Cost,
}
if requestBoolDefault(req, "include_pack", false) {
env.Pack = s.buildReviewPack(diff, impact, intArg(req.GetArguments(), "max_tokens", 0))
}
payload := reviewPackPayload(env)
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeReviewPack(payload))
}
if s.isTOON(ctx, req) {
return returnTOON(payload)
}
return s.respondJSONOrTOON(ctx, req, payload)
}
// classifyChangedSymbols stamps a change class + impact-risk tier on every
// changed symbol. The class is graph-grounded on the symbol's diff-hunk text;
// the risk tier is the symbol's AnalyzeImpact tier (LOW when no impact entry).
func (s *Server) classifyChangedSymbols(diff *analysis.DiffResult, impact map[string]*analysis.ImpactResult) []classifiedSymbol {
if diff == nil {
return []classifiedSymbol{}
}
view, _ := s.reviewChangeView(diff)
out := make([]classifiedSymbol, 0, len(diff.ChangedSymbols))
for _, cs := range diff.ChangedSymbols {
hunk := review.SymbolHunk(s.graph, view, cs)
risk := string(analysis.RiskLow)
if impact != nil {
if ir := impact[cs.ID]; ir != nil && ir.Risk != "" {
risk = string(ir.Risk)
}
}
out = append(out, classifiedSymbol{
ID: cs.ID,
Name: cs.Name,
Class: review.ClassifyChange(s.graph, cs, hunk),
Risk: risk,
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// reviewChangeView builds the ChangeView the classify/pack rendering reads its
// diff-hunk text from. Errors degrade to a nil view (the renderers tolerate it).
func (s *Server) reviewChangeView(diff *analysis.DiffResult) (*review.ChangeView, *analysis.DiffResult) {
repoRoot := ""
if s.indexer != nil {
repoRoot = s.indexer.RootPath()
}
view, err := review.BuildChangeView(s.graph, repoRoot, s.diffJoinPrefix(repoRoot), "", "")
if err != nil {
return nil, diff
}
return view, diff
}
// reviewTestTargets walks the impacted symbols to their covering test files,
// reusing the same EdgeTests / caller walk get_test_targets uses, and returns
// the distinct test-file paths.
func (s *Server) reviewTestTargets(ctx context.Context, ids []string) []string {
files := map[string]bool{}
eng := s.engineFor(ctx)
for _, id := range ids {
node := eng.GetSymbol(id)
if node == nil {
continue
}
// Fast path: the persistent test-edge inverse walk.
if testers := eng.GetTesters(id); len(testers) > 0 {
for _, tn := range testers {
if tn != nil && tn.FilePath != "" {
files[cleanPathMCP(tn.FilePath)] = true
}
}
continue
}
// Fallback: BFS callers, keep the ones in test files.
callers := eng.GetCallers(id, query.QueryOptions{Depth: 3, Limit: 100, Detail: "brief"})
for _, cn := range callers.Nodes {
if isTestFile(cn.FilePath) {
files[cleanPathMCP(cn.FilePath)] = true
}
}
if isTestFile(node.FilePath) {
files[cleanPathMCP(node.FilePath)] = true
}
}
out := make([]string, 0, len(files))
for f := range files {
out = append(out, f)
}
sort.Strings(out)
return out
}
// highRiskPreviews runs a cost-bounded speculative preview for each high-risk
// changed symbol — one whose direct-dependent (depth-1) count meets the
// threshold. A no-op rewrite of the symbol's own range is fed to buildSimulation
// so the broken-callers / impact rollup is computed without touching disk. Only
// the high-risk subset is simulated, so the pass never scales with the whole
// changeset.
func (s *Server) highRiskPreviews(ctx context.Context, diff *analysis.DiffResult, impact map[string]*analysis.ImpactResult) []reviewPreview {
if diff == nil || impact == nil {
return nil
}
var previews []reviewPreview
for _, cs := range diff.ChangedSymbols {
ir := impact[cs.ID]
if ir == nil || len(ir.ByDepth[1]) < highRiskD1Threshold {
continue
}
edit, ok := s.identityEditForSymbol(cs.ID)
if !ok {
continue
}
sim, err := s.buildSimulation(ctx, []lsp.WorkspaceEdit{edit}, false)
if err != nil || len(sim.steps) == 0 {
continue
}
step := sim.steps[0]
previews = append(previews, reviewPreview{
SymbolID: cs.ID,
BrokenCallers: len(step.brokenCallers),
BrokenImplementors: len(step.brokenImplementors),
Summary: step.summary,
})
}
sort.SliceStable(previews, func(i, j int) bool { return previews[i].SymbolID < previews[j].SymbolID })
return previews
}
// identityEditForSymbol builds a no-op WorkspaceEdit that rewrites a symbol's own
// [StartLine,EndLine] range with its current source. Fed to buildSimulation it
// yields the symbol's broken-callers / impact preview without changing disk. Only
// nodes with a known range under the indexed root are eligible.
func (s *Server) identityEditForSymbol(id string) (lsp.WorkspaceEdit, bool) {
n := s.graph.GetNode(id)
if n == nil || n.StartLine <= 0 || n.FilePath == "" {
return lsp.WorkspaceEdit{}, false
}
abs, err := s.resolveOverlayAbsPath(n.FilePath)
if err != nil || abs == "" {
return lsp.WorkspaceEdit{}, false
}
data, rerr := os.ReadFile(abs)
if rerr != nil {
return lsp.WorkspaceEdit{}, false
}
lines := strings.Split(string(data), "\n")
end := n.EndLine
if end < n.StartLine {
end = n.StartLine
}
if n.StartLine > len(lines) {
return lsp.WorkspaceEdit{}, false
}
if end > len(lines) {
end = len(lines)
}
src := strings.Join(lines[n.StartLine-1:end], "\n") + "\n"
edit := lsp.WorkspaceEdit{
Changes: map[string][]lsp.TextEdit{
n.FilePath: {{
Range: lsp.Range{
Start: lsp.Position{Line: n.StartLine - 1, Character: 0},
End: lsp.Position{Line: end, Character: 0},
},
NewText: src,
}},
},
}
return edit, true
}
// reviewReceipt scores PR-level risk over the changeset and projects it to the
// privacy-safe receipt. A contract break or guard violation flags the
// out-of-band hard blocker so the receipt's merge_blocker reflects the gates.
func (s *Server) reviewReceipt(ids []string, diff *analysis.DiffResult, ci *contractImpact, guards []analysis.GuardViolation, scrub bool) analysis.ReviewReceipt {
var changedFiles []string
if diff != nil {
changedFiles = diff.ChangedFiles
}
communities := s.getCommunities()
var nodeToComm map[string]string
if communities != nil {
nodeToComm = communities.NodeToComm
}
result := analysis.ScorePRRisk(s.graph, analysis.PRRiskInput{
SymbolIDs: ids,
ChangedFiles: changedFiles,
NodeToComm: nodeToComm,
Communities: communities,
Processes: s.getProcesses(),
})
blocker := (ci != nil && ci.Breaking > 0) || len(guards) > 0
return analysis.BuildReviewReceipt(result, "NONE", blocker, scrub)
}
// buildReviewPack renders the FG9 tiered review pack for the envelope: changed
// symbols as diff hunks, direct callers as full source, the rest as an outline.
func (s *Server) buildReviewPack(diff *analysis.DiffResult, impact map[string]*analysis.ImpactResult, budget int) *review.ReviewPack {
if diff == nil {
return nil
}
view, _ := s.reviewChangeView(diff)
return review.BuildReviewPack(s.graph, view, diff, review.MergeImpact(impact), budget)
}
// reviewFindingsToComments projects the report's findings onto the line-anchored
// inline-comment shape the envelope carries.
func reviewFindingsToComments(report *review.ReviewReport) []inlineComment {
out := make([]inlineComment, 0)
if report == nil {
return out
}
for _, f := range report.Findings {
line := f.Line
if line == 0 {
line = f.StartLine
}
out = append(out, inlineComment{
File: f.File,
Line: line,
Severity: string(f.Severity),
Message: f.Message,
Rule: f.Rule,
Category: f.Category,
Source: f.Source,
})
}
return out
}
// reviewPackLang infers the dominant language of the changeset from the changed
// file extensions so the verification command targets the right toolchain.
func reviewPackLang(diff *analysis.DiffResult) string {
if diff == nil {
return ""
}
counts := map[string]int{}
for _, f := range diff.ChangedFiles {
switch strings.ToLower(filepath.Ext(f)) {
case ".go":
counts["go"]++
case ".py":
counts["python"]++
case ".ts", ".tsx":
counts["typescript"]++
case ".js", ".jsx":
counts["javascript"]++
case ".rs":
counts["rust"]++
}
}
best, bestN := "", 0
for lang, n := range counts {
if n > bestN || (n == bestN && lang < best) {
best, bestN = lang, n
}
}
return best
}
// cleanPathMCP cleans a graph-relative path to slash form for the test-target set.
func cleanPathMCP(p string) string {
return filepath.ToSlash(filepath.Clean(strings.TrimSpace(p)))
}
// reviewPackPayload projects the envelope onto the wire map shape. The map form
// keeps the GCX / TOON encoders and the byte/token budget machinery on the same
// path as the rest of the review surface.
func reviewPackPayload(env reviewEnvelope) map[string]any {
changed := make([]map[string]any, 0, len(env.ChangedSymbols))
for _, c := range env.ChangedSymbols {
changed = append(changed, map[string]any{
"id": c.ID,
"name": c.Name,
"class": c.Class,
"risk": c.Risk,
})
}
fileRisk := make([]map[string]any, 0, len(env.FileRisk))
for _, fr := range env.FileRisk {
fileRisk = append(fileRisk, map[string]any{
"file": fr.File,
"risk": fr.Risk,
"findings": fr.Findings,
"affected": fr.Affected,
"symbols": fr.Symbols,
"uncovered": fr.Uncovered,
})
}
findings := make([]map[string]any, 0, len(env.Findings))
for _, f := range env.Findings {
findings = append(findings, map[string]any{
"file": f.File,
"line": f.Line,
"severity": f.Severity,
"message": f.Message,
"rule": f.Rule,
"category": f.Category,
"source": f.Source,
})
}
previews := make([]map[string]any, 0, len(env.HighRiskPreviews))
for _, p := range env.HighRiskPreviews {
previews = append(previews, map[string]any{
"symbol_id": p.SymbolID,
"broken_callers": p.BrokenCallers,
"broken_implementors": p.BrokenImplementors,
"summary": p.Summary,
})
}
payload := map[string]any{
"verdict": env.Verdict,
"summary": env.Summary,
"changed_symbols": changed,
"file_risk": fileRisk,
"findings": findings,
"guards": env.Guards,
"test_targets": env.TestTargets,
"verification_command": env.VerificationCommand,
"receipt": env.Receipt,
"total": len(findings),
"depth": env.Depth,
"gate": env.Gate,
}
if cost := reviewCostMap(env.Cost); cost != nil {
payload["cost"] = cost
}
if env.Contracts != nil {
payload["contracts"] = env.Contracts
}
if len(previews) > 0 {
payload["high_risk_previews"] = previews
}
if env.Pack != nil {
payload["pack"] = env.Pack
}
return payload
}