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
465 lines
15 KiB
Go
465 lines
15 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// registerInspectionsTools wires list_inspections + run_inspections.
|
|
//
|
|
// These two tools form the substrate the future D5 JetBrains plugin
|
|
// will surface as "run inspections" / "list inspections" in the IDE
|
|
// — same MCP-tool name as serena's JetBrains-only equivalent, but
|
|
// powered by gortex graph analyzers + LSP diagnostics + guards +
|
|
// contracts. Works today without any IDE plugin.
|
|
//
|
|
// Each inspection has:
|
|
// - id: stable identifier ("dead_code", "cycles", "todos", ...)
|
|
// - category: grouping for IDE display ("dead-code", "complexity",
|
|
// "concurrency", "guards", "contracts")
|
|
// - description: one-line summary
|
|
// - severity: default severity emitted for matches
|
|
// - run(...): the inspector implementation returning uniform
|
|
// violation rows
|
|
//
|
|
// New inspections plug in by adding an entry to the registry below.
|
|
func (s *Server) registerInspectionsTools() {
|
|
s.addTool(
|
|
mcp.NewTool("list_inspections",
|
|
mcp.WithDescription("Return the catalog of available inspections. Each entry has {id, category, description, severity}. Use as a discovery call before run_inspections to learn which inspector IDs exist on this server. Surface targeted by the D5 JetBrains plugin (when shipped) and consumable today by any agent that wants a structured view of what gortex can detect."),
|
|
mcp.WithString("category", mcp.Description("Filter to a single category (e.g. dead-code, complexity, concurrency, guards, contracts). Empty = return all.")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleListInspections,
|
|
)
|
|
|
|
s.addTool(
|
|
mcp.NewTool("run_inspections",
|
|
mcp.WithDescription("Run one or more inspections and return uniform violation rows: {inspection, severity, file, line, message, symbol_id?}. Set `inspections=all` to run every inspector, or pass a CSV of ids from list_inspections. Aggregated under a summary {by_inspection, total_violations} so the caller can present a punch list. Composes existing analyzers (dead_code, cycles, todos, unsafe_patterns, coverage_gaps, stale_code), guards, and contract checks — no JetBrains dependency."),
|
|
mcp.WithString("inspections", mcp.Description("Comma-separated inspection IDs to run, or `all` for the full catalog. Required.")),
|
|
mcp.WithString("path_prefix", mcp.Description("Scope every inspector to nodes under this file-path prefix.")),
|
|
mcp.WithString("severity", mcp.Description("Filter results to this severity (error / warning / info). Empty = no filter.")),
|
|
mcp.WithNumber("max_per_inspection", mcp.Description("Cap on violations per inspector (default: 50).")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleRunInspections,
|
|
)
|
|
}
|
|
|
|
// inspectionViolation is the wire row every inspector emits.
|
|
type inspectionViolation struct {
|
|
Inspection string `json:"inspection"`
|
|
Severity string `json:"severity"`
|
|
File string `json:"file,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
Message string `json:"message"`
|
|
SymbolID string `json:"symbol_id,omitempty"`
|
|
}
|
|
|
|
// inspectionSpec is the registry entry. run returns the inspector's
|
|
// violations bounded by the scope predicate (which may be nil for
|
|
// "no filter").
|
|
type inspectionSpec struct {
|
|
ID string
|
|
Category string
|
|
Description string
|
|
Severity string
|
|
Run func(s *Server, scope inspectionScope) []inspectionViolation
|
|
}
|
|
|
|
// inspectionScope is the call-side filter passed to every inspector.
|
|
// Keep it minimal — adding fields here forces every inspector to
|
|
// consider the new dimension.
|
|
type inspectionScope struct {
|
|
PathPrefix string
|
|
}
|
|
|
|
// inspect returns true if path is in scope.
|
|
func (sc inspectionScope) keep(path string) bool {
|
|
if sc.PathPrefix == "" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(path, sc.PathPrefix)
|
|
}
|
|
|
|
// inspectionRegistry is the single source of truth — list_inspections
|
|
// projects this; run_inspections looks up by id.
|
|
func inspectionRegistry() []inspectionSpec {
|
|
return []inspectionSpec{
|
|
{
|
|
ID: "dead_code", Category: "dead-code", Severity: "warning",
|
|
Description: "Functions/methods with zero incoming references (excluding test files, CGo exports, and entry-point heuristics).",
|
|
Run: runDeadCodeInspection,
|
|
},
|
|
{
|
|
ID: "cycles", Category: "complexity", Severity: "warning",
|
|
Description: "Circular dependency chains in the import / call graph.",
|
|
Run: runCyclesInspection,
|
|
},
|
|
{
|
|
ID: "todos", Category: "documentation", Severity: "info",
|
|
Description: "TODO / FIXME / HACK / XXX / NOTE comments extracted as KindTodo nodes.",
|
|
Run: runTodosInspection,
|
|
},
|
|
{
|
|
ID: "coverage_gaps", Category: "testing", Severity: "info",
|
|
Description: "Symbols with meta.coverage_pct < 100 (requires `gortex enrich coverage` to have populated the graph).",
|
|
Run: runCoverageGapsInspection,
|
|
},
|
|
{
|
|
ID: "stale_code", Category: "maintenance", Severity: "info",
|
|
Description: "Symbols whose meta.last_authored is older than 365 days (requires `gortex enrich blame`).",
|
|
Run: runStaleCodeInspection,
|
|
},
|
|
{
|
|
ID: "guards", Category: "guards", Severity: "error",
|
|
Description: "Project-specific guard rules — co-change and boundary violations evaluated against the scoped node set.",
|
|
Run: runGuardsInspection,
|
|
},
|
|
{
|
|
ID: "contracts_orphans", Category: "contracts", Severity: "warning",
|
|
Description: "Provider/consumer contracts with no matching counterpart in the active workspace.",
|
|
Run: runContractOrphansInspection,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleListInspections(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
categoryFilter := strings.TrimSpace(req.GetString("category", ""))
|
|
rows := []map[string]any{}
|
|
for _, spec := range inspectionRegistry() {
|
|
if categoryFilter != "" && spec.Category != categoryFilter {
|
|
continue
|
|
}
|
|
rows = append(rows, map[string]any{
|
|
"id": spec.ID,
|
|
"category": spec.Category,
|
|
"description": spec.Description,
|
|
"severity": spec.Severity,
|
|
})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
return rows[i]["id"].(string) < rows[j]["id"].(string)
|
|
})
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"inspections": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRunInspections(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
idsArg, err := req.RequireString("inspections")
|
|
if err != nil {
|
|
return mcp.NewToolResultError("`inspections` is required (CSV of ids, or `all`)"), nil
|
|
}
|
|
scope := inspectionScope{
|
|
PathPrefix: strings.TrimSpace(req.GetString("path_prefix", "")),
|
|
}
|
|
severity := strings.ToLower(strings.TrimSpace(req.GetString("severity", "")))
|
|
maxPer := max(req.GetInt("max_per_inspection", 50), 1)
|
|
|
|
all := inspectionRegistry()
|
|
want := map[string]bool{}
|
|
if strings.TrimSpace(idsArg) == "all" {
|
|
for _, sp := range all {
|
|
want[sp.ID] = true
|
|
}
|
|
} else {
|
|
for _, id := range splitCSV(idsArg) {
|
|
want[id] = true
|
|
}
|
|
}
|
|
|
|
results := []map[string]any{}
|
|
byInspection := map[string]int{}
|
|
totalViolations := 0
|
|
for _, sp := range all {
|
|
if !want[sp.ID] {
|
|
continue
|
|
}
|
|
raw := sp.Run(s, scope)
|
|
// Apply per-call filters: severity + cap.
|
|
filtered := make([]inspectionViolation, 0, len(raw))
|
|
for _, v := range raw {
|
|
if severity != "" && strings.ToLower(v.Severity) != severity {
|
|
continue
|
|
}
|
|
filtered = append(filtered, v)
|
|
}
|
|
truncated := false
|
|
if len(filtered) > maxPer {
|
|
filtered = filtered[:maxPer]
|
|
truncated = true
|
|
}
|
|
results = append(results, map[string]any{
|
|
"inspection": sp.ID,
|
|
"category": sp.Category,
|
|
"severity": sp.Severity,
|
|
"violations": filtered,
|
|
"total": len(filtered),
|
|
"truncated": truncated,
|
|
})
|
|
byInspection[sp.ID] = len(filtered)
|
|
totalViolations += len(filtered)
|
|
}
|
|
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i]["inspection"].(string) < results[j]["inspection"].(string)
|
|
})
|
|
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"results": results,
|
|
"summary": map[string]any{
|
|
"by_inspection": byInspection,
|
|
"total_violations": totalViolations,
|
|
},
|
|
"path_prefix": scope.PathPrefix,
|
|
"max_per_inspection": maxPer,
|
|
})
|
|
}
|
|
|
|
// --- Inspector implementations --------------------------------------
|
|
|
|
func runDeadCodeInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
entries := analysis.FindDeadCode(s.graph, s.getProcesses(), nil)
|
|
out := make([]inspectionViolation, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !scope.keep(e.FilePath) {
|
|
continue
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "dead_code",
|
|
Severity: "warning",
|
|
File: e.FilePath,
|
|
Line: e.Line,
|
|
SymbolID: e.ID,
|
|
Message: "dead code: " + e.Kind + " " + e.Name + " has zero incoming references",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runCyclesInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
cycles := analysis.DetectCycles(s.graph, s.getCommunities(), "")
|
|
out := make([]inspectionViolation, 0, len(cycles))
|
|
for _, c := range cycles {
|
|
// Cycle path is a list of node IDs; surface the first as the
|
|
// anchor and roll the chain into the message so the agent
|
|
// sees the loop without an extra round-trip.
|
|
chain := strings.Join(c.Path, " → ")
|
|
anchor := ""
|
|
anchorFile := ""
|
|
anchorLine := 0
|
|
if len(c.Path) > 0 {
|
|
anchor = c.Path[0]
|
|
if n := s.graph.GetNode(anchor); n != nil {
|
|
if !scope.keep(n.FilePath) {
|
|
continue
|
|
}
|
|
anchorFile = n.FilePath
|
|
anchorLine = n.StartLine
|
|
}
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "cycles",
|
|
Severity: "warning",
|
|
File: anchorFile,
|
|
Line: anchorLine,
|
|
SymbolID: anchor,
|
|
Message: "dependency cycle: " + chain,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runTodosInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
out := make([]inspectionViolation, 0)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n.Kind != graph.KindTodo {
|
|
continue
|
|
}
|
|
if !scope.keep(n.FilePath) {
|
|
continue
|
|
}
|
|
tag, _ := n.Meta["tag"].(string)
|
|
text, _ := n.Meta["text"].(string)
|
|
msg := tag
|
|
if text != "" {
|
|
if msg != "" {
|
|
msg += ": "
|
|
}
|
|
msg += text
|
|
}
|
|
if msg == "" {
|
|
msg = "todo"
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "todos",
|
|
Severity: "info",
|
|
File: n.FilePath,
|
|
Line: n.StartLine,
|
|
SymbolID: n.ID,
|
|
Message: msg,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runCoverageGapsInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
out := make([]inspectionViolation, 0)
|
|
covRows := s.coverageByID()
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
if !scope.keep(n.FilePath) {
|
|
continue
|
|
}
|
|
pct, ok := coveragePctFrom(covRows, n)
|
|
if !ok || pct >= 100.0 {
|
|
continue
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "coverage_gaps",
|
|
Severity: "info",
|
|
File: n.FilePath,
|
|
Line: n.StartLine,
|
|
SymbolID: n.ID,
|
|
Message: "coverage gap: " + n.Name + " at " + formatPct(pct),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// staleInspectionDays mirrors analyze stale_code's default threshold: a
|
|
// function/method whose latest blame authorship is older than this is
|
|
// surfaced as a stale_code inspection.
|
|
const staleInspectionDays = 365
|
|
|
|
func runStaleCodeInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
out := make([]inspectionViolation, 0)
|
|
now := time.Now().Unix()
|
|
cutoff := now - staleInspectionDays*24*3600
|
|
blame := blameRowsByID(s.graph)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
if !scope.keep(n.FilePath) {
|
|
continue
|
|
}
|
|
// last_authored is a nested map (commit / email / timestamp),
|
|
// primarily from the blame sidecar and falling back to the
|
|
// node's meta — lastAuthoredFrom normalises both. Reading it as
|
|
// a bare string (as this inspection once did) always missed.
|
|
la, ok := lastAuthoredFrom(blame, n)
|
|
if !ok || la.Timestamp == 0 || la.Timestamp > cutoff {
|
|
continue
|
|
}
|
|
ageDays := (now - la.Timestamp) / (24 * 3600)
|
|
msg := fmt.Sprintf("stale: %s last authored %dd ago", n.Name, ageDays)
|
|
if la.Email != "" {
|
|
msg += " by " + la.Email
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "stale_code",
|
|
Severity: "info",
|
|
File: n.FilePath,
|
|
Line: n.StartLine,
|
|
SymbolID: n.ID,
|
|
Message: msg,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runGuardsInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
// Evaluate against every node in scope. check_guards' substrate
|
|
// accepts a slice of IDs; we pass the scoped set.
|
|
if len(s.guardRules) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]inspectionViolation, 0)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if !scope.keep(n.FilePath) {
|
|
continue
|
|
}
|
|
// Cheap proxy: a real implementation would evaluate the rule
|
|
// expression. We surface the guard rule's name + scope when
|
|
// the node falls under a rule's pattern so the agent has a
|
|
// pointer to the relevant rule. This keeps the inspection
|
|
// useful when the user has guards configured, without
|
|
// duplicating the full check_guards machinery here.
|
|
for _, rule := range s.guardRules {
|
|
if rule.Name == "" {
|
|
continue
|
|
}
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "guards",
|
|
Severity: "error",
|
|
File: n.FilePath,
|
|
Line: n.StartLine,
|
|
SymbolID: n.ID,
|
|
Message: "guard rule " + rule.Name + " applies — run check_guards for full evaluation",
|
|
})
|
|
break // one rule mention per node is enough
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runContractOrphansInspection(s *Server, scope inspectionScope) []inspectionViolation {
|
|
if s.contractRegistry == nil {
|
|
return nil
|
|
}
|
|
out := make([]inspectionViolation, 0)
|
|
all := s.contractRegistry.All()
|
|
byID := map[string]int{}
|
|
roleByID := map[string]map[string]int{}
|
|
for _, c := range all {
|
|
byID[c.ID]++
|
|
if roleByID[c.ID] == nil {
|
|
roleByID[c.ID] = map[string]int{}
|
|
}
|
|
roleByID[c.ID][string(c.Role)]++
|
|
}
|
|
for _, c := range all {
|
|
if !scope.keep(c.FilePath) {
|
|
continue
|
|
}
|
|
roles := roleByID[c.ID]
|
|
if roles == nil {
|
|
continue
|
|
}
|
|
// Orphan = either role missing.
|
|
if roles["provider"] == 0 || roles["consumer"] == 0 {
|
|
out = append(out, inspectionViolation{
|
|
Inspection: "contracts_orphans",
|
|
Severity: "warning",
|
|
File: c.FilePath,
|
|
Line: c.Line,
|
|
SymbolID: c.SymbolID,
|
|
Message: "orphan contract " + c.ID + " (" + string(c.Role) + " with no counterpart)",
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// formatPct renders a coverage percentage without dragging fmt in for one call.
|
|
func formatPct(v float64) string {
|
|
whole := int(v)
|
|
frac := int((v - float64(whole)) * 10)
|
|
if frac < 0 {
|
|
frac = -frac
|
|
}
|
|
return itoa(whole) + "." + itoa(frac) + "%"
|
|
}
|