Files
zzet--gortex/internal/mcp/tools_pr_review_context.go
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

682 lines
26 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/audit"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/semantic/lsp"
)
// registerPRReviewContextTool wires pr_review_context — a one-call,
// LLM-free, deterministic rollup of the analysis-layer review gates for a
// changeset. It composes the existing diff-context, signature-verification,
// agent-config-audit, and (optionally) simulation passes into a single
// envelope with a top-level verdict tying them together. Side-effect free:
// the simulation path layers a shadow view on top of the base graph and
// never writes disk or mutates the base graph.
//
// It is the lightweight sibling of review_pack: where review_pack runs the
// full review engine (LLM findings, per-symbol classification, the pack),
// pr_review_context answers the cheap question "what do the deterministic
// gates say about this diff" with no LLM round-trip. Deferred: it lands in
// the lazy catalog unless lazy tools are disabled.
func (s *Server) registerPRReviewContextTool() {
s.addTool(
mcp.NewTool("pr_review_context",
mcp.WithDescription("Assemble a deterministic, LLM-free PR-review rollup for a changeset in one call. Composes up to five sections: diff_context (graph-enriched changed-symbol context — signatures, callers, callees, community, per-file risk), verify_change (broken callers / interface implementors a signature edit would introduce), simulate_chain (impact of an optional edit chain — gated: only runs when an explicit overlay session id is supplied), audit_agent_config (stale-symbol / dead-path / bloat drift in the repo's agent-config files), plus a composite verdict (PASS / WARN / BLOCK) derived from the worst section. Pass `base` (a git ref — diff scopes against it) or `scope`/`base_ref`; `edits` (a JSON array of LSP WorkspaceEdit objects) plus `session_id` (the overlay session to layer them on) to enable the simulation section. Side-effect free: disk and the base graph are never mutated. The cheap deterministic counterpart to review_pack — escalate to review_pack for LLM-grade inline findings."),
mcp.WithString("base", mcp.Description("Base git ref (e.g. main). Diffs HEAD against it (compare scope). Convenience alias for scope=compare + base_ref.")),
mcp.WithString("scope", mcp.Description("Diff scope: unstaged (default), staged, all, or compare. Ignored when `base` is set.")),
mcp.WithString("base_ref", mcp.Description("Base ref for compare scope (default main). Ignored when `base` is set.")),
mcp.WithString("ids", mcp.Description("Comma-separated changed symbol IDs to scope the rollup to. Takes precedence over the git diff when supplied.")),
mcp.WithString("repo", mcp.Description("Repository prefix to resolve the working tree (multi-repo mode).")),
mcp.WithBoolean("verify", mcp.Description("Include the verify_change section — broken callers / implementors a signature edit would introduce (default true).")),
mcp.WithBoolean("audit_config", mcp.Description("Include the audit_agent_config section — stale-symbol / dead-path / bloat drift in the repo's agent-config files (default true).")),
mcp.WithString("edits", mcp.Description("JSON array of LSP WorkspaceEdit objects. With `session_id` set, the simulation section runs the chain against that overlay session and reports the per-step impact. Without a session id the simulation section is omitted with a note (no synthesized session).")),
mcp.WithString("session_id", mcp.Description("Overlay session id the edit chain layers on top of. Required to enable the simulate_chain section — it gates the simulation behind an explicit, caller-supplied session so it never runs against a synthesized or empty session.")),
mcp.WithString("sections", mcp.Description("Comma-separated subset of {diff_context,verify,simulate,audit_config} to emit. Omit for all requested sections. Drops heavy blocks an agent does not need.")),
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; truncation metadata rides on the response. Omit for no cap.")),
mcp.WithNumber("max_tokens", mcp.Description("Cap the marshaled response at approximately this many tokens. Composable with max_bytes — tighter wins. Omit for no cap.")),
),
s.handlePRReviewContext,
)
}
// PR-review verdict tokens. The worst section sets the overall verdict.
const (
prReviewPass = "PASS"
prReviewWarn = "WARN"
prReviewBlock = "BLOCK"
)
// reviewGate is one named deterministic gate's outcome — the section name,
// its PASS/WARN/BLOCK status, and a one-line human-readable detail.
type reviewGate struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
}
// prReviewContext is the one-call rollup envelope. Each optional section is
// populated only when requested and when there is something to report; the
// gates slice always carries one row per evaluated section so the verdict is
// auditable.
type prReviewContext struct {
Verdict string `json:"verdict"`
Gates []reviewGate `json:"gates"`
ChangedFiles []string `json:"changed_files"`
ChangedSymbols int `json:"changed_symbols"`
DiffContext []diffContextSymbol `json:"diff_context,omitempty"`
Impact *prReviewImpact `json:"impact,omitempty"`
Contracts *contractImpact `json:"contracts,omitempty"`
Verify *analysis.VerifyResult `json:"verify,omitempty"`
Guards []analysis.GuardViolation `json:"guards,omitempty"`
Simulation *prReviewSimulation `json:"simulation,omitempty"`
ConfigAudit *audit.Report `json:"config_audit,omitempty"`
}
// diffContextSymbol is the graph-enriched view of one changed symbol: its
// signature, depth-1 callers/callees, community, affected processes, the
// file it lives in, and that file's blast-radius risk tier.
type diffContextSymbol struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
Line int `json:"start_line"`
Risk string `json:"risk"`
Signature string `json:"signature,omitempty"`
Callers []string `json:"callers,omitempty"`
Callees []string `json:"callees,omitempty"`
Community string `json:"community,omitempty"`
Processes []string `json:"processes,omitempty"`
}
// prReviewImpact is the composite blast-radius summary over the whole
// changeset — the worst risk tier and the total affected count.
type prReviewImpact struct {
Risk string `json:"risk"`
TotalAffected int `json:"total_affected"`
Summary string `json:"summary,omitempty"`
}
// prReviewSimulation carries the simulation section. When the section could
// not run (no overlay session, no edits, or a build error) Ran is false and
// Note explains why so the caller sees an omitted-with-note section rather
// than a panic or a silent drop.
type prReviewSimulation struct {
Ran bool `json:"ran"`
Note string `json:"note,omitempty"`
SessionID string `json:"session_id,omitempty"`
TotalSteps int `json:"total_steps,omitempty"`
Steps []map[string]any `json:"steps,omitempty"`
Cumulative map[string]any `json:"cumulative,omitempty"`
GraphUntouched bool `json:"graph_untouched"`
}
// changedSymbolsToSignatureChanges derives the verify_change input from the
// changeset: for every changed symbol that has an indexed signature, a
// SignatureChange echoing the current signature. Echoing the existing
// signature lets VerifyChanges walk the caller / implementor surface for the
// changed set without the caller having to hand-author proposed signatures —
// the broken-edge gate is "does this symbol's surface still hold for everyone
// who depends on it". Symbols with no recorded signature are skipped.
func changedSymbolsToSignatureChanges(g graph.Store, changed []analysis.ChangedSymbol) []analysis.SignatureChange {
if g == nil {
return nil
}
var out []analysis.SignatureChange
for _, cs := range changed {
if cs.ID == "" {
continue
}
node := g.GetNode(cs.ID)
if node == nil || node.Meta == nil {
continue
}
sig, ok := node.Meta["signature"].(string)
if !ok || strings.TrimSpace(sig) == "" {
continue
}
out = append(out, analysis.SignatureChange{
SymbolID: cs.ID,
NewSignature: sig,
})
}
return out
}
// handlePRReviewContext runs the deterministic review gates for the
// changeset and returns the composite envelope.
func (s *Server) handlePRReviewContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("no graph available — index a repo first"), nil
}
wantSection := prReviewSectionSet(req.GetString("sections", ""))
wantVerify := req.GetBool("verify", true) && wantSection("verify")
wantAudit := req.GetBool("audit_config", true) && wantSection("audit_config")
wantDiffCtx := wantSection("diff_context")
wantSimulate := wantSection("simulate")
repoRoot := s.prReviewRepoRoot(ctx, req)
scope, baseRef := siblingDiffScope(req)
// Enumerate the changeset. Caller-supplied ids take precedence; otherwise
// map the git diff to changed symbols.
var (
diff *analysis.DiffResult
ids []string
)
if idsArg := strings.TrimSpace(req.GetString("ids", "")); idsArg != "" {
diff = s.prReviewDiffFromIDs(idsArg)
for _, cs := range diff.ChangedSymbols {
ids = append(ids, cs.ID)
}
} else {
if repoRoot == "" {
return mcp.NewToolResultError("could not resolve a repository root for the changeset diff"), nil
}
d, err := analysis.MapGitDiff(s.graph, repoRoot, s.diffJoinPrefix(repoRoot), scope, baseRef)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff = d
for _, cs := range diff.ChangedSymbols {
if cs.ID != "" {
ids = append(ids, cs.ID)
}
}
}
out := prReviewContext{
ChangedFiles: diff.ChangedFiles,
ChangedSymbols: len(diff.ChangedSymbols),
}
// --- section: verify_change (computed FIRST) ---
//
// VerifyChanges reads the changed symbol's old signature and every
// implementor's signature from node.Meta. The impact / diff_context
// passes below run AnalyzeImpact and brief engine queries that re-stamp
// (and, for the brief-detail path, can empty) node.Meta in place — so the
// signature surface must be read before any of them runs. We compute the
// verify result here and append its gate in section order below.
var (
verifyResult *analysis.VerifyResult
verifyGate *reviewGate
)
if wantVerify {
changes := changedSymbolsToSignatureChanges(s.graph, diff.ChangedSymbols)
if len(changes) > 0 {
verifyResult = analysis.VerifyChanges(s.graph, s.engine, changes)
out.Verify = verifyResult
g := prReviewVerifyGate(verifyResult)
verifyGate = &g
} else {
verifyGate = &reviewGate{
Name: "verify_change", Status: prReviewPass,
Detail: "no signature-bearing changed symbols to verify",
}
}
}
communities := s.getCommunities()
processes := s.getProcesses()
// --- composite impact (verdict input; also exposed) ---
if len(ids) > 0 {
imp := analysis.AnalyzeImpact(s.graph, ids, communities, processes)
out.Impact = &prReviewImpact{
Risk: string(imp.Risk),
TotalAffected: imp.TotalAffected,
Summary: imp.Summary,
}
out.Gates = append(out.Gates, prReviewImpactGate(imp.Risk))
}
// --- section: diff_context ---
if wantDiffCtx {
out.DiffContext = s.buildDiffContextSection(ctx, diff, communities, processes)
out.Gates = append(out.Gates, reviewGate{
Name: "diff_context", Status: prReviewPass,
Detail: detailf("%d changed symbol(s) across %d file(s)", len(out.DiffContext), len(diff.ChangedFiles)),
})
}
// --- contracts gate (always evaluated for the verdict) ---
if len(ids) > 0 {
contracts := s.computeContractImpact(ids)
out.Contracts = contracts
out.Gates = append(out.Gates, prReviewContractGate(contracts))
}
// --- section: verify_change gate (result computed above) ---
if verifyGate != nil {
out.Gates = append(out.Gates, *verifyGate)
}
// --- guards gate (architecture + co-change / boundary rules) ---
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)...)
out.Guards = guards
out.Gates = append(out.Gates, prReviewGuardGate(guards))
}
// --- section: simulate_chain (gated on an explicit overlay session) ---
if wantSimulate {
sim, gate := s.buildPRReviewSimulation(ctx, req)
out.Simulation = sim
out.Gates = append(out.Gates, gate)
}
// --- section: audit_agent_config ---
if wantAudit {
report := s.buildConfigAuditSection(repoRoot)
out.ConfigAudit = report
out.Gates = append(out.Gates, prReviewAuditGate(report))
}
out.Verdict = worstVerdict(out.Gates)
payload := prReviewContextPayload(out)
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodePRReviewContext(out))
}
return s.respondJSONOrTOON(ctx, req, payload)
}
// prReviewRepoRoot resolves the working-tree root for the changeset diff,
// mirroring the review tools' resolution order: an explicit repo selector
// (prefix or path), the lone tracked repo, or the session's cwd-bound repo.
func (s *Server) prReviewRepoRoot(ctx context.Context, req mcp.CallToolRequest) string {
repoRoot, _ := s.diffRepoScope(ctx, strings.TrimSpace(req.GetString("repo", "")))
return repoRoot
}
// prReviewDiffFromIDs synthesises a DiffResult from caller-supplied symbol
// IDs so the rollup can run without a working tree. Each ID resolves to its
// graph node for the name / kind / file, and the file set is unioned.
func (s *Server) prReviewDiffFromIDs(idsArg string) *analysis.DiffResult {
diff := &analysis.DiffResult{}
fileSet := map[string]bool{}
for _, raw := range strings.Split(idsArg, ",") {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
cs := analysis.ChangedSymbol{ID: id}
if node := s.graph.GetNode(id); node != nil {
cs.Name = node.Name
cs.Kind = string(node.Kind)
cs.FilePath = node.FilePath
cs.Line = node.StartLine
if node.FilePath != "" && !fileSet[node.FilePath] {
fileSet[node.FilePath] = true
diff.ChangedFiles = append(diff.ChangedFiles, node.FilePath)
}
}
diff.ChangedSymbols = append(diff.ChangedSymbols, cs)
}
sort.Strings(diff.ChangedFiles)
return diff
}
// buildDiffContextSection builds the graph-enriched changed-symbol context —
// the same per-symbol enrichment diff_context surfaces (signature, depth-1
// callers/callees, community, processes) plus the per-file blast-radius risk
// tier, capped to keep the section bounded.
func (s *Server) buildDiffContextSection(ctx context.Context, diff *analysis.DiffResult, communities *analysis.CommunityResult, processes *analysis.ProcessResult) []diffContextSymbol {
const cap = 50
engine := s.engineFor(ctx)
// Pre-compute per-file risk so every symbol in a file shares one tier.
fileIDs := map[string][]string{}
for _, cs := range diff.ChangedSymbols {
if cs.ID == "" {
continue
}
node := s.graph.GetNode(cs.ID)
if node == nil || node.FilePath == "" {
continue
}
fileIDs[node.FilePath] = append(fileIDs[node.FilePath], cs.ID)
}
fileRisk := map[string]string{}
for fp, fids := range fileIDs {
imp := analysis.AnalyzeImpact(s.graph, fids, communities, processes)
fileRisk[fp] = string(imp.Risk)
}
var syms []diffContextSymbol
for _, cs := range diff.ChangedSymbols {
if len(syms) >= cap {
break
}
node := s.graph.GetNode(cs.ID)
if node == nil {
continue
}
info := diffContextSymbol{
ID: cs.ID,
Name: cs.Name,
Kind: cs.Kind,
File: node.FilePath,
Line: cs.Line,
Risk: fileRisk[node.FilePath],
}
if sig, ok := node.Meta["signature"].(string); ok {
info.Signature = sig
}
callers := engine.GetCallers(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"})
for _, cn := range callers.Nodes {
if cn.ID != cs.ID {
info.Callers = append(info.Callers, cn.ID)
}
}
callees := engine.GetCallChain(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"})
for _, cn := range callees.Nodes {
if cn.ID != cs.ID {
info.Callees = append(info.Callees, cn.ID)
}
}
if communities != nil {
if cid, ok := communities.NodeToComm[cs.ID]; ok {
info.Community = cid
}
}
if processes != nil {
info.Processes = processes.NodeToProcs[cs.ID]
}
syms = append(syms, info)
}
return syms
}
// buildConfigAuditSection runs the agent-config drift audit over the repo's
// discovered config files. Returns nil when no root or no config files.
func (s *Server) buildConfigAuditSection(repoRoot string) *audit.Report {
root := repoRoot
if root == "" && s.indexer != nil {
root = s.indexer.RootPath()
}
if root == "" {
return nil
}
files := audit.DiscoverConfigFiles(root)
if len(files) == 0 {
return &audit.Report{Root: root, FilesScanned: 0}
}
return audit.Audit(s.graph, root, files)
}
// buildPRReviewSimulation runs the optional simulation section. It is gated
// behind an explicit overlay session id: buildSimulation can layer a shadow
// view on top of the base graph, but persisting / inheriting overlay state
// requires a real MCP session, and synthesising an empty one would silently
// diverge from the caller's actual buffers. So when no session id is supplied
// (via the `session_id` param or the request context) the section is omitted
// with a note rather than run against a phantom session. The base graph is
// never mutated regardless.
func (s *Server) buildPRReviewSimulation(ctx context.Context, req mcp.CallToolRequest) (*prReviewSimulation, reviewGate) {
editsArg := strings.TrimSpace(req.GetString("edits", ""))
if editsArg == "" {
return &prReviewSimulation{
Ran: false, GraphUntouched: true,
Note: "no `edits` supplied — pass a JSON array of WorkspaceEdit objects to run the simulation section",
}, reviewGate{
Name: "simulate_chain", Status: prReviewPass,
Detail: "skipped: no edits supplied",
}
}
sessionID := strings.TrimSpace(req.GetString("session_id", ""))
if sessionID == "" {
sessionID = SessionIDFromContext(ctx)
}
if sessionID == "" {
return &prReviewSimulation{
Ran: false, GraphUntouched: true,
Note: "simulation section skipped: pass an explicit `session_id` (an overlay session) — the simulation is not run against a synthesized or empty session",
}, reviewGate{
Name: "simulate_chain", Status: prReviewPass,
Detail: "skipped: no overlay session id",
}
}
edits, err := parsePRReviewEdits(editsArg)
if err != nil {
return &prReviewSimulation{
Ran: false, GraphUntouched: true, SessionID: sessionID,
Note: "invalid edits: " + err.Error(),
}, reviewGate{
Name: "simulate_chain", Status: prReviewWarn,
Detail: "invalid edits: " + err.Error(),
}
}
// Run the chain on top of the named session's overlay. buildSimulation
// reads the session via the request context, so thread the explicit
// session id onto the context it consumes.
simCtx := WithSessionID(ctx, sessionID)
sim, simErr := s.buildSimulation(simCtx, edits, true)
if simErr != nil {
return &prReviewSimulation{
Ran: false, GraphUntouched: true, SessionID: sessionID,
Note: "simulation failed: " + simErr.Error(),
}, reviewGate{
Name: "simulate_chain", Status: prReviewWarn,
Detail: "simulation failed: " + simErr.Error(),
}
}
steps := make([]map[string]any, 0, len(sim.steps))
brokenAny := false
for i, step := range sim.steps {
steps = append(steps, map[string]any{
"index": i,
"touched_files": step.touchedFiles,
"broken_callers": step.brokenCallers,
"broken_implementors": step.brokenImplementors,
"summary": step.summary,
})
if len(step.brokenCallers) > 0 || len(step.brokenImplementors) > 0 {
brokenAny = true
}
}
out := &prReviewSimulation{
Ran: true,
SessionID: sessionID,
TotalSteps: len(sim.steps),
Steps: steps,
Cumulative: sim.cumulative,
GraphUntouched: true,
}
status := prReviewPass
detail := detailf("%d step(s), no broken edges", len(sim.steps))
if brokenAny {
status = prReviewBlock
detail = detailf("%d step(s) introduce broken callers / implementors", len(sim.steps))
}
return out, reviewGate{Name: "simulate_chain", Status: status, Detail: detail}
}
// parsePRReviewEdits parses the `edits` JSON array into WorkspaceEdits,
// reusing the same per-edit parser the simulate_chain handler uses.
func parsePRReviewEdits(raw string) ([]lsp.WorkspaceEdit, error) {
var rawEdits []json.RawMessage
if err := json.Unmarshal([]byte(raw), &rawEdits); err != nil {
return nil, errors.New("edits must be a JSON array of WorkspaceEdit objects: " + err.Error())
}
if len(rawEdits) == 0 {
return nil, errors.New("edits array is empty")
}
edits := make([]lsp.WorkspaceEdit, 0, len(rawEdits))
for i, re := range rawEdits {
edit, err := parseWorkspaceEdit(string(re))
if err != nil {
return nil, fmt.Errorf("edit %d: %w", i, err)
}
if isEmptyEdit(edit) {
return nil, fmt.Errorf("edit %d: workspace_edit contains no document changes", i)
}
edits = append(edits, edit)
}
return edits, nil
}
// detailf is a thin fmt.Sprintf alias keeping the gate detail call sites terse.
func detailf(format string, a ...any) string { return fmt.Sprintf(format, a...) }
// prReviewSectionSet returns a predicate over the optional sections honouring
// a comma-separated `sections` filter. An empty filter enables everything.
func prReviewSectionSet(arg string) func(string) bool {
arg = strings.TrimSpace(arg)
if arg == "" {
return func(string) bool { return true }
}
want := map[string]bool{}
for _, s := range strings.Split(arg, ",") {
if s = strings.TrimSpace(s); s != "" {
want[s] = true
}
}
return func(name string) bool { return want[name] }
}
// prReviewImpactGate maps the composite blast-radius risk to a gate status:
// CRITICAL → BLOCK, HIGH → WARN, else PASS.
func prReviewImpactGate(risk analysis.RiskLevel) reviewGate {
g := reviewGate{Name: "impact", Status: prReviewPass, Detail: "blast radius " + string(risk)}
switch risk {
case analysis.RiskCritical:
g.Status = prReviewBlock
case analysis.RiskHigh:
g.Status = prReviewWarn
}
return g
}
// prReviewContractGate flags breaking contract drift as BLOCK, warnings as
// WARN.
func prReviewContractGate(c *contractImpact) reviewGate {
g := reviewGate{Name: "contracts", Status: prReviewPass, Detail: "no contract-boundary impact"}
if c == nil {
return g
}
switch {
case c.Breaking > 0:
g.Status = prReviewBlock
g.Detail = detailf("%d breaking contract change(s)", c.Breaking)
case c.Warning > 0:
g.Status = prReviewWarn
g.Detail = detailf("%d contract warning(s)", c.Warning)
default:
g.Detail = detailf("%d affected contract(s)", len(c.Affected))
}
return g
}
// prReviewVerifyGate flags any contract violation (broken caller / implementor)
// as BLOCK.
func prReviewVerifyGate(r *analysis.VerifyResult) reviewGate {
g := reviewGate{Name: "verify_change", Status: prReviewPass}
if r == nil || r.Clean || len(r.Violations) == 0 {
g.Detail = "no broken callers or implementors"
return g
}
g.Status = prReviewBlock
g.Detail = detailf("%d broken caller(s) / implementor(s)", len(r.Violations))
return g
}
// prReviewGuardGate flags any guard / architecture violation as BLOCK.
func prReviewGuardGate(v []analysis.GuardViolation) reviewGate {
g := reviewGate{Name: "guards", Status: prReviewPass, Detail: "no guard violations"}
if len(v) > 0 {
g.Status = prReviewBlock
g.Detail = detailf("%d guard / architecture violation(s)", len(v))
}
return g
}
// prReviewAuditGate flags agent-config drift (stale refs / dead paths) as
// WARN — drift is advisory, never a merge blocker.
func prReviewAuditGate(r *audit.Report) reviewGate {
g := reviewGate{Name: "audit_agent_config", Status: prReviewPass, Detail: "no agent-config drift"}
if r == nil {
g.Detail = "no agent-config files found"
return g
}
drift := len(r.StaleRefs) + len(r.DeadPaths)
if drift > 0 {
g.Status = prReviewWarn
g.Detail = detailf("%d stale ref(s) / dead path(s) across %d file(s)", drift, r.FilesScanned)
}
return g
}
// worstVerdict folds the gate statuses into the overall verdict: any BLOCK →
// BLOCK, else any WARN → WARN, else PASS.
func worstVerdict(gates []reviewGate) string {
verdict := prReviewPass
for _, g := range gates {
switch g.Status {
case prReviewBlock:
return prReviewBlock
case prReviewWarn:
verdict = prReviewWarn
}
}
return verdict
}
// prReviewContextPayload renders the envelope to a map so the JSON / TOON
// path and the GCX encoder share one field vocabulary. The struct already
// JSON-marshals cleanly; the map indirection keeps the budget degradation
// path (respondJSONOrTOON) able to trim the longest list.
func prReviewContextPayload(out prReviewContext) map[string]any {
m := map[string]any{
"verdict": out.Verdict,
"gates": out.Gates,
"changed_files": out.ChangedFiles,
"changed_symbols": out.ChangedSymbols,
}
if out.DiffContext != nil {
m["diff_context"] = out.DiffContext
}
if out.Impact != nil {
m["impact"] = out.Impact
}
if out.Contracts != nil {
m["contracts"] = out.Contracts
}
if out.Verify != nil {
m["verify"] = out.Verify
}
if out.Guards != nil {
m["guards"] = out.Guards
}
if out.Simulation != nil {
m["simulation"] = out.Simulation
}
if out.ConfigAudit != nil {
m["config_audit"] = out.ConfigAudit
}
return m
}