package mcp import ( "context" "sort" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/graph" ) // handleAnalyzeConstructorsMissingFields surfaces struct/class // literal sites that don't populate every field defined on the type. // Catches the classic "added a field, forgot to populate at 3 of 5 // instantiation sites" bug class. // // Heuristic, not a full literal-site analyzer: // // 1. For every type node T with member fields (via inbound // EdgeMemberOf from field nodes), collect T_fields. // 2. For every EdgeInstantiates whose target is T, take the // origin function F. // 3. Look at every outbound EdgeReferences from F whose target // is a member field of T. The set of referenced field names // is F_referenced. // 4. missing = T_fields - F_referenced. When non-empty, emit a // row (F, T, missing fields). // // False positives: // - F populates the field via shorthand the extractor doesn't // emit a Reference edge for (rare; we know which extractors emit // references, but the field-write case isn't always tagged). // - The field carries a meaningful zero-value default (Go zero, // struct embedding, JSON omitempty). The analyzer flags these // conservatively — agents can suppress by tagging the field // with meta["nullable"]=true. // // False negatives: // - F populates the field outside the literal (e.g. `c := Foo{}; // c.X = 1`) — we count that as "referenced," which is what we // want for the "did you forget about this field at all" question. // // The accuracy is good enough for "is this an underpopulated // literal site?" agent prompts; not good enough for compiler-grade // claims. The wire response carries `accuracy: "heuristic"` so // agents don't oversell. func (s *Server) handleAnalyzeConstructorsMissingFields(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { pathPrefix := strings.TrimSpace(req.GetString("path_prefix", "")) minMissing := max(req.GetInt("min_missing", 1), 1) typeFilter := strings.TrimSpace(req.GetString("type_id", "")) limit := max(req.GetInt("limit", 100), 1) scoped := s.scopedNodes(ctx) scopedSet := make(map[string]*graph.Node, len(scoped)) for _, n := range scoped { scopedSet[n.ID] = n } // Step 1: index types → their member fields. typeFields := map[string]map[string]*graph.Node{} // typeID → {fieldName: fieldNode} for _, n := range scoped { if n.Kind != graph.KindField { continue } for _, e := range s.graph.GetOutEdges(n.ID) { if e.Kind != graph.EdgeMemberOf { continue } if scopedSet[e.To] == nil { continue } if typeFilter != "" && e.To != typeFilter { continue } if pathPrefix != "" && !strings.HasPrefix(scopedSet[e.To].FilePath, pathPrefix) { continue } if typeFields[e.To] == nil { typeFields[e.To] = map[string]*graph.Node{} } typeFields[e.To][n.Name] = n } } type missingRow struct { Function string `json:"function_id"` FunctionName string `json:"function_name"` File string `json:"file"` Line int `json:"line"` Type string `json:"type_id"` TypeName string `json:"type_name"` MissingFields []string `json:"missing_fields"` TotalFields int `json:"total_fields"` } rows := []missingRow{} for typeID, fields := range typeFields { typeNode := scopedSet[typeID] if typeNode == nil || len(fields) == 0 { continue } // Step 2: every function that instantiates this type. for _, e := range s.graph.GetInEdges(typeID) { if e.Kind != graph.EdgeInstantiates { continue } f := scopedSet[e.From] if f == nil { continue } if f.Kind != graph.KindFunction && f.Kind != graph.KindMethod { continue } // Step 3: which member fields does F reference? referenced := map[string]bool{} for _, ref := range s.graph.GetOutEdges(f.ID) { if ref.Kind != graph.EdgeReferences { continue } target := s.graph.GetNode(ref.To) if target == nil || target.Kind != graph.KindField { continue } if _, isFieldOfT := fields[target.Name]; !isFieldOfT { continue } referenced[target.Name] = true } // Step 4: missing = T_fields - referenced. missing := []string{} for name, fnode := range fields { if referenced[name] { continue } if isNullableField(fnode) { continue } missing = append(missing, name) } if len(missing) < minMissing { continue } sort.Strings(missing) rows = append(rows, missingRow{ Function: f.ID, FunctionName: f.Name, File: f.FilePath, Line: f.StartLine, Type: typeID, TypeName: typeNode.Name, MissingFields: missing, TotalFields: len(fields), }) } } sort.Slice(rows, func(i, j int) bool { if len(rows[i].MissingFields) != len(rows[j].MissingFields) { return len(rows[i].MissingFields) > len(rows[j].MissingFields) } if rows[i].Function != rows[j].Function { return rows[i].Function < rows[j].Function } return rows[i].Type < rows[j].Type }) truncated := false if len(rows) > limit { rows = rows[:limit] truncated = true } return s.respondJSONOrTOON(ctx, req, map[string]any{ "sites": rows, "total": len(rows), "truncated": truncated, "accuracy": "heuristic", "min_missing": minMissing, }) } // isNullableField returns true when the field carries an explicit // nullable / optional / omitempty marker the agent can use to // suppress false positives. Reads three meta keys: // - meta["nullable"] bool — explicit opt-out // - meta["optional"] bool — same intent, different convention // - meta["json_tag"] string containing "omitempty" — Go convention func isNullableField(n *graph.Node) bool { if n.Meta == nil { return false } if b, ok := n.Meta["nullable"].(bool); ok && b { return true } if b, ok := n.Meta["optional"].(bool); ok && b { return true } if tag, ok := n.Meta["json_tag"].(string); ok && strings.Contains(tag, "omitempty") { return true } return false }