a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
206 lines
6.0 KiB
Go
206 lines
6.0 KiB
Go
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
|
|
}
|