package analysis import ( "math" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" ) // connNode is a single node in a connectivity test fixture. type connNode struct { id string kind graph.NodeKind file string } // connEdge is a single directed edge in a connectivity test fixture. type connEdge struct { from string to string } // buildConnGraph assembles a graph from a node/edge fixture so each // test pins exactly which nodes are isolated / leaf / connected. func buildConnGraph(nodes []connNode, edges []connEdge) (*graph.Graph, []*graph.Node) { g := graph.New() for _, n := range nodes { g.AddNode(&graph.Node{ID: n.id, Kind: n.kind, Name: n.id, FilePath: n.file, Language: "go"}) } for _, e := range edges { g.AddEdge(&graph.Edge{From: e.from, To: e.to, Kind: graph.EdgeDefines, Confidence: 1}) } return g, g.AllNodes() } func TestGraphConnectivity(t *testing.T) { tests := []struct { name string // fixture nodes []connNode edges []connEdge // expectations wantNominal int wantEffective int wantRatio float64 wantIsolated int wantLeaf int wantSourceOnly int wantSinkOnly int }{ { name: "empty graph", nodes: nil, edges: nil, wantNominal: 0, wantEffective: 0, wantRatio: 0, }, { // file.go defines fn — fn is a leaf (one edge) and // sink-only (only incoming); file.go is source-only. // No node is isolated. name: "fully connected pair", nodes: []connNode{ {"a.go", graph.KindFile, "a.go"}, {"a.go::Fn", graph.KindFunction, "a.go"}, }, edges: []connEdge{{"a.go", "a.go::Fn"}}, wantNominal: 2, wantEffective: 2, wantRatio: 1.0, wantIsolated: 0, wantLeaf: 2, // both ends of the single edge have degree 1 wantSourceOnly: 1, // a.go wantSinkOnly: 1, // a.go::Fn }, { // One isolated function: zero edges of any kind. This is // the headline extraction-gap signal — NOT dead code. name: "one isolated node", nodes: []connNode{ {"a.go", graph.KindFile, "a.go"}, {"a.go::Fn", graph.KindFunction, "a.go"}, {"orphan.go::Lost", graph.KindFunction, "orphan.go"}, }, edges: []connEdge{{"a.go", "a.go::Fn"}}, wantNominal: 3, wantEffective: 2, wantRatio: 2.0 / 3.0, wantIsolated: 1, // orphan.go::Lost wantLeaf: 2, // a.go and a.go::Fn wantSourceOnly: 1, wantSinkOnly: 1, }, { // A chain a -> b -> c. b has in+out (degree 2, neither // leaf nor source/sink-only). a is source-only, c is // sink-only; both are leaves. name: "three-node chain", nodes: []connNode{ {"a.go::A", graph.KindFunction, "a.go"}, {"a.go::B", graph.KindFunction, "a.go"}, {"a.go::C", graph.KindFunction, "a.go"}, }, edges: []connEdge{ {"a.go::A", "a.go::B"}, {"a.go::B", "a.go::C"}, }, wantNominal: 3, wantEffective: 3, wantRatio: 1.0, wantIsolated: 0, wantLeaf: 2, // A and C wantSourceOnly: 1, // A wantSinkOnly: 1, // C }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g, nodes := buildConnGraph(tt.nodes, tt.edges) report := GraphConnectivity(g, nodes, 0) assert.Equal(t, tt.wantNominal, report.NominalNodes, "nominal_nodes") assert.Equal(t, tt.wantEffective, report.EffectiveNodes, "effective_nodes") assert.InDelta(t, tt.wantRatio, report.EffectiveRatio, 1e-9, "effective_ratio") assert.Equal(t, tt.wantIsolated, report.Isolated, "isolated") assert.Equal(t, tt.wantLeaf, report.Leaf, "leaf") assert.Equal(t, tt.wantSourceOnly, report.SourceOnly, "source_only") assert.Equal(t, tt.wantSinkOnly, report.SinkOnly, "sink_only") assert.NotEmpty(t, report.Note, "report must carry the extraction-vs-dead-code note") }) } } // TestGraphConnectivity_DeadWeightByFile asserts the per-file // dead-weight attribution ranks files by isolated+leaf contribution // so an extraction gap can be localised. func TestGraphConnectivity_DeadWeightByFile(t *testing.T) { // gappy.go contributes 3 isolated nodes; ok.go contributes a // single connected pair (two leaves, zero isolated). nodes := []connNode{ {"ok.go", graph.KindFile, "ok.go"}, {"ok.go::Fn", graph.KindFunction, "ok.go"}, {"gappy.go::L1", graph.KindFunction, "gappy.go"}, {"gappy.go::L2", graph.KindFunction, "gappy.go"}, {"gappy.go::L3", graph.KindFunction, "gappy.go"}, } edges := []connEdge{{"ok.go", "ok.go::Fn"}} g, allNodes := buildConnGraph(nodes, edges) report := GraphConnectivity(g, allNodes, 0) require.Len(t, report.DeadWeightByFile, 2, "both files contribute dead-weight nodes") // gappy.go ranks first: 3 isolated nodes > ok.go's 2 leaf nodes. top := report.DeadWeightByFile[0] assert.Equal(t, "gappy.go", top.FilePath) assert.Equal(t, 3, top.Isolated) assert.Equal(t, 0, top.Leaf) assert.Equal(t, 3, top.DeadWeight) second := report.DeadWeightByFile[1] assert.Equal(t, "ok.go", second.FilePath) assert.Equal(t, 0, second.Isolated) assert.Equal(t, 2, second.Leaf) assert.Equal(t, 2, second.DeadWeight) } // TestGraphConnectivity_FileLimit asserts the fileLimit argument // truncates the dead-weight ranking to the top-N files. func TestGraphConnectivity_FileLimit(t *testing.T) { nodes := []connNode{ {"a.go::A", graph.KindFunction, "a.go"}, {"b.go::B", graph.KindFunction, "b.go"}, {"c.go::C", graph.KindFunction, "c.go"}, } g, allNodes := buildConnGraph(nodes, nil) // all three isolated report := GraphConnectivity(g, allNodes, 2) assert.Len(t, report.DeadWeightByFile, 2, "fileLimit=2 must cap the ranking at 2 files") assert.Equal(t, 3, report.Isolated, "the isolated count is not affected by fileLimit") } // TestGraphConnectivity_ByKind asserts the per-node-kind breakdown // tallies isolated / leaf counts separately per kind. func TestGraphConnectivity_ByKind(t *testing.T) { // One connected file->function pair, plus an isolated type. nodes := []connNode{ {"a.go", graph.KindFile, "a.go"}, {"a.go::Fn", graph.KindFunction, "a.go"}, {"a.go::Orphan", graph.KindType, "a.go"}, } edges := []connEdge{{"a.go", "a.go::Fn"}} g, allNodes := buildConnGraph(nodes, edges) report := GraphConnectivity(g, allNodes, 0) byKind := map[string]ConnectivityKindEntry{} for _, k := range report.ByKind { byKind[k.Kind] = k } require.Contains(t, byKind, "type") assert.Equal(t, 1, byKind["type"].Total) assert.Equal(t, 1, byKind["type"].Isolated, "the orphan type is isolated") assert.Equal(t, 0, byKind["type"].Leaf) require.Contains(t, byKind, "function") assert.Equal(t, 1, byKind["function"].Total) assert.Equal(t, 0, byKind["function"].Isolated) assert.Equal(t, 1, byKind["function"].Leaf, "the defined function has degree 1") } // TestGraphConnectivity_IsolatedIsNotDeadCode pins the load-bearing // distinction: an isolated node (zero edges of ANY kind) is an // extraction-gap signal, whereas a dead-code node still carries a // structural edge. A node that is `defines`-linked from its file but // has no incoming usage edge is dead code, NOT isolated — this // analyzer must not count it as isolated. func TestGraphConnectivity_IsolatedIsNotDeadCode(t *testing.T) { nodes := []connNode{ {"a.go", graph.KindFile, "a.go"}, // Defined by its file but never used — classic dead code. {"a.go::Unused", graph.KindFunction, "a.go"}, // No edges at all — an extraction gap. {"b.go::Missing", graph.KindFunction, "b.go"}, } edges := []connEdge{{"a.go", "a.go::Unused"}} g, allNodes := buildConnGraph(nodes, edges) report := GraphConnectivity(g, allNodes, 0) // Only the zero-edge node counts as isolated; the dead-code node // has a structural edge and does not. assert.Equal(t, 1, report.Isolated, "only the zero-edge node is isolated") // Cross-check against the shared classifier the analyzer reuses. assert.Equal(t, graph.ZeroEdgePossibleExtractionGap, graph.ClassifyZeroEdge(g, "b.go::Missing"), "the zero-edge node classifies as an extraction gap") assert.NotEqual(t, graph.ZeroEdgePossibleExtractionGap, graph.ClassifyZeroEdge(g, "a.go::Unused"), "the structurally-linked dead-code node is NOT an extraction gap") } // TestGraphConnectivity_NilGraph asserts a nil graph yields a zero // report rather than panicking. func TestGraphConnectivity_NilGraph(t *testing.T) { report := GraphConnectivity(nil, nil, 0) assert.Equal(t, 0, report.NominalNodes) assert.Equal(t, 0, report.EffectiveNodes) assert.True(t, math.IsNaN(report.EffectiveRatio) == false, "ratio stays a real number") }