Skip to content
Merged
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
16 changes: 15 additions & 1 deletion internal/graph/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,21 @@ func writeHuman(w io.Writer, g *api.Graph, filter string) error {
ui.Table(w, []string{"ID", "LABEL", "NAME"}, rows)

rels := g.Rels()
fmt.Fprintf(w, "\n%d nodes, %d relationships", len(nodes), len(rels))
relCount := len(rels)
if filter != "" {
visible := make(map[string]bool, len(nodes))
for _, n := range nodes {
visible[n.ID] = true
}
count := 0
for _, r := range rels {
if visible[r.StartNode] && visible[r.EndNode] {
count++
}
}
relCount = count
}
fmt.Fprintf(w, "\n%d nodes, %d relationships", len(nodes), relCount)
if filter != "" {
fmt.Fprintf(w, " (filtered by label: %s)", filter)
}
Expand Down
29 changes: 29 additions & 0 deletions internal/graph/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ func TestWriteHuman_Filter(t *testing.T) {
}
}

func TestWriteHuman_FilterRelationshipCount(t *testing.T) {
// File nodes connected to each other, plus a Function→Function call.
// When filtering by File, only the file→file relationship should be counted.
g := &api.Graph{
Nodes: []api.Node{
{ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}},
{ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}},
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "doWork"}},
{ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "helper"}},
},
Relationships: []api.Relationship{
{ID: "r1", Type: "imports", StartNode: "f1", EndNode: "f2"},
{ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "fn2"},
},
}
var buf bytes.Buffer
if err := writeHuman(&buf, g, "File"); err != nil {
t.Fatal(err)
}
out := buf.String()
// Only r1 connects two File nodes — r2 connects Functions and must not be counted.
if !strings.Contains(out, "1 relationship") {
t.Errorf("filtered relationship count should be 1, got:\n%s", out)
}
if strings.Contains(out, "2 relationship") {
t.Errorf("should not show 2 relationships when filter excludes one:\n%s", out)
}
}

func TestWriteHuman_FilterExcludes(t *testing.T) {
g := &api.Graph{
Nodes: []api.Node{
Expand Down
17 changes: 17 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,23 @@ func filterGraph(g *api.Graph, label, relType string) graphSlice {
nodes = g.NodesByLabel(label)
}
rels := g.Rels()
// When a label filter is set, restrict relationships to only those where
// both endpoints are within the filtered node set. Without this, the
// returned JSON would contain relationships referencing node IDs that
// are not present in the nodes list.
if label != "" {
visible := make(map[string]bool, len(nodes))
for _, n := range nodes {
visible[n.ID] = true
}
var inLabel []api.Relationship
for _, r := range rels {
if visible[r.StartNode] && visible[r.EndNode] {
inLabel = append(inLabel, r)
}
}
rels = inLabel
}
if relType != "" {
var filtered []api.Relationship
for _, r := range rels {
Expand Down
108 changes: 108 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,114 @@ func TestFormatImpact_WithTarget(t *testing.T) {
}
}

func TestFilterGraph_NoFilter(t *testing.T) {
g := makeTestGraph()
result := filterGraph(g, "", "")
if len(result.Nodes) != len(g.Nodes) {
t.Errorf("no filter: want %d nodes, got %d", len(g.Nodes), len(result.Nodes))
}
if len(result.Relationships) != len(g.Rels()) {
t.Errorf("no filter: want %d rels, got %d", len(g.Rels()), len(result.Relationships))
}
}

func TestFilterGraph_LabelOnly(t *testing.T) {
g := makeTestGraph()
result := filterGraph(g, "File", "")
for _, n := range result.Nodes {
if !n.HasLabel("File") {
t.Errorf("label filter: expected only File nodes, got label %v", n.Labels)
}
}
// Relationships must only reference nodes that are in the result.
visible := make(map[string]bool)
for _, n := range result.Nodes {
visible[n.ID] = true
}
for _, r := range result.Relationships {
if !visible[r.StartNode] || !visible[r.EndNode] {
t.Errorf("label filter: relationship %s→%s references a node not in the filtered set", r.StartNode, r.EndNode)
}
}
}

func TestFilterGraph_LabelExcludesCrossLabelRels(t *testing.T) {
// File nodes + Function nodes; one file→file rel, one function→function rel.
// Filtering by File should yield only the file→file rel.
g := &api.Graph{
Nodes: []api.Node{
{ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}},
{ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}},
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "foo"}},
{ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "bar"}},
},
Relationships: []api.Relationship{
{ID: "r1", Type: "imports", StartNode: "f1", EndNode: "f2"},
{ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "fn2"},
},
}
result := filterGraph(g, "File", "")
if len(result.Relationships) != 1 {
t.Errorf("want 1 rel (file→file), got %d", len(result.Relationships))
}
if len(result.Relationships) > 0 && result.Relationships[0].ID != "r1" {
t.Errorf("want rel r1 (imports), got %s", result.Relationships[0].ID)
}
}

func TestFilterGraph_RelTypeOnly(t *testing.T) {
g := makeTestGraph()
result := filterGraph(g, "", "calls")
for _, r := range result.Relationships {
if r.Type != "calls" {
t.Errorf("relType filter: expected only 'calls', got %q", r.Type)
}
}
// All nodes should be returned when only relType is filtered.
if len(result.Nodes) != len(g.Nodes) {
t.Errorf("relType filter: want all %d nodes, got %d", len(g.Nodes), len(result.Nodes))
}
}

func TestFilterGraph_BothFilters(t *testing.T) {
g := &api.Graph{
Nodes: []api.Node{
{ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}},
{ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}},
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "foo"}},
},
Relationships: []api.Relationship{
{ID: "r1", Type: "imports", StartNode: "f1", EndNode: "f2"},
{ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "f2"}, // fn→file, excluded by label filter
{ID: "r3", Type: "contains_call", StartNode: "f1", EndNode: "f2"}, // file→file, wrong relType
},
}
result := filterGraph(g, "File", "imports")
if len(result.Nodes) != 2 {
t.Errorf("both filters: want 2 File nodes, got %d", len(result.Nodes))
}
if len(result.Relationships) != 1 || result.Relationships[0].ID != "r1" {
t.Errorf("both filters: want only r1 (imports between Files), got %v", result.Relationships)
}
}

// makeTestGraph builds a small mixed graph for filter tests.
func makeTestGraph() *api.Graph {
return &api.Graph{
Nodes: []api.Node{
{ID: "f1", Labels: []string{"File"}, Properties: map[string]any{"path": "a.go"}},
{ID: "f2", Labels: []string{"File"}, Properties: map[string]any{"path": "b.go"}},
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "handleReq"}},
{ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "parse"}},
},
Relationships: []api.Relationship{
{ID: "r1", Type: "imports", StartNode: "f1", EndNode: "f2"},
{ID: "r2", Type: "calls", StartNode: "fn1", EndNode: "fn2"},
{ID: "r3", Type: "defines_function", StartNode: "f1", EndNode: "fn1"},
},
}
}

func TestFormatImpact_NoEntryPoints(t *testing.T) {
result := &api.ImpactResult{
Metadata: api.ImpactMetadata{TargetsAnalyzed: 1, TotalFiles: 50, TotalFunctions: 200},
Expand Down
Loading