Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/graph/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ const (
// KindLicense represents an SPDX license identifier. ID convention:
// `license::<spdx>`. Files link to it via EdgeLicensedAs.
KindLicense NodeKind = "license"
// KindString represents a string literal that crosses an API
// boundary worth tracking — Datadog/Prometheus metric names,
// errors.New / fmt.Errorf messages, raw HTTP route paths, and
// (later) HTML class/id values. ID convention:
// `string::<context>::<value-or-hash>`. Context ∈
// metric|error_msg|route|html_class|html_id|… EdgeEmits links the
// enclosing function/method to the string node, mirroring KindEvent.
// Per-repo: applyRepoPrefix prefixes every node ID with the repo
// slug so two repos that emit the same string don't collide.
KindString NodeKind = "string"
)

var validNodeKinds = map[NodeKind]bool{
Expand All @@ -111,7 +121,7 @@ var validNodeKinds = map[NodeKind]bool{
KindTable: true, KindColumn: true, KindConfigKey: true,
KindFlag: true, KindEvent: true, KindMigration: true,
KindFixture: true, KindTodo: true, KindTeam: true,
KindRelease: true, KindLicense: true,
KindRelease: true, KindLicense: true, KindString: true,
}

type Node struct {
Expand Down
12 changes: 12 additions & 0 deletions internal/mcp/gcx.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,18 @@ func encodeAnalyze(kind string, payload any) ([]byte, error) {
}
}
return buf.Bytes(), enc.Close()
case "string_emitters":
items, _ := payload.([]stringEmitterItem)
enc := newGCX(&buf, "analyze.string_emitters",
[]string{"id", "context", "value", "emits", "emitters"},
"count", fmt.Sprintf("%d", len(items)),
)
for _, it := range items {
if err := enc.WriteRow(it.ID, it.Context, it.Value, it.Emits, it.Emitters); err != nil {
return nil, err
}
}
return buf.Bytes(), enc.Close()
case "error_surface":
items, _ := payload.([]errorSurfaceItem)
enc := newGCX(&buf, "analyze.error_surface",
Expand Down
117 changes: 117 additions & 0 deletions internal/mcp/tools_analyze_string_emitters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package mcp

import (
"context"
"fmt"
"sort"
"strings"

"github.com/mark3labs/mcp-go/mcp"

"github.com/zzet/gortex/internal/graph"
)

// handleAnalyzeStringEmitters walks EdgeEmits edges to KindString
// nodes and groups by (context, value). Mirrors handleAnalyzeEventEmitters
// but works against the broader string domain (metrics, error
// messages, raw routes; later HTML class/id and i18n keys).
//
// Filters:
//
// - context: metric|error_msg|route — narrows to one string domain.
// - name: string value (case-insensitive substring match). Use to
// find emitters of a specific metric, error message, or route.
func (s *Server) handleAnalyzeStringEmitters(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
contextFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "context")))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))

type stringRow struct {
ID string `json:"id"`
Context string `json:"context"`
Value string `json:"value"`
Emits int `json:"emits"`
Emitters []string `json:"emitters,omitempty"`
}
byString := map[string]*stringRow{}
for _, e := range s.graph.AllEdges() {
if e.Kind != graph.EdgeEmits {
continue
}
n := s.graph.GetNode(e.To)
if n == nil || n.Kind != graph.KindString {
continue
}
ctx, _ := n.Meta["context"].(string)
if contextFilter != "" && ctx != contextFilter {
continue
}
if nameFilter != "" && !strings.Contains(strings.ToLower(n.Name), nameFilter) {
continue
}
row, ok := byString[e.To]
if !ok {
row = &stringRow{
ID: e.To,
Context: ctx,
Value: n.Name,
}
byString[e.To] = row
}
row.Emits++
row.Emitters = appendUnique(row.Emitters, e.From)
}
rows := make([]*stringRow, 0, len(byString))
for _, r := range byString {
sort.Strings(r.Emitters)
rows = append(rows, r)
}
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].Context != rows[j].Context {
return rows[i].Context < rows[j].Context
}
return rows[i].Value < rows[j].Value
})

if isGCX(req) {
items := make([]stringEmitterItem, 0, len(rows))
for _, r := range rows {
items = append(items, stringEmitterItem{
ID: r.ID,
Context: r.Context,
Value: r.Value,
Emits: r.Emits,
Emitters: strings.Join(r.Emitters, ","),
})
}
return gcxResponse(encodeAnalyze("string_emitters", items))
}

if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d [%s] %s\n", r.Emits, r.Context, r.Value)
}
if len(rows) == 0 {
b.WriteString("no string emitters\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return mcp.NewToolResultJSON(map[string]any{
"strings": rows,
"total": len(rows),
})
}

// stringEmitterItem is the GCX1 row layout for the string_emitters
// analyzer. Mirrors eventEmitterItem.
type stringEmitterItem struct {
ID string `gcx:"id"`
Context string `gcx:"context"`
Value string `gcx:"value"`
Emits int `gcx:"emits"`
Emitters string `gcx:"emitters"`
}
124 changes: 124 additions & 0 deletions internal/mcp/tools_analyze_string_emitters_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package mcp

import (
"context"
"encoding/json"
"testing"

mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)

func callAnalyzeStringEmitters(t *testing.T, srv *Server, args map[string]any) map[string]any {
t.Helper()
args["kind"] = "string_emitters"
req := mcplib.CallToolRequest{}
req.Params.Name = "analyze"
req.Params.Arguments = args
res, err := srv.handleAnalyze(context.Background(), req)
if err != nil {
t.Fatalf("handleAnalyze: %v", err)
}
if res.IsError {
t.Fatalf("error: %+v", res.Content)
}
textBlock := res.Content[0].(mcplib.TextContent)
var out map[string]any
if err := json.Unmarshal([]byte(textBlock.Text), &out); err != nil {
t.Fatalf("json: %v\n%s", err, textBlock.Text)
}
return out
}

func addStringNode(g *graph.Graph, id, value, ctx string) {
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindString,
Name: value,
Meta: map[string]any{"context": ctx, "value": value},
})
}

func addStringEmitEdge(g *graph.Graph, from, to, ctx, method string) {
g.AddEdge(&graph.Edge{
From: from,
To: to,
Kind: graph.EdgeEmits,
Meta: map[string]any{"context": ctx, "method": method},
})
}

func TestAnalyzeStringEmitters_GroupsByString(t *testing.T) {
srv, _ := setupTestServer(t)
addStringNode(srv.graph, "string::metric::orders.success", "orders.success", "metric")
addStringNode(srv.graph, "string::error_msg::not authorized", "not authorized", "error_msg")
addStringEmitEdge(srv.graph, "f.go::Checkout", "string::metric::orders.success", "metric", "Increment")
addStringEmitEdge(srv.graph, "f.go::Refund", "string::metric::orders.success", "metric", "Increment")
addStringEmitEdge(srv.graph, "f.go::Auth", "string::error_msg::not authorized", "error_msg", "errors.New")

out := callAnalyzeStringEmitters(t, srv, map[string]any{})
rows, _ := out["strings"].([]any)
if len(rows) != 2 {
t.Fatalf("expected 2 strings, got %d", len(rows))
}
first := rows[0].(map[string]any)
if first["value"] != "orders.success" {
t.Errorf("expected orders.success first (more emits), got %v", first["value"])
}
if first["context"] != "metric" {
t.Errorf("expected first.context = metric, got %v", first["context"])
}
if int(first["emits"].(float64)) != 2 {
t.Errorf("expected 2 emits, got %v", first["emits"])
}
}

func TestAnalyzeStringEmitters_ContextFilter(t *testing.T) {
srv, _ := setupTestServer(t)
addStringNode(srv.graph, "string::metric::a", "a", "metric")
addStringNode(srv.graph, "string::route::/x", "/x", "route")
addStringEmitEdge(srv.graph, "f.go::A", "string::metric::a", "metric", "Increment")
addStringEmitEdge(srv.graph, "f.go::B", "string::route::/x", "route", "HandleFunc")

out := callAnalyzeStringEmitters(t, srv, map[string]any{
"context": "route",
})
rows, _ := out["strings"].([]any)
if len(rows) != 1 {
t.Fatalf("expected 1 string after context=route filter, got %d", len(rows))
}
first := rows[0].(map[string]any)
if first["context"] != "route" {
t.Errorf("filter leaked: got context=%v", first["context"])
}
}

func TestAnalyzeStringEmitters_NameSubstringFilter(t *testing.T) {
srv, _ := setupTestServer(t)
addStringNode(srv.graph, "string::metric::orders.success", "orders.success", "metric")
addStringNode(srv.graph, "string::metric::server.memory", "server.memory", "metric")
addStringEmitEdge(srv.graph, "f.go::A", "string::metric::orders.success", "metric", "Increment")
addStringEmitEdge(srv.graph, "f.go::B", "string::metric::server.memory", "metric", "Gauge")

out := callAnalyzeStringEmitters(t, srv, map[string]any{
"name": "orders",
})
rows, _ := out["strings"].([]any)
if len(rows) != 1 {
t.Fatalf("expected 1 string after name=orders filter, got %d", len(rows))
}
}

func TestAnalyzeStringEmitters_IgnoresNonStringEmitTargets(t *testing.T) {
// EdgeEmits to KindEvent (the legacy log target) shouldn't appear
// in string_emitters results.
srv, _ := setupTestServer(t)
addEventNode(srv.graph, "event::log::user.login", "user.login", "log")
addEmitsEdge(srv.graph, "f.go::Auth", "event::log::user.login", "Info")

out := callAnalyzeStringEmitters(t, srv, map[string]any{})
rows, _ := out["strings"].([]any)
if len(rows) != 0 {
t.Fatalf("expected 0 string emitters (only event present), got %d", len(rows))
}
}
4 changes: 3 additions & 1 deletion internal/mcp/tools_enhancements.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,10 +650,12 @@ func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*m
return s.handleAnalyzeConfigReaders(ctx, req)
case "event_emitters":
return s.handleAnalyzeEventEmitters(ctx, req)
case "string_emitters":
return s.handleAnalyzeStringEmitters(ctx, req)
case "error_surface":
return s.handleAnalyzeErrorSurface(ctx, req)
default:
return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, goroutine_spawns, field_writers, annotation_users, config_readers, event_emitters, error_surface)"), nil
return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, goroutine_spawns, field_writers, annotation_users, config_readers, event_emitters, string_emitters, error_surface)"), nil
}
}

Expand Down
Loading
Loading