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
1055 lines
35 KiB
Go
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")
|
|
}
|
|
}
|