Files
zzet--gortex/internal/analysis/deadcode_test.go
T
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

1055 lines
35 KiB
Go

package analysis
import (
"fmt"
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"pgregory.net/rapid"
)
// Feature: gortex-enhancements, Property 5: Dead code has zero incoming edges
// --- Generators ---
// deadCodeGraphResult holds a generated graph with known dead and live symbols.
type deadCodeGraphResult struct {
Graph *graph.Graph
Processes *ProcessResult
DeadIDs []string // symbols that should be detected as dead code
LiveIDs []string // symbols that should NOT be detected as dead code
}
// genDeadCodeGraph generates a random graph with a mix of dead and live symbols.
// Dead symbols: unexported, non-test, non-process, zero incoming calls/references.
// Live symbols: have incoming edges, or are exported, or are in test files, or are in processes.
func genDeadCodeGraph() *rapid.Generator[deadCodeGraphResult] {
return rapid.Custom(func(t *rapid.T) deadCodeGraphResult {
g := graph.New()
processes := &ProcessResult{
Processes: nil,
NodeToProcs: make(map[string][]string),
}
var deadIDs []string
var liveIDs []string
// Generate 2-6 dead symbols: unexported, non-test file, no incoming edges, not in process
numDead := rapid.IntRange(2, 6).Draw(t, "numDead")
for i := range numDead {
id := fmt.Sprintf("pkg/internal/mod%d.go::helper%d", i, i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("helper%d", i), // lowercase = unexported in Go
FilePath: fmt.Sprintf("pkg/internal/mod%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
deadIDs = append(deadIDs, id)
}
// Generate 1-4 live symbols with incoming call edges
numLiveCalled := rapid.IntRange(1, 4).Draw(t, "numLiveCalled")
for i := range numLiveCalled {
calleeID := fmt.Sprintf("pkg/called%d.go::calledFunc%d", i, i)
callerID := fmt.Sprintf("pkg/caller%d.go::callerFunc%d", i, i)
g.AddNode(&graph.Node{
ID: calleeID,
Kind: graph.KindFunction,
Name: fmt.Sprintf("calledFunc%d", i),
FilePath: fmt.Sprintf("pkg/called%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
g.AddNode(&graph.Node{
ID: callerID,
Kind: graph.KindFunction,
Name: fmt.Sprintf("callerFunc%d", i),
FilePath: fmt.Sprintf("pkg/caller%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
g.AddEdge(&graph.Edge{
From: callerID,
To: calleeID,
Kind: graph.EdgeCalls,
})
liveIDs = append(liveIDs, calleeID)
}
// Generate 1-3 live symbols that are exported (uppercase name in Go)
numExported := rapid.IntRange(1, 3).Draw(t, "numExported")
for i := range numExported {
id := fmt.Sprintf("pkg/exported%d.go::ExportedFunc%d", i, i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("ExportedFunc%d", i), // uppercase = exported
FilePath: fmt.Sprintf("pkg/exported%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
liveIDs = append(liveIDs, id)
}
// Generate 1-2 live symbols in test files
numTest := rapid.IntRange(1, 2).Draw(t, "numTest")
for i := range numTest {
id := fmt.Sprintf("pkg/mod_test.go::testHelper%d", i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("testHelper%d", i),
FilePath: "pkg/mod_test.go",
StartLine: 1,
EndLine: 10,
Language: "go",
})
liveIDs = append(liveIDs, id)
}
// Generate 1-2 live symbols that are process members
numProcess := rapid.IntRange(1, 2).Draw(t, "numProcess")
var procSteps []Step
for i := range numProcess {
id := fmt.Sprintf("pkg/entry%d.go::entryFunc%d", i, i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("entryFunc%d", i),
FilePath: fmt.Sprintf("pkg/entry%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
procSteps = append(procSteps, Step{ID: id, Depth: i})
processes.NodeToProcs[id] = []string{"process-0"}
liveIDs = append(liveIDs, id)
}
processes.Processes = []Process{{
ID: "process-0",
Name: "test process",
EntryPoint: procSteps[0].ID,
Steps: procSteps,
StepCount: len(procSteps),
}}
// Generate 1-2 live symbols with incoming reference edges
numReferenced := rapid.IntRange(1, 2).Draw(t, "numReferenced")
for i := range numReferenced {
refID := fmt.Sprintf("pkg/ref%d.go::referencedVar%d", i, i)
refByID := fmt.Sprintf("pkg/refby%d.go::refByFunc%d", i, i)
g.AddNode(&graph.Node{
ID: refID,
Kind: graph.KindVariable,
Name: fmt.Sprintf("referencedVar%d", i),
FilePath: fmt.Sprintf("pkg/ref%d.go", i),
StartLine: 1,
EndLine: 5,
Language: "go",
})
g.AddNode(&graph.Node{
ID: refByID,
Kind: graph.KindFunction,
Name: fmt.Sprintf("refByFunc%d", i),
FilePath: fmt.Sprintf("pkg/refby%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
g.AddEdge(&graph.Edge{
From: refByID,
To: refID,
Kind: graph.EdgeReferences,
})
liveIDs = append(liveIDs, refID)
}
return deadCodeGraphResult{
Graph: g,
Processes: processes,
DeadIDs: deadIDs,
LiveIDs: liveIDs,
}
})
}
// --- Property Tests ---
// TestPropertyDeadCode_ZeroIncomingEdges verifies that every symbol returned by
// FindDeadCode has zero incoming calls or references edges, is not a process member,
// is not in a test file, and is not exported.
func TestPropertyDeadCode_ZeroIncomingEdges(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genDeadCodeGraph().Draw(rt, "deadCodeGraph")
result := FindDeadCode(tc.Graph, tc.Processes, nil)
resultIDs := make(map[string]bool)
for _, entry := range result {
resultIDs[entry.ID] = true
// Verify zero incoming calls or references
inEdges := tc.Graph.GetInEdges(entry.ID)
for _, e := range inEdges {
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
rt.Errorf("dead code entry %s has incoming %s edge from %s", entry.ID, e.Kind, e.From)
}
}
// Verify not a process member
if _, inProc := tc.Processes.NodeToProcs[entry.ID]; inProc {
rt.Errorf("dead code entry %s is a process member", entry.ID)
}
// Verify not in a test file
if isTestFilePath(entry.FilePath) {
rt.Errorf("dead code entry %s is in a test file: %s", entry.ID, entry.FilePath)
}
// Verify not exported
node := tc.Graph.GetNode(entry.ID)
if node != nil && isExportedSymbol(node.Name, node.Language) {
rt.Errorf("dead code entry %s is exported: %s", entry.ID, node.Name)
}
}
// Verify all known dead symbols are in the result
for _, deadID := range tc.DeadIDs {
if !resultIDs[deadID] {
rt.Errorf("expected dead symbol %s was not in FindDeadCode result", deadID)
}
}
// Verify no known live symbols are in the result
for _, liveID := range tc.LiveIDs {
if resultIDs[liveID] {
rt.Errorf("live symbol %s was incorrectly reported as dead code", liveID)
}
}
})
}
// TestPropertyDeadCode_Completeness verifies that no symbol meeting all dead code
// criteria is omitted from the result (the converse direction).
func TestPropertyDeadCode_Completeness(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genDeadCodeGraph().Draw(rt, "deadCodeGraph")
result := FindDeadCode(tc.Graph, tc.Processes, nil)
resultIDs := make(map[string]bool)
for _, entry := range result {
resultIDs[entry.ID] = true
}
// For every node in the graph, if it meets all dead code criteria,
// it must be in the result.
for _, node := range tc.Graph.AllNodes() {
// Never-reported kinds (file/package/import + the broader
// noise set: param/closure/module/string/etc).
if neverDeadCodeKinds[node.Kind] {
continue
}
// Variables / fields / constants excluded by default — same
// reasoning the analyzer uses (graph lacks intra-function
// data flow, so a non-opt-in scan must skip them).
if node.Kind == graph.KindVariable {
continue
}
if node.Kind == graph.KindField {
continue
}
if node.Kind == graph.KindConstant {
continue
}
// Go init() excluded
if node.Name == "init" && node.Language == "go" {
continue
}
// Go main() excluded
if node.Name == "main" && node.Language == "go" && node.Kind == graph.KindFunction {
continue
}
// Generated/vendored files excluded
if isVendoredOrGenerated(node.FilePath) {
continue
}
// Build-constrained files excluded
if node.Language == "go" && hasBuildConstraint(node.FilePath) {
continue
}
// Per-kind incoming-edge allowlist — mirror what the
// analyzer does. Functions/methods need Calls/References;
// types need References/Instantiates/MemberOf/...; etc.
allowed := incomingUsageKinds(node.Kind)
inEdges := tc.Graph.GetInEdges(node.ID)
hasIncoming := false
for _, e := range inEdges {
for _, k := range allowed {
if e.Kind == k {
hasIncoming = true
break
}
}
if hasIncoming {
break
}
}
if hasIncoming {
continue
}
// Well-known interface methods excluded
if node.Kind == graph.KindMethod && isWellKnownInterfaceMethod(node.Name, node.Language) {
continue
}
// CGo exports excluded by default
if cgoExport, ok := node.Meta["cgo_export"].(bool); ok && cgoExport {
continue
}
// go:linkname targets excluded by default
if linkname, ok := node.Meta["go_linkname"].(bool); ok && linkname {
continue
}
// Check exclusions
if _, inProc := tc.Processes.NodeToProcs[node.ID]; inProc {
continue
}
if isTestFilePath(node.FilePath) {
continue
}
if isExportedSymbol(node.Name, node.Language) && !isPackagePrivateByConvention(node.FilePath, node.Language) {
continue
}
// This node meets all dead code criteria — it must be in the result
if !resultIDs[node.ID] {
rt.Errorf("symbol %s meets all dead code criteria but was not returned by FindDeadCode", node.ID)
}
}
})
}
// Feature: gortex-enhancements, Property 6: Hotspot complexity score matches formula
// --- Generators ---
// hotspotGraphResult holds a generated graph with known fan_in/fan_out/crossing values.
type hotspotGraphResult struct {
Graph *graph.Graph
Communities *CommunityResult
// Expected raw values per function node ID
ExpectedFanIn map[string]int
ExpectedFanOut map[string]int
ExpectedCrossing map[string]int
}
// genHotspotGraph generates a graph with function nodes that have known
// fan_in, fan_out, and community crossing values for score verification.
func genHotspotGraph() *rapid.Generator[hotspotGraphResult] {
return rapid.Custom(func(t *rapid.T) hotspotGraphResult {
g := graph.New()
// Create 3-8 function nodes spread across 2-3 communities
numFuncs := rapid.IntRange(3, 8).Draw(t, "numFuncs")
numComms := rapid.IntRange(2, 3).Draw(t, "numComms")
commNames := make([]string, numComms)
for i := range numComms {
commNames[i] = fmt.Sprintf("community-%d", i)
}
nodeToComm := make(map[string]string)
funcIDs := make([]string, numFuncs)
funcComms := make([]string, numFuncs) // which community each func belongs to
for i := range numFuncs {
id := fmt.Sprintf("pkg/mod%d.go::func%d", i, i)
funcIDs[i] = id
commIdx := rapid.IntRange(0, numComms-1).Draw(t, fmt.Sprintf("comm%d", i))
funcComms[i] = commNames[commIdx]
nodeToComm[id] = commNames[commIdx]
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("func%d", i),
FilePath: fmt.Sprintf("pkg/mod%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
communities := &CommunityResult{
NodeToComm: nodeToComm,
}
expectedFanIn := make(map[string]int)
expectedFanOut := make(map[string]int)
expectedCrossing := make(map[string]int)
// Add random call edges between functions
numEdges := rapid.IntRange(2, numFuncs*2).Draw(t, "numEdges")
seen := make(map[string]bool)
for e := 0; e < numEdges; e++ {
fromIdx := rapid.IntRange(0, numFuncs-1).Draw(t, fmt.Sprintf("from%d", e))
toIdx := rapid.IntRange(0, numFuncs-1).Draw(t, fmt.Sprintf("to%d", e))
if fromIdx == toIdx {
continue
}
key := fmt.Sprintf("%d->%d", fromIdx, toIdx)
if seen[key] {
continue
}
seen[key] = true
fromID := funcIDs[fromIdx]
toID := funcIDs[toIdx]
// Randomly choose calls or references
edgeKind := graph.EdgeCalls
if rapid.Bool().Draw(t, fmt.Sprintf("isRef%d", e)) {
edgeKind = graph.EdgeReferences
}
g.AddEdge(&graph.Edge{
From: fromID,
To: toID,
Kind: edgeKind,
})
// fan_in: incoming calls + references
expectedFanIn[toID]++
// fan_out: outgoing calls only
if edgeKind == graph.EdgeCalls {
expectedFanOut[fromID]++
}
// community crossings: outgoing edges to different community
if funcComms[fromIdx] != funcComms[toIdx] {
expectedCrossing[fromID]++
}
}
return hotspotGraphResult{
Graph: g,
Communities: communities,
ExpectedFanIn: expectedFanIn,
ExpectedFanOut: expectedFanOut,
ExpectedCrossing: expectedCrossing,
}
})
}
// --- Property Tests ---
// hotspotBetweennessComponent recomputes the 0-100 normalized
// betweenness term FindHotspots folds into a node's raw score. The
// generated hotspot graphs are tiny, so ComputeBetweenness runs the
// exact path here — the expected score stays faithful to the
// implementation without re-deriving Brandes' in the test.
func hotspotBetweennessComponent(g *graph.Graph) map[string]float64 {
bc := ComputeBetweenness(g)
out := make(map[string]float64, len(bc.Scores))
if bc.Max > 0 {
for id, v := range bc.Scores {
out[id] = (v / bc.Max) * 100.0
}
}
return out
}
// TestPropertyHotspot_ComplexityScoreFormula verifies that for every hotspot entry,
// ComplexityScore equals (fan_in * 2) + (fan_out * 1.5) + (community_crossings * 3) +
// (betweenness * hotspotBetweennessWeight) normalized to [0, 100], and that
// FindHotspots returns exactly those symbols whose score exceeds the threshold.
func TestPropertyHotspot_ComplexityScoreFormula(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genHotspotGraph().Draw(rt, "hotspotGraph")
// Use threshold=0 to get ALL function nodes with any score
result := FindHotspots(tc.Graph, tc.Communities, 0)
betweenness := hotspotBetweennessComponent(tc.Graph)
// Compute the max raw score across all function nodes to verify normalization
maxRaw := 0.0
for _, id := range tc.Graph.AllNodes() {
if id.Kind != graph.KindFunction && id.Kind != graph.KindMethod {
continue
}
fi := tc.ExpectedFanIn[id.ID]
fo := tc.ExpectedFanOut[id.ID]
cc := tc.ExpectedCrossing[id.ID]
raw := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[id.ID]*hotspotBetweennessWeight
if raw > maxRaw {
maxRaw = raw
}
}
for _, entry := range result {
fi := tc.ExpectedFanIn[entry.ID]
fo := tc.ExpectedFanOut[entry.ID]
cc := tc.ExpectedCrossing[entry.ID]
// Verify fan_in, fan_out, community_crossings match
if entry.FanIn != fi {
rt.Errorf("hotspot %s: FanIn = %d, expected %d", entry.ID, entry.FanIn, fi)
}
if entry.FanOut != fo {
rt.Errorf("hotspot %s: FanOut = %d, expected %d", entry.ID, entry.FanOut, fo)
}
if entry.CommunityCrossings != cc {
rt.Errorf("hotspot %s: CommunityCrossings = %d, expected %d", entry.ID, entry.CommunityCrossings, cc)
}
// Verify the reported betweenness matches the 0-100 normalized value.
expectedBw := math.Round(betweenness[entry.ID]*100) / 100
if math.Abs(entry.Betweenness-expectedBw) > 0.01 {
rt.Errorf("hotspot %s: Betweenness = %.2f, expected %.2f", entry.ID, entry.Betweenness, expectedBw)
}
// Verify complexity score matches formula
rawScore := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[entry.ID]*hotspotBetweennessWeight
var expectedNormalized float64
if maxRaw > 0 {
expectedNormalized = (rawScore / maxRaw) * 100.0
}
expectedNormalized = math.Round(expectedNormalized*100) / 100
if math.Abs(entry.ComplexityScore-expectedNormalized) > 0.01 {
rt.Errorf("hotspot %s: ComplexityScore = %.2f, expected %.2f (raw=%.2f, maxRaw=%.2f, fi=%d, fo=%d, cc=%d)",
entry.ID, entry.ComplexityScore, expectedNormalized, rawScore, maxRaw, fi, fo, cc)
}
// Verify score is in [0, 100]
if entry.ComplexityScore < 0 || entry.ComplexityScore > 100 {
rt.Errorf("hotspot %s: ComplexityScore = %.2f, not in [0, 100]", entry.ID, entry.ComplexityScore)
}
}
})
}
// TestPropertyHotspot_ThresholdFiltering verifies that FindHotspots returns
// exactly those symbols whose normalized score exceeds the given threshold.
func TestPropertyHotspot_ThresholdFiltering(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genHotspotGraph().Draw(rt, "hotspotGraph")
// Pick a random threshold between 0 and 100
threshold := rapid.Float64Range(0.01, 99.0).Draw(rt, "threshold")
result := FindHotspots(tc.Graph, tc.Communities, threshold)
// All returned entries must have score >= threshold
for _, entry := range result {
if entry.ComplexityScore < threshold {
rt.Errorf("hotspot %s has score %.2f below threshold %.2f",
entry.ID, entry.ComplexityScore, threshold)
}
}
// Compute all scores to verify no symbol above threshold is missing
betweenness := hotspotBetweennessComponent(tc.Graph)
maxRaw := 0.0
type nodeScore struct {
id string
raw float64
}
var allScores []nodeScore
for _, n := range tc.Graph.AllNodes() {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
fi := tc.ExpectedFanIn[n.ID]
fo := tc.ExpectedFanOut[n.ID]
cc := tc.ExpectedCrossing[n.ID]
raw := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + betweenness[n.ID]*hotspotBetweennessWeight
allScores = append(allScores, nodeScore{n.ID, raw})
if raw > maxRaw {
maxRaw = raw
}
}
resultIDs := make(map[string]bool)
for _, entry := range result {
resultIDs[entry.ID] = true
}
for _, ns := range allScores {
var normalized float64
if maxRaw > 0 {
normalized = (ns.raw / maxRaw) * 100.0
}
normalized = math.Round(normalized*100) / 100
if normalized >= threshold && !resultIDs[ns.id] {
rt.Errorf("symbol %s has score %.2f >= threshold %.2f but was not returned",
ns.id, normalized, threshold)
}
}
})
}
// --- Unit Tests ---
// TestHotspots_SmallGraphError verifies that a graph with fewer than 10 function/method
// symbols returns an empty result from FindHotspots.
func TestHotspots_SmallGraphError(t *testing.T) {
g := graph.New()
// Add only 5 function nodes (< 10)
for i := 0; i < 5; i++ {
g.AddNode(&graph.Node{
ID: fmt.Sprintf("pkg/small%d.go::func%d", i, i),
Kind: graph.KindFunction,
Name: fmt.Sprintf("func%d", i),
FilePath: fmt.Sprintf("pkg/small%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Add some edges so there's non-zero scores
g.AddEdge(&graph.Edge{
From: "pkg/small0.go::func0",
To: "pkg/small1.go::func1",
Kind: graph.EdgeCalls,
})
g.AddEdge(&graph.Edge{
From: "pkg/small2.go::func2",
To: "pkg/small3.go::func3",
Kind: graph.EdgeCalls,
})
communities := &CommunityResult{
NodeToComm: make(map[string]string),
}
// With default threshold (mean + 2*stddev), a small graph should return
// empty or very few results. The MCP handler enforces the < 10 symbols error,
// but at the analysis level, FindHotspots should still work correctly.
result := FindHotspots(g, communities, 0)
// With threshold=0, we get all nodes that have any score.
// The important thing is the function doesn't panic on small graphs.
assert.LessOrEqual(t, len(result), 5, "small graph should have at most 5 hotspots")
// Verify that with a very high threshold, we get empty results
result = FindHotspots(g, communities, 101)
assert.Empty(t, result, "threshold above 100 should return no hotspots")
}
// TestHotspots_EmptyGraph verifies FindHotspots handles an empty graph gracefully.
func TestHotspots_EmptyGraph(t *testing.T) {
g := graph.New()
communities := &CommunityResult{
NodeToComm: make(map[string]string),
}
result := FindHotspots(g, communities, 0)
assert.Empty(t, result)
}
// TestDeadCode_StructuralNodesExcluded verifies that file, import, and package
// nodes are never reported as dead code.
func TestDeadCode_StructuralNodesExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "file1.go", Kind: graph.KindFile, Name: "file1.go", FilePath: "file1.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg1", Kind: graph.KindPackage, Name: "pkg1", FilePath: "pkg1", Language: "go"})
g.AddNode(&graph.Node{ID: "import1", Kind: graph.KindImport, Name: "fmt", FilePath: "file1.go", Language: "go"})
processes := &ProcessResult{
NodeToProcs: make(map[string][]string),
}
result := FindDeadCode(g, processes, nil)
assert.Empty(t, result, "structural nodes should never be reported as dead code")
}
// TestDeadCode_GeneratedFilesExcluded verifies that symbols in generated files
// (protobuf, codegen, mocks) are not reported as dead code.
func TestDeadCode_GeneratedFilesExcluded(t *testing.T) {
g := graph.New()
generatedFiles := []struct {
file string
name string
}{
{"pkg/api.pb.go", "apiHelper"},
{"pkg/api_grpc.pb.go", "grpcHelper"},
{"pkg/types_gen.go", "genHelper"},
{"pkg/types_generated.go", "generatedHelper"},
{"pkg/types.gen.go", "dotGenHelper"},
{"pkg/zz_generated.deepcopy.go", "deepCopyHelper"},
{"pkg/mock_service.go", "mockHelper"},
{"pkg/service_mock.go", "mockHelper2"},
}
for _, gf := range generatedFiles {
g.AddNode(&graph.Node{
ID: gf.file + "::" + gf.name, Kind: graph.KindFunction,
Name: gf.name, FilePath: gf.file, StartLine: 1, EndLine: 10, Language: "go",
})
}
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "symbols in generated files should be excluded")
}
// TestDeadCode_MainFunctionExcluded verifies that Go main() is not reported as dead.
func TestDeadCode_MainFunctionExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "cmd/app/main.go::main", Kind: graph.KindFunction,
Name: "main", FilePath: "cmd/app/main.go", StartLine: 5, EndLine: 20, Language: "go",
})
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "Go main() should be excluded as runtime entry point")
}
// TestDeadCode_MainMethodNotExcluded verifies that a method named main on a
// type IS reported as dead (only the package-level main function is special).
func TestDeadCode_MainMethodNotExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/foo.go::Foo.main", Kind: graph.KindMethod,
Name: "main", FilePath: "pkg/foo.go", StartLine: 5, EndLine: 10, Language: "go",
})
result := FindDeadCode(g, nil, nil)
assert.Len(t, result, 1, "a method named main should still be reported as dead")
}
// TestDeadCode_WellKnownMethodsExcluded verifies that methods matching
// well-known stdlib interface names are excluded even without implements edges.
func TestDeadCode_WellKnownMethodsExcluded(t *testing.T) {
g := graph.New()
wellKnown := []string{"ServeHTTP", "MarshalJSON", "UnmarshalJSON", "String", "Error", "Read", "Write", "Close"}
for _, name := range wellKnown {
g.AddNode(&graph.Node{
ID: "pkg/foo.go::myType." + name, Kind: graph.KindMethod,
Name: name, FilePath: "pkg/foo.go", StartLine: 1, EndLine: 5, Language: "go",
})
}
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "well-known interface methods should be excluded")
}
// TestDeadCode_WellKnownDoesNotSuppressOtherMethods verifies that non-well-known
// method names are still reported as dead.
func TestDeadCode_WellKnownDoesNotSuppressOtherMethods(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/foo.go::myType.handleInternal", Kind: graph.KindMethod,
Name: "handleInternal", FilePath: "pkg/foo.go", StartLine: 1, EndLine: 5, Language: "go",
})
result := FindDeadCode(g, nil, nil)
assert.Len(t, result, 1, "non-well-known methods should still be reported")
assert.Equal(t, "pkg/foo.go::myType.handleInternal", result[0].ID)
}
// TestDeadCode_CgoExportExcluded verifies that functions with cgo_export Meta
// are excluded by default but included when IncludeCgoExports is set.
func TestDeadCode_CgoExportExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/bridge.go::bridge_init", Kind: graph.KindFunction,
Name: "bridge_init", FilePath: "pkg/bridge.go", StartLine: 10, EndLine: 20, Language: "go",
Meta: map[string]any{"cgo_export": true},
})
// Default: excluded
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "CGo exports should be excluded by default")
// With IncludeCgoExports: included
result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeCgoExports: true})
assert.Len(t, result, 1, "CGo exports should be included when IncludeCgoExports is true")
}
// TestDeadCode_LinknameExcluded verifies that functions with go_linkname Meta
// are excluded by default but included when IncludeLinknameTargets is set.
func TestDeadCode_LinknameExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/runtime.go::nanotime", Kind: graph.KindFunction,
Name: "nanotime", FilePath: "pkg/runtime.go", StartLine: 10, EndLine: 15, Language: "go",
Meta: map[string]any{"go_linkname": true},
})
// Default: excluded
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "linkname targets should be excluded by default")
// With IncludeLinknameTargets: included
result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeLinknameTargets: true})
assert.Len(t, result, 1, "linkname targets should be included when IncludeLinknameTargets is true")
}
// TestDeadCode_CrossRepoNodeExcluded verifies that nodes with a RepoPrefix
// are excluded when SkipCrossRepoNodes is set.
func TestDeadCode_CrossRepoNodeExcluded(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "github.com/other/repo/pkg/util.go::helperFunc", Kind: graph.KindFunction,
Name: "helperFunc", FilePath: "pkg/util.go", StartLine: 1, EndLine: 10, Language: "go",
RepoPrefix: "github.com/other/repo",
})
// Default: included (so users see them)
result := FindDeadCode(g, nil, nil)
assert.Len(t, result, 1, "cross-repo nodes should be included by default")
// With SkipCrossRepoNodes: excluded
result = FindDeadCode(g, nil, nil, FindDeadCodeOptions{SkipCrossRepoNodes: true})
assert.Empty(t, result, "cross-repo nodes should be excluded when SkipCrossRepoNodes is true")
}
// TestDeadCode_ExpandedBuildConstraints verifies that the expanded set of
// OS/arch-suffixed files are excluded.
func TestDeadCode_ExpandedBuildConstraints(t *testing.T) {
g := graph.New()
constrainedFiles := []string{
"pkg/net_openbsd.go", "pkg/net_plan9.go", "pkg/net_js.go",
"pkg/asm_riscv64.go", "pkg/asm_s390x.go", "pkg/sys_purego.go",
}
for i, f := range constrainedFiles {
g.AddNode(&graph.Node{
ID: f + "::helper", Kind: graph.KindFunction,
Name: "helper", FilePath: f, StartLine: 1, EndLine: 10, Language: "go",
})
_ = i
}
result := FindDeadCode(g, nil, nil)
assert.Empty(t, result, "symbols in build-constrained files should be excluded")
}
// Regression: exported symbols inside `internal/` directories are
// package-private by Go's import rules — the compiler refuses to let
// external packages import them. So if the indexed graph has no caller,
// they're genuinely dead. The pre-fix behaviour was to skip every
// exported name unconditionally, silently hiding dead code anywhere a
// project used Go's `internal/` convention.
func TestDeadCode_ExportedInsideInternalIsSurfaced(t *testing.T) {
g := graph.New()
// Exported (capitalised) method inside `internal/` with zero callers.
// Real-world case: `func (b *Node) Test() bool` in
// gortex/internal/parser/tsitter/tsitter.go — used by the user as a
// dead-code-detection probe and expected to show up.
g.AddNode(&graph.Node{
ID: "gortex/internal/parser/tsitter/tsitter.go::Node.Test",
Kind: graph.KindMethod, Name: "Test",
FilePath: "gortex/internal/parser/tsitter/tsitter.go",
StartLine: 85, EndLine: 85, Language: "go",
Meta: map[string]any{"receiver": "Node"},
})
// Also: an exported function that's NOT inside internal/. Must still
// be excluded (the user's public-API code).
g.AddNode(&graph.Node{
ID: "pkg/gortex/api.go::DoThing", Kind: graph.KindFunction,
Name: "DoThing", FilePath: "pkg/gortex/api.go",
StartLine: 10, EndLine: 12, Language: "go",
})
// And an unexported function inside internal/ — pre-fix this was
// already surfaced; the new code path must not regress that.
g.AddNode(&graph.Node{
ID: "gortex/internal/helpers.go::helper", Kind: graph.KindFunction,
Name: "helper", FilePath: "gortex/internal/helpers.go",
StartLine: 5, EndLine: 7, Language: "go",
})
result := FindDeadCode(g, nil, nil)
ids := make(map[string]bool)
for _, e := range result {
ids[e.ID] = true
}
assert.True(t, ids["gortex/internal/parser/tsitter/tsitter.go::Node.Test"],
"exported method inside internal/ with zero callers must be surfaced as dead code")
assert.False(t, ids["pkg/gortex/api.go::DoThing"],
"exported function outside internal/ stays excluded — could be called externally")
assert.True(t, ids["gortex/internal/helpers.go::helper"],
"unexported function inside internal/ stays surfaced (no regression)")
}
// TestDeadCode_NeverDeadCodeKinds verifies that the never-reported kinds
// (param, closure, module, string, enum_member, todo, etc) are NEVER
// returned, even when they have zero incoming edges and look "dead"
// structurally. Without this filter, a typical workspace surfaces
// ~18k noise rows (params, fields, npm modules) that drown out the
// ~300 real function-level dead-code signals.
func TestDeadCode_NeverDeadCodeKinds(t *testing.T) {
g := graph.New()
// One example per never-reported kind, all unexported and
// edge-less so they'd otherwise look dead.
cases := []struct {
id string
kind graph.NodeKind
}{
{"pkg/a.go::func#param:x", graph.KindParam},
{"pkg/a.go::func#closure@10", graph.KindClosure},
{"pkg/a.go::Foo#tparam:T", graph.KindGenericParam},
{"module::npm:lodash@4.17.21", graph.KindModule},
{"string::error_msg::oops", graph.KindString},
{"pkg/a.go::Color.Red", graph.KindEnumMember},
{"db::pg::public.users.email", graph.KindColumn},
{"db::pg::public.users", graph.KindTable},
{"cfg::env::API_KEY", graph.KindConfigKey},
{"flag::launchdarkly::beta", graph.KindFlag},
{"event::metric::requests_total", graph.KindEvent},
{"migration::pg::001_init", graph.KindMigration},
{"fixture::testdata/sample.json", graph.KindFixture},
{"todo::pkg/a.go:42", graph.KindTodo},
{"team::@platform", graph.KindTeam},
{"release::v1.2.3", graph.KindRelease},
{"license::MIT", graph.KindLicense},
{"k8s::Deployment::default::api", graph.KindResource},
{"kustomize::overlays/prod", graph.KindKustomization},
{"image::nginx:1.25", graph.KindImage},
{"contract::Request::POST::/v1/foo", graph.KindContract},
}
for _, c := range cases {
g.AddNode(&graph.Node{
ID: c.id, Kind: c.kind, Name: "x", FilePath: "pkg/a.go",
StartLine: 1, EndLine: 1, Language: "go",
})
}
result := FindDeadCode(g, nil, nil, FindDeadCodeOptions{
// Enable the broadest opt-ins; never-dead kinds must still
// stay suppressed even with every flag flipped.
IncludeVariables: true, IncludeFields: true, IncludeConstants: true,
})
for _, e := range result {
t.Errorf("never-reported kind %s leaked into dead_code: %s", e.Kind, e.ID)
}
}
// TestDeadCode_FieldsAndConstantsOptIn verifies fields and constants
// are excluded by default and only appear when their opt-in flags
// fire. Mirrors the existing IncludeVariables contract.
func TestDeadCode_FieldsAndConstantsOptIn(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/a.go::User.email", Kind: graph.KindField, Name: "email",
FilePath: "pkg/a.go", StartLine: 5, Language: "go",
})
g.AddNode(&graph.Node{
ID: "pkg/a.go::maxRetries", Kind: graph.KindConstant, Name: "maxRetries",
FilePath: "pkg/a.go", StartLine: 10, Language: "go",
})
// Default: both excluded.
def := FindDeadCode(g, nil, nil)
assert.Empty(t, def, "fields and constants excluded by default")
// Fields opt-in: only the field surfaces.
withFields := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true})
require.Len(t, withFields, 1)
assert.Equal(t, "field", withFields[0].Kind)
// Constants opt-in: only the constant surfaces.
withConsts := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeConstants: true})
require.Len(t, withConsts, 1)
assert.Equal(t, "constant", withConsts[0].Kind)
// Both flags: both surface.
both := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true, IncludeConstants: true})
assert.Len(t, both, 2)
}
// TestDeadCode_FieldWithReadIsAlive — fields are exercised by EdgeReads,
// not EdgeCalls. The pre-fix global allowlist {Calls,References,...}
// missed every field read, so every field-with-readers also looked
// "dead" once IncludeFields was on. The per-kind allowlist must count
// EdgeReads (and EdgeWrites) for KindField, KindVariable, KindConstant.
func TestDeadCode_FieldWithReadIsAlive(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/a.go::User.email", Kind: graph.KindField, Name: "email",
FilePath: "pkg/a.go", StartLine: 5, Language: "go",
})
g.AddNode(&graph.Node{
ID: "pkg/a.go::printUser", Kind: graph.KindFunction, Name: "printUser",
FilePath: "pkg/a.go", StartLine: 20, Language: "go",
})
// printUser reads User.email — EdgeReads, exactly what the
// resolver lands on a field selector.
g.AddEdge(&graph.Edge{
From: "pkg/a.go::printUser", To: "pkg/a.go::User.email",
Kind: graph.EdgeReads, FilePath: "pkg/a.go", Line: 21,
})
result := FindDeadCode(g, nil, nil, FindDeadCodeOptions{IncludeFields: true})
for _, e := range result {
assert.NotEqual(t, "pkg/a.go::User.email", e.ID,
"field with an incoming Reads edge must not be reported as dead")
}
}
// TestDeadCode_TypeReferencedAsParamIsAlive — types are exercised by
// References / TypedAs / Instantiates. The pre-fix global allowlist
// caught References but not TypedAs, so a type used only as a
// parameter declaration (no constructor, no method receivers) would
// have been reported dead. The per-kind allowlist covers it.
func TestDeadCode_TypeReferencedAsParamIsAlive(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "pkg/a.go::Config", Kind: graph.KindType, Name: "Config",
FilePath: "pkg/a.go", StartLine: 5, Language: "go",
})
g.AddNode(&graph.Node{
ID: "pkg/a.go::Run#param:cfg", Kind: graph.KindParam, Name: "cfg",
FilePath: "pkg/a.go", StartLine: 20, Language: "go",
})
g.AddEdge(&graph.Edge{
From: "pkg/a.go::Run#param:cfg", To: "pkg/a.go::Config",
Kind: graph.EdgeTypedAs, FilePath: "pkg/a.go", Line: 20,
})
result := FindDeadCode(g, nil, nil)
for _, e := range result {
assert.NotEqual(t, "pkg/a.go::Config", e.ID,
"type referenced as a param type must not be reported as dead")
}
}