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
323 lines
10 KiB
Go
323 lines
10 KiB
Go
// Analyzers that consume the KindString registry as their primary
|
|
// input — the downstream side of the string-anchored extractor
|
|
// pipeline:
|
|
//
|
|
// - log_events: aggregate log-message KindString nodes (the data-
|
|
// side companion to KindEvent log emissions) by literal value,
|
|
// with emitter list and severity level.
|
|
// - sql_rebuild: short-circuit re-derive the KindTable / KindColumn
|
|
// / EdgeQueries layer from the sql-context KindString registry,
|
|
// without re-parsing source.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
gortexsql "github.com/zzet/gortex/internal/sql"
|
|
)
|
|
|
|
// handleAnalyzeLogEvents walks KindString nodes with
|
|
// context="log_message" — the registry shadow of log KindEvent
|
|
// emissions — and groups by literal value. Each row carries the
|
|
// string node id, the literal value, the inferred severity level
|
|
// (from Meta), and the unique emitter symbols.
|
|
//
|
|
// Distinct from event_emitters in two ways: the source is the
|
|
// KindString registry (richer per-literal grouping; multiple
|
|
// canonicalised event nodes can collapse onto the same literal) and
|
|
// the row includes a severity column derived from the emitter's
|
|
// matched method.
|
|
//
|
|
// Filters:
|
|
//
|
|
// - level: log severity (case-insensitive). Matches against the
|
|
// edge's `level` meta first, falling back to the method name so
|
|
// callers using either taxonomy match.
|
|
// - value: literal value (case-insensitive substring match).
|
|
func (s *Server) handleAnalyzeLogEvents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
levelFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "level")))
|
|
valueFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "value")))
|
|
|
|
type logRow struct {
|
|
ID string `json:"id"`
|
|
Value string `json:"value"`
|
|
Level string `json:"level,omitempty"`
|
|
Emits int `json:"emits"`
|
|
Emitters []string `json:"emitters,omitempty"`
|
|
}
|
|
byString := map[string]*logRow{}
|
|
for e := range edgesByKinds(s.graph, graph.EdgeEmits) {
|
|
n := s.graph.GetNode(e.To)
|
|
if n == nil || n.Kind != graph.KindString {
|
|
continue
|
|
}
|
|
ctxLabel, _ := n.Meta["context"].(string)
|
|
if ctxLabel != "log_message" {
|
|
continue
|
|
}
|
|
level, _ := e.Meta["level"].(string)
|
|
if level == "" {
|
|
level, _ = n.Meta["level"].(string)
|
|
}
|
|
if levelFilter != "" {
|
|
method, _ := e.Meta["method"].(string)
|
|
if !levelMatches(levelFilter, level) && !levelMatches(levelFilter, method) {
|
|
continue
|
|
}
|
|
}
|
|
if valueFilter != "" && !strings.Contains(strings.ToLower(n.Name), valueFilter) {
|
|
continue
|
|
}
|
|
row, ok := byString[e.To]
|
|
if !ok {
|
|
row = &logRow{ID: e.To, Value: n.Name, Level: level}
|
|
byString[e.To] = row
|
|
}
|
|
if row.Level == "" && level != "" {
|
|
row.Level = level
|
|
}
|
|
row.Emits++
|
|
row.Emitters = appendUnique(row.Emitters, e.From)
|
|
}
|
|
rows := make([]*logRow, 0, len(byString))
|
|
for _, r := range byString {
|
|
sort.Strings(r.Emitters)
|
|
rows = append(rows, r)
|
|
}
|
|
// Scope filter: keep a row iff its string node (subject) is visible to
|
|
// the current request, and prune its emitter (actor) list to visible
|
|
// emitters. Emits is an edge count (not a node ID) so it is left
|
|
// intact; total recomputes below. No-op for an unbound request.
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]*logRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
|
|
continue
|
|
}
|
|
emitters := make([]string, 0, len(r.Emitters))
|
|
for _, em := range r.Emitters {
|
|
if s.analyzeNodeVisible(ctx, s.graph.GetNode(em)) {
|
|
emitters = append(emitters, em)
|
|
}
|
|
}
|
|
r.Emitters = emitters
|
|
kept = append(kept, r)
|
|
}
|
|
rows = kept
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Emits != rows[j].Emits {
|
|
return rows[i].Emits > rows[j].Emits
|
|
}
|
|
if rows[i].Level != rows[j].Level {
|
|
return rows[i].Level < rows[j].Level
|
|
}
|
|
return rows[i].Value < rows[j].Value
|
|
})
|
|
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]logEventItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, logEventItem{
|
|
ID: r.ID,
|
|
Value: r.Value,
|
|
Level: r.Level,
|
|
Emits: r.Emits,
|
|
Emitters: strings.Join(r.Emitters, ","),
|
|
})
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("log_events", items))
|
|
}
|
|
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
level := r.Level
|
|
if level == "" {
|
|
level = "?"
|
|
}
|
|
fmt.Fprintf(&b, "%-3d [%s] %s\n", r.Emits, level, r.Value)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no log events\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"events": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// handleAnalyzeSQLRebuild is the short-circuit operator: it walks
|
|
// the KindString context="sql" registry already present in
|
|
// the graph and rederives the KindTable / KindColumn / EdgeQueries
|
|
// / EdgeReadsCol / EdgeWritesCol layer from those nodes alone,
|
|
// without re-parsing source. Idempotent (graph.AddNode / AddEdge
|
|
// dedupe by ID + edgeKey) — running it twice on a stable graph
|
|
// reports `tables_created: 0, emitters_linked: 0`.
|
|
//
|
|
// Returns a single-row summary of counts: how many string nodes
|
|
// were visited, how many table / column nodes were created, how
|
|
// many EdgeQueries / EdgeReadsCol / EdgeWritesCol edges were
|
|
// emitted, and how many (caller, table) pairs were linked.
|
|
//
|
|
// Use cases:
|
|
//
|
|
// - Re-derive the SQL layer after enabling the SQL coverage gate
|
|
// on an existing index without forcing a full reindex.
|
|
// - Recover the SQL layer after a snapshot round-trip that
|
|
// dropped Node.Meta / Edge.Meta (per the graph's JSON exclusion
|
|
// of Meta).
|
|
// - Health-check the registry — a non-zero `skipped` count means
|
|
// KindString sql nodes exist that produced zero tables, which
|
|
// hints at parser regressions in sql.ExtractTables.
|
|
func (s *Server) handleAnalyzeSQLRebuild(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
stats := gortexsql.RebuildTablesFromStringRegistry(s.graph)
|
|
|
|
if s.isGCX(ctx, req) {
|
|
items := []sqlRebuildItem{{
|
|
StringsVisited: stats.StringsVisited,
|
|
TablesCreated: stats.TablesCreated,
|
|
ColumnsCreated: stats.ColumnsCreated,
|
|
QueryEdges: stats.QueryEdges,
|
|
ReadColEdges: stats.ReadColEdges,
|
|
WriteColEdges: stats.WriteColEdges,
|
|
EmittersLinked: stats.EmittersLinked,
|
|
Skipped: stats.Skipped,
|
|
}}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("sql_rebuild", items))
|
|
}
|
|
|
|
if isCompact(req) {
|
|
return mcp.NewToolResultText(fmt.Sprintf(
|
|
"strings=%d tables+%d cols+%d queries+%d reads+%d writes+%d emitters+%d skipped=%d\n",
|
|
stats.StringsVisited, stats.TablesCreated, stats.ColumnsCreated,
|
|
stats.QueryEdges, stats.ReadColEdges, stats.WriteColEdges,
|
|
stats.EmittersLinked, stats.Skipped,
|
|
)), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"strings_visited": stats.StringsVisited,
|
|
"tables_created": stats.TablesCreated,
|
|
"columns_created": stats.ColumnsCreated,
|
|
"query_edges_created": stats.QueryEdges,
|
|
"reads_col_edges": stats.ReadColEdges,
|
|
"writes_col_edges": stats.WriteColEdges,
|
|
"emitters_linked": stats.EmittersLinked,
|
|
"skipped": stats.Skipped,
|
|
})
|
|
}
|
|
|
|
// handleAnalyzeSQLCallSites lists the call sites that execute SQL,
|
|
// grouped by the calling symbol, with the tables each one touches and
|
|
// a read / write split. It re-derives the table / EdgeQueries layer
|
|
// from the string registry first (idempotent), so the view works even
|
|
// when sql_rebuild was not run explicitly.
|
|
//
|
|
// Filters: name (call-site symbol name, case-insensitive), limit.
|
|
func (s *Server) handleAnalyzeSQLCallSites(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
gortexsql.RebuildTablesFromStringRegistry(s.graph)
|
|
|
|
args := req.GetArguments()
|
|
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
|
|
limit := 20
|
|
if v, ok := args["limit"].(float64); ok && v > 0 {
|
|
limit = int(v)
|
|
}
|
|
|
|
type sqlCallSite struct {
|
|
Symbol string `json:"symbol"`
|
|
Name string `json:"name"`
|
|
File string `json:"file,omitempty"`
|
|
Tables []string `json:"tables,omitempty"`
|
|
Queries int `json:"queries"`
|
|
Reads int `json:"reads"`
|
|
Writes int `json:"writes"`
|
|
}
|
|
bySite := map[string]*sqlCallSite{}
|
|
for e := range edgesByKinds(s.graph, graph.EdgeQueries) {
|
|
row, ok := bySite[e.From]
|
|
if !ok {
|
|
name, file := e.From, ""
|
|
if n := s.graph.GetNode(e.From); n != nil {
|
|
name, file = n.Name, n.FilePath
|
|
}
|
|
if nameFilter != "" && strings.ToLower(name) != nameFilter {
|
|
continue
|
|
}
|
|
row = &sqlCallSite{Symbol: e.From, Name: name, File: file}
|
|
bySite[e.From] = row
|
|
}
|
|
row.Queries++
|
|
if op, _ := e.Meta["op"].(string); op == "write" {
|
|
row.Writes++
|
|
} else {
|
|
row.Reads++
|
|
}
|
|
if t := s.graph.GetNode(e.To); t != nil && t.Name != "" {
|
|
row.Tables = appendUnique(row.Tables, t.Name)
|
|
}
|
|
}
|
|
|
|
rows := make([]*sqlCallSite, 0, len(bySite))
|
|
for _, r := range bySite {
|
|
sort.Strings(r.Tables)
|
|
rows = append(rows, r)
|
|
}
|
|
// Scope filter: keep only call sites whose calling symbol is visible
|
|
// to the current request. Tables are names (not node IDs) so they need
|
|
// no pruning. total/truncated recompute below. No-op for an unbound
|
|
// request. (The RebuildTablesFromStringRegistry call above is left
|
|
// untouched — it is an idempotent graph mutation, not a row source.)
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]*sqlCallSite, 0, len(rows))
|
|
for _, r := range rows {
|
|
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) {
|
|
kept = append(kept, r)
|
|
}
|
|
}
|
|
rows = kept
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Queries != rows[j].Queries {
|
|
return rows[i].Queries > rows[j].Queries
|
|
}
|
|
return rows[i].Symbol < rows[j].Symbol
|
|
})
|
|
truncated := false
|
|
if nameFilter == "" && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
truncated = true
|
|
}
|
|
|
|
if s.isGCX(ctx, req) {
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("sql_call_sites", rows))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%-3d r%d/w%d %s [%s]\n",
|
|
r.Queries, r.Reads, r.Writes, r.Name, strings.Join(r.Tables, ","))
|
|
}
|
|
if truncated {
|
|
fmt.Fprintf(&b, "... truncated to %d\n", limit)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no SQL call sites\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"call_sites": rows,
|
|
"total": len(rows),
|
|
"truncated": truncated,
|
|
})
|
|
}
|