Files
zzet--gortex/internal/mcp/tools_analyze_string_downstream.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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,
})
}