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
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// api_impact is a fused, route-anchored pre-change report. Given a route path
|
|
// (or handler file) substring, it composes four existing subsystems — the
|
|
// contract registry (route + consumers), contracts.Validate (field-level
|
|
// response-shape mismatch), analysis.AnalyzeImpact (the real blast radius:
|
|
// callers, processes, test files), and best-effort middleware — into one
|
|
// answer to "what breaks if I change this endpoint?". It is the single call an
|
|
// agent makes BEFORE modifying an API handler.
|
|
|
|
type apiImpactShape struct {
|
|
Success []string `json:"success"`
|
|
Error []string `json:"error,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type apiImpactConsumer struct {
|
|
Name string `json:"name"`
|
|
File string `json:"file,omitempty"`
|
|
Repo string `json:"repo,omitempty"`
|
|
Accesses []string `json:"accesses,omitempty"`
|
|
AttributionNote string `json:"attribution_note,omitempty"`
|
|
}
|
|
|
|
type apiImpactMismatch struct {
|
|
Consumer string `json:"consumer"`
|
|
Field string `json:"field"`
|
|
Reason string `json:"reason"`
|
|
Confidence string `json:"confidence"` // high | low
|
|
}
|
|
|
|
type apiImpactSummary struct {
|
|
DirectConsumers int `json:"direct_consumers"`
|
|
AffectedFlows int `json:"affected_flows"`
|
|
AffectedCallers int `json:"affected_callers"`
|
|
TestFilesToRun []string `json:"test_files_to_run,omitempty"`
|
|
RiskLevel string `json:"risk_level"`
|
|
Warning string `json:"warning,omitempty"`
|
|
ContractRiskUpgrade string `json:"contract_risk_upgrade,omitempty"`
|
|
}
|
|
|
|
type apiImpactReport struct {
|
|
Route string `json:"route"`
|
|
Method string `json:"method,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Handler string `json:"handler,omitempty"`
|
|
Repo string `json:"repo,omitempty"`
|
|
ResponseShape apiImpactShape `json:"response_shape"`
|
|
Middleware []string `json:"middleware,omitempty"`
|
|
MiddlewareDetection string `json:"middleware_detection,omitempty"`
|
|
Consumers []apiImpactConsumer `json:"consumers"`
|
|
Mismatches []apiImpactMismatch `json:"mismatches,omitempty"`
|
|
ExecutionFlows []string `json:"execution_flows,omitempty"`
|
|
ImpactSummary apiImpactSummary `json:"impact_summary"`
|
|
}
|
|
|
|
func (s *Server) handleAPIImpact(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
route := strings.TrimSpace(req.GetString("route", ""))
|
|
file := strings.TrimSpace(req.GetString("file", ""))
|
|
if route == "" && file == "" {
|
|
return mcp.NewToolResultError("either route or file is required"), nil
|
|
}
|
|
allowed, err := s.resolveRepoFilter(ctx, req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
reg := s.effectiveContractRegistry()
|
|
if reg == nil {
|
|
return mcp.NewToolResultError("no contract registry available — index a repo with API routes first"), nil
|
|
}
|
|
|
|
providers := resolveRouteTargets(reg, route, file, allowed)
|
|
if len(providers) == 0 {
|
|
target := route
|
|
if target == "" {
|
|
target = file
|
|
}
|
|
return mcp.NewToolResultError(fmt.Sprintf("no route matching %q", target)), nil
|
|
}
|
|
|
|
lookup := s.contractShapeLookup()
|
|
fanout := consumerFileFanout(reg)
|
|
|
|
reports := make([]apiImpactReport, 0, len(providers))
|
|
for _, p := range providers {
|
|
reports = append(reports, s.buildAPIImpactReport(p, reg, lookup, fanout))
|
|
}
|
|
|
|
var payload any
|
|
if len(reports) == 1 {
|
|
payload = reports[0]
|
|
} else {
|
|
payload = map[string]any{"routes": reports, "total": len(reports)}
|
|
}
|
|
|
|
if s.isGCX(ctx, req) {
|
|
return s.gcxResponseWithBudget(req)(encodeAPIImpact(reports))
|
|
}
|
|
if s.isTOON(ctx, req) {
|
|
return returnTOON(payload)
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, payload)
|
|
}
|
|
|
|
// resolveRouteTargets returns provider HTTP contracts whose path matches the
|
|
// route substring or whose handler file matches the file substring, deduped by
|
|
// canonical contract ID (first wins). Registry contracts carry FLAT meta, so
|
|
// this avoids the contract_meta nesting gotcha on graph nodes.
|
|
func resolveRouteTargets(reg *contracts.Registry, route, file string, allowed map[string]bool) []contracts.Contract {
|
|
seen := map[string]bool{}
|
|
var out []contracts.Contract
|
|
for _, c := range reg.All() {
|
|
if c.Role != contracts.RoleProvider || c.Type != contracts.ContractHTTP {
|
|
continue
|
|
}
|
|
if allowed != nil && c.RepoPrefix != "" && !allowed[c.RepoPrefix] {
|
|
continue
|
|
}
|
|
path := metaStr(c.Meta, "path")
|
|
match := false
|
|
if route != "" && path != "" && strings.Contains(path, route) {
|
|
match = true
|
|
}
|
|
if file != "" && c.FilePath != "" && strings.Contains(c.FilePath, file) {
|
|
match = true
|
|
}
|
|
if !match || seen[c.ID] {
|
|
continue
|
|
}
|
|
seen[c.ID] = true
|
|
out = append(out, c)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// consumerFileFanout counts, per consumer file, how many distinct route
|
|
// contract IDs that file consumes — the multi-fetch heuristic that downgrades
|
|
// a mismatch's confidence (one file fetching many routes is a weaker signal
|
|
// that a given field belongs to a given route).
|
|
func consumerFileFanout(reg *contracts.Registry) map[string]int {
|
|
perFile := map[string]map[string]bool{}
|
|
for _, c := range reg.All() {
|
|
if c.Role != contracts.RoleConsumer || c.FilePath == "" {
|
|
continue
|
|
}
|
|
if perFile[c.FilePath] == nil {
|
|
perFile[c.FilePath] = map[string]bool{}
|
|
}
|
|
perFile[c.FilePath][c.ID] = true
|
|
}
|
|
out := make(map[string]int, len(perFile))
|
|
for f, ids := range perFile {
|
|
out[f] = len(ids)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) buildAPIImpactReport(p contracts.Contract, reg *contracts.Registry, lookup contracts.ShapeLookup, fanout map[string]int) apiImpactReport {
|
|
rep := apiImpactReport{
|
|
Route: p.ID,
|
|
Method: metaStr(p.Meta, "method"),
|
|
Path: metaStr(p.Meta, "path"),
|
|
Handler: p.SymbolID,
|
|
Repo: p.RepoPrefix,
|
|
}
|
|
rep.ResponseShape = apiImpactShape{Success: envelopeNames(p.Meta)}
|
|
if len(rep.ResponseShape.Success) == 0 {
|
|
rep.ResponseShape.Note = "no response envelope extracted for this route"
|
|
}
|
|
|
|
// Consumers = consumer-role contracts sharing this canonical ID (same-repo
|
|
// and cross-repo via the contract matcher's pairing).
|
|
repoToConsumer := map[string]string{}
|
|
for _, c := range reg.ByID(p.ID) {
|
|
if c.Role != contracts.RoleConsumer {
|
|
continue
|
|
}
|
|
name := s.nodeName(c.SymbolID)
|
|
cons := apiImpactConsumer{
|
|
Name: name,
|
|
File: c.FilePath,
|
|
Repo: c.RepoPrefix,
|
|
Accesses: consumerAccesses(c, lookup),
|
|
}
|
|
if fanout[c.FilePath] > 1 {
|
|
cons.AttributionNote = fmt.Sprintf("file consumes %d routes; field attribution is approximate", fanout[c.FilePath])
|
|
}
|
|
rep.Consumers = append(rep.Consumers, cons)
|
|
if c.RepoPrefix != "" {
|
|
repoToConsumer[c.RepoPrefix] = name
|
|
}
|
|
}
|
|
sort.SliceStable(rep.Consumers, func(i, j int) bool { return rep.Consumers[i].Name < rep.Consumers[j].Name })
|
|
|
|
// Mismatches via real field-level shape diffing (contracts.Validate over a
|
|
// sub-registry of just this route + its consumers).
|
|
sub := contracts.NewRegistry()
|
|
sub.Add(p)
|
|
for _, c := range reg.ByID(p.ID) {
|
|
if c.Role == contracts.RoleConsumer {
|
|
sub.Add(c)
|
|
}
|
|
}
|
|
breaking := 0
|
|
for _, issue := range contracts.Validate(sub, lookup) {
|
|
if issue.Field == "" {
|
|
continue
|
|
}
|
|
if issue.Severity == contracts.SeverityBreaking {
|
|
breaking++
|
|
}
|
|
confidence := "low"
|
|
if issue.Severity == contracts.SeverityBreaking {
|
|
confidence = "high"
|
|
}
|
|
consumerName := repoToConsumer[issue.Consumer]
|
|
if consumerName == "" {
|
|
consumerName = issue.Consumer
|
|
}
|
|
// A consumer file fetching many routes weakens per-field attribution.
|
|
reason := issue.Details
|
|
if reason == "" {
|
|
reason = issue.Kind
|
|
}
|
|
rep.Mismatches = append(rep.Mismatches, apiImpactMismatch{
|
|
Consumer: consumerName,
|
|
Field: issue.Field,
|
|
Reason: reason,
|
|
Confidence: confidence,
|
|
})
|
|
}
|
|
sort.SliceStable(rep.Mismatches, func(i, j int) bool {
|
|
if rep.Mismatches[i].Consumer != rep.Mismatches[j].Consumer {
|
|
return rep.Mismatches[i].Consumer < rep.Mismatches[j].Consumer
|
|
}
|
|
return rep.Mismatches[i].Field < rep.Mismatches[j].Field
|
|
})
|
|
|
|
// Blast radius + execution flows from the real impact analysis.
|
|
var impact *analysis.ImpactResult
|
|
if p.SymbolID != "" {
|
|
impact = analysis.AnalyzeImpact(s.graph, []string{p.SymbolID}, s.getCommunities(), s.getProcesses())
|
|
}
|
|
if impact != nil {
|
|
rep.ExecutionFlows = impact.AffectedProcesses
|
|
rep.ImpactSummary.AffectedFlows = len(impact.AffectedProcesses)
|
|
rep.ImpactSummary.AffectedCallers = impact.TotalAffected
|
|
rep.ImpactSummary.TestFilesToRun = impact.TestFiles
|
|
}
|
|
|
|
// Middleware: best-effort from annotation edges on the handler. Never
|
|
// fabricate — mark unavailable when nothing is extracted.
|
|
rep.Middleware = s.handlerMiddleware(p.SymbolID)
|
|
if len(rep.Middleware) == 0 {
|
|
rep.MiddlewareDetection = "unavailable: gortex does not extract HTTP middleware chains; none inferable from annotations"
|
|
}
|
|
|
|
// Risk fusion: consumer-count tier, bumped on any mismatch, max'd with the
|
|
// blast-radius risk, forced HIGH when the route carries breaking drift.
|
|
impactRisk := analysis.RiskLow
|
|
if impact != nil {
|
|
impactRisk = impact.Risk
|
|
}
|
|
risk, upgrade := fuseRouteRisk(len(rep.Consumers), len(rep.Mismatches), impactRisk, breaking)
|
|
rep.ImpactSummary.DirectConsumers = len(rep.Consumers)
|
|
rep.ImpactSummary.RiskLevel = string(risk)
|
|
rep.ImpactSummary.ContractRiskUpgrade = upgrade
|
|
if len(rep.Consumers) > 0 {
|
|
rep.ImpactSummary.Warning = fmt.Sprintf("changing the response shape of %s will affect %d consumer(s)", p.ID, len(rep.Consumers))
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// fuseRouteRisk combines GitNexus's consumer-count heuristic (>=10 HIGH, >=4
|
|
// MEDIUM, else LOW; +1 level on any mismatch) with gortex's real blast-radius
|
|
// risk tier, and forces HIGH when the route is a contract boundary with
|
|
// existing breaking drift.
|
|
func fuseRouteRisk(consumers, mismatches int, impactRisk analysis.RiskLevel, breaking int) (analysis.RiskLevel, string) {
|
|
base := 0 // 0=LOW 1=MEDIUM 2=HIGH
|
|
switch {
|
|
case consumers >= 10:
|
|
base = 2
|
|
case consumers >= 4:
|
|
base = 1
|
|
}
|
|
if mismatches > 0 && base < 2 {
|
|
base++
|
|
}
|
|
if r := riskRank(impactRisk); r > base {
|
|
base = r
|
|
}
|
|
if breaking > 0 {
|
|
return analysis.RiskHigh, "risk raised to HIGH — route is a contract boundary with breaking response-shape drift"
|
|
}
|
|
return riskFromRank(base), ""
|
|
}
|
|
|
|
func riskRank(r analysis.RiskLevel) int {
|
|
switch r {
|
|
case analysis.RiskCritical, analysis.RiskHigh:
|
|
return 2
|
|
case analysis.RiskMedium:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func riskFromRank(n int) analysis.RiskLevel {
|
|
switch {
|
|
case n >= 2:
|
|
return analysis.RiskHigh
|
|
case n == 1:
|
|
return analysis.RiskMedium
|
|
default:
|
|
return analysis.RiskLow
|
|
}
|
|
}
|
|
|
|
// contractShapeLookup resolves a type symbol ID to its field-level Shape from
|
|
// the graph (the same closure handleValidateContracts / computeContractImpact
|
|
// use).
|
|
func (s *Server) contractShapeLookup() contracts.ShapeLookup {
|
|
return contracts.ShapeLookup(func(id string) *contracts.Shape {
|
|
n := s.graph.GetNode(id)
|
|
if n == nil || n.Meta == nil {
|
|
return nil
|
|
}
|
|
switch v := n.Meta["shape"].(type) {
|
|
case *contracts.Shape:
|
|
return v
|
|
case contracts.Shape:
|
|
return &v
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// consumerAccesses returns the field names a consumer expects from the route's
|
|
// response — the fields of the consumer contract's response_type shape.
|
|
func consumerAccesses(c contracts.Contract, lookup contracts.ShapeLookup) []string {
|
|
respType := metaStr(c.Meta, "response_type")
|
|
if respType == "" || lookup == nil {
|
|
return nil
|
|
}
|
|
sh := lookup(respType)
|
|
if sh == nil {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(sh.Fields))
|
|
for _, f := range sh.Fields {
|
|
if f.Name != "" {
|
|
names = append(names, f.Name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// handlerMiddleware collects annotation targets on a handler symbol as a
|
|
// best-effort middleware list (decorators like @UseGuards / withAuth).
|
|
func (s *Server) handlerMiddleware(symbolID string) []string {
|
|
if symbolID == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, e := range s.graph.GetOutEdges(symbolID) {
|
|
if e.Kind != graph.EdgeAnnotated {
|
|
continue
|
|
}
|
|
name := s.nodeName(e.To)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// envelopeNames extracts the success response key names from a route's flat
|
|
// response_envelope meta ([]map with a "name" key).
|
|
func envelopeNames(meta map[string]any) []string {
|
|
raw, ok := meta["response_envelope"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var names []string
|
|
switch env := raw.(type) {
|
|
case []map[string]any:
|
|
for _, row := range env {
|
|
if n, _ := row["name"].(string); n != "" {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
case []any:
|
|
for _, r := range env {
|
|
if row, ok := r.(map[string]any); ok {
|
|
if n, _ := row["name"].(string); n != "" {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func metaStr(m map[string]any, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
v, _ := m[key].(string)
|
|
return v
|
|
}
|
|
|
|
func (s *Server) nodeName(id string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
if n := s.graph.GetNode(id); n != nil && n.Name != "" {
|
|
return n.Name
|
|
}
|
|
if i := strings.LastIndex(id, "::"); i >= 0 {
|
|
return id[i+2:]
|
|
}
|
|
return id
|
|
}
|