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
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package callpath
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func newGraph(ids ...string) *graph.Graph {
|
|
g := graph.New()
|
|
for _, id := range ids {
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: "test.go"})
|
|
}
|
|
return g
|
|
}
|
|
|
|
func calls(g *graph.Graph, from, to string) {
|
|
g.AddEdge(&graph.Edge{From: from, To: to, Kind: graph.EdgeCalls, FilePath: "test.go", Origin: graph.OriginASTResolved})
|
|
}
|
|
|
|
func callsTier(g *graph.Graph, from, to, origin string) {
|
|
g.AddEdge(&graph.Edge{From: from, To: to, Kind: graph.EdgeCalls, FilePath: "test.go", Origin: origin})
|
|
}
|
|
|
|
func TestShortestPath_Direct(t *testing.T) {
|
|
g := newGraph("A", "B")
|
|
calls(g, "A", "B")
|
|
res := New(g).ShortestPath("A", "B", Options{})
|
|
if !res.Found || len(res.Paths) != 1 {
|
|
t.Fatalf("expected 1 path, got found=%v paths=%d gap=%+v", res.Found, len(res.Paths), res.Gap)
|
|
}
|
|
p := res.Paths[0]
|
|
if p.Length != 1 || len(p.Nodes) != 2 || p.Nodes[0] != "A" || p.Nodes[1] != "B" {
|
|
t.Errorf("unexpected path: %+v", p)
|
|
}
|
|
if p.Confidence <= 0 || p.Confidence > 1 {
|
|
t.Errorf("confidence out of range: %v", p.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_MultiHop(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D")
|
|
calls(g, "A", "B")
|
|
calls(g, "B", "C")
|
|
calls(g, "C", "D")
|
|
res := New(g).ShortestPath("A", "D", Options{})
|
|
if !res.Found || len(res.Paths) != 1 {
|
|
t.Fatalf("expected found, got %+v", res)
|
|
}
|
|
got := res.Paths[0].Nodes
|
|
want := []string{"A", "B", "C", "D"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("unexpected nodes: %v", got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("node %d = %q, want %q (path %v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestShortestPath_BidirectionalMeet asserts the engine returns the SHORTEST of
|
|
// two routes (the length-2 diamond top, not a longer detour).
|
|
func TestShortestPath_BidirectionalMeet(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D", "X", "Y", "Z")
|
|
// Short route: A->B->D
|
|
calls(g, "A", "B")
|
|
calls(g, "B", "D")
|
|
// Long route: A->C->X->Y->Z->D
|
|
calls(g, "A", "C")
|
|
calls(g, "C", "X")
|
|
calls(g, "X", "Y")
|
|
calls(g, "Y", "Z")
|
|
calls(g, "Z", "D")
|
|
res := New(g).ShortestPath("A", "D", Options{})
|
|
if !res.Found {
|
|
t.Fatalf("expected found, got %+v", res.Gap)
|
|
}
|
|
if res.Paths[0].Length != 2 {
|
|
t.Errorf("expected shortest length 2, got %d (%v)", res.Paths[0].Length, res.Paths[0].Nodes)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_Disconnected(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D")
|
|
calls(g, "A", "B")
|
|
calls(g, "C", "D")
|
|
res := New(g).ShortestPath("A", "D", Options{})
|
|
if res.Found {
|
|
t.Fatalf("expected no path, got %+v", res.Paths)
|
|
}
|
|
if res.Gap == nil || res.Gap.Reason != ReasonDisconnected {
|
|
t.Fatalf("expected disconnected, got %+v", res.Gap)
|
|
}
|
|
if res.Gap.ForwardReached != 1 || res.Gap.BackwardReached != 1 {
|
|
t.Errorf("expected 1 reached each side, got fwd=%d bwd=%d", res.Gap.ForwardReached, res.Gap.BackwardReached)
|
|
}
|
|
if len(res.Gap.FurthestFromSource) == 0 || len(res.Gap.NearestToSink) == 0 {
|
|
t.Errorf("expected frontier reports, got %+v", res.Gap)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_DynamicDispatchBoundary(t *testing.T) {
|
|
g := newGraph("A", "B", "sink")
|
|
calls(g, "A", "B")
|
|
// B's only onward call is to an unresolved (dynamic-dispatch) target.
|
|
g.AddNode(&graph.Node{ID: "unresolved::handler", Kind: graph.KindFunction, Name: "handler"})
|
|
calls(g, "B", "unresolved::handler")
|
|
// sink has an incoming edge from an unrelated, unreachable node.
|
|
g.AddNode(&graph.Node{ID: "Z", Kind: graph.KindFunction, Name: "Z"})
|
|
calls(g, "Z", "sink")
|
|
res := New(g).ShortestPath("A", "sink", Options{})
|
|
if res.Found {
|
|
t.Fatalf("expected no path, got %+v", res.Paths)
|
|
}
|
|
if res.Gap.Reason != ReasonDynamicDispatch {
|
|
t.Fatalf("expected dynamic_dispatch, got %s", res.Gap.Reason)
|
|
}
|
|
found := false
|
|
for _, b := range res.Gap.BoundaryHits {
|
|
if b.Target == "unresolved::handler" && b.Reason == boundaryDynamicDispatch {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected boundary hit for unresolved::handler, got %+v", res.Gap.BoundaryHits)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_StubBoundary(t *testing.T) {
|
|
g := newGraph("A", "B", "sink")
|
|
calls(g, "A", "B")
|
|
g.AddNode(&graph.Node{ID: "stdlib::fmt.Println", Kind: graph.KindFunction})
|
|
calls(g, "B", "stdlib::fmt.Println")
|
|
g.AddNode(&graph.Node{ID: "Z", Kind: graph.KindFunction})
|
|
calls(g, "Z", "sink")
|
|
res := New(g).ShortestPath("A", "sink", Options{})
|
|
if res.Found {
|
|
t.Fatalf("expected no path")
|
|
}
|
|
if res.Gap.Reason != ReasonExternalBoundary {
|
|
t.Fatalf("expected external boundary, got %s", res.Gap.Reason)
|
|
}
|
|
if len(res.Gap.BoundaryHits) == 0 || res.Gap.BoundaryHits[0].Reason != "stdlib" {
|
|
t.Errorf("expected stdlib boundary hit, got %+v", res.Gap.BoundaryHits)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_DepthExceeded(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D", "E")
|
|
calls(g, "A", "B")
|
|
calls(g, "B", "C")
|
|
calls(g, "C", "D")
|
|
calls(g, "D", "E")
|
|
res := New(g).ShortestPath("A", "E", Options{MaxDepth: 2})
|
|
if res.Found {
|
|
t.Fatalf("expected no path within depth 2, got %+v", res.Paths)
|
|
}
|
|
if res.Gap.Reason != ReasonDepthExceeded {
|
|
t.Fatalf("expected depth_exceeded, got %s", res.Gap.Reason)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_NotFound(t *testing.T) {
|
|
g := newGraph("A", "B")
|
|
calls(g, "A", "B")
|
|
if res := New(g).ShortestPath("MISSING", "B", Options{}); res.Found || res.Gap.Reason != ReasonSrcNotFound {
|
|
t.Errorf("expected src_not_found, got %+v", res)
|
|
}
|
|
if res := New(g).ShortestPath("A", "MISSING", Options{}); res.Found || res.Gap.Reason != ReasonSinkNotFound {
|
|
t.Errorf("expected sink_not_found, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_SrcNoOutSinkNoIn(t *testing.T) {
|
|
g := newGraph("A", "B", "C")
|
|
calls(g, "B", "C")
|
|
// A has no out edges.
|
|
if res := New(g).ShortestPath("A", "C", Options{}); res.Found || res.Gap.Reason != ReasonSrcNoOut {
|
|
t.Errorf("expected src_no_out, got %+v", res.Gap)
|
|
}
|
|
// A has out edge to B but B is the target with no incoming... use a fresh
|
|
// graph where the sink genuinely has no in-edges.
|
|
g2 := newGraph("A", "B", "C")
|
|
calls(g2, "A", "B")
|
|
if res := New(g2).ShortestPath("A", "C", Options{}); res.Found || res.Gap.Reason != ReasonSinkNoIn {
|
|
t.Errorf("expected sink_no_in, got %+v", res.Gap)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_SameNode(t *testing.T) {
|
|
g := newGraph("A")
|
|
res := New(g).ShortestPath("A", "A", Options{})
|
|
if !res.Found || len(res.Paths) != 1 || res.Paths[0].Length != 0 {
|
|
t.Errorf("expected trivial 0-length path, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_KShortest(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D")
|
|
// Two equal-length routes A->B->D and A->C->D.
|
|
calls(g, "A", "B")
|
|
calls(g, "B", "D")
|
|
calls(g, "A", "C")
|
|
calls(g, "C", "D")
|
|
res := New(g).ShortestPath("A", "D", Options{K: 2})
|
|
if !res.Found {
|
|
t.Fatalf("expected found, got %+v", res.Gap)
|
|
}
|
|
if len(res.Paths) != 2 {
|
|
t.Fatalf("expected 2 paths with K=2, got %d: %+v", len(res.Paths), res.Paths)
|
|
}
|
|
for _, p := range res.Paths {
|
|
if p.Length != 2 {
|
|
t.Errorf("expected length-2 path, got %d", p.Length)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_KShortest_RankedByConfidence(t *testing.T) {
|
|
g := newGraph("A", "B", "C", "D")
|
|
// A->B->D is all ast_resolved; A->C->D goes through a text_matched edge.
|
|
callsTier(g, "A", "B", graph.OriginASTResolved)
|
|
callsTier(g, "B", "D", graph.OriginASTResolved)
|
|
callsTier(g, "A", "C", graph.OriginTextMatched)
|
|
callsTier(g, "C", "D", graph.OriginASTResolved)
|
|
res := New(g).ShortestPath("A", "D", Options{K: 2})
|
|
if len(res.Paths) != 2 {
|
|
t.Fatalf("expected 2 paths, got %d", len(res.Paths))
|
|
}
|
|
if res.Paths[0].Nodes[1] != "B" {
|
|
t.Errorf("expected higher-confidence path (via B) first, got %v", res.Paths[0].Nodes)
|
|
}
|
|
if res.Paths[0].Confidence <= res.Paths[1].Confidence {
|
|
t.Errorf("expected paths ranked by confidence desc, got %v then %v",
|
|
res.Paths[0].Confidence, res.Paths[1].Confidence)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_MinTierPrune(t *testing.T) {
|
|
g := newGraph("A", "B", "C")
|
|
// Only route A->B->C exists, and B->C is text_matched.
|
|
callsTier(g, "A", "B", graph.OriginASTResolved)
|
|
callsTier(g, "B", "C", graph.OriginTextMatched)
|
|
// Without min_tier the path is found.
|
|
if res := New(g).ShortestPath("A", "C", Options{}); !res.Found {
|
|
t.Fatalf("expected found without min_tier, got %+v", res.Gap)
|
|
}
|
|
// With min_tier=ast_resolved the weak edge is pruned and no path remains.
|
|
res := New(g).ShortestPath("A", "C", Options{MinTier: graph.OriginASTResolved})
|
|
if res.Found {
|
|
t.Errorf("expected min_tier prune to drop the path, got %+v", res.Paths)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_EdgeMatchesCrossService(t *testing.T) {
|
|
g := newGraph("consumer", "providerContract", "handler")
|
|
g.AddNode(&graph.Node{ID: "consumerContract", Kind: graph.KindContract})
|
|
// consumer -> calls -> consumerContract -> matches -> providerContract -> calls -> handler
|
|
calls(g, "consumer", "consumerContract")
|
|
g.AddEdge(&graph.Edge{From: "consumerContract", To: "providerContract", Kind: graph.EdgeMatches, Origin: graph.OriginASTResolved})
|
|
calls(g, "providerContract", "handler")
|
|
res := New(g).ShortestPath("consumer", "handler", Options{})
|
|
if !res.Found {
|
|
t.Fatalf("expected cross-service path via EdgeMatches, got %+v", res.Gap)
|
|
}
|
|
if res.Paths[0].Length != 3 {
|
|
t.Errorf("expected length 3, got %d (%v)", res.Paths[0].Length, res.Paths[0].Nodes)
|
|
}
|
|
}
|
|
|
|
func TestShortestPath_IncludeReferencesFalse(t *testing.T) {
|
|
g := newGraph("A", "B", "C")
|
|
calls(g, "A", "B")
|
|
// Only wiring from B to C is an EdgeReferences (method-value registration).
|
|
g.AddEdge(&graph.Edge{From: "B", To: "C", Kind: graph.EdgeReferences, Origin: graph.OriginASTResolved})
|
|
// Default (references included) finds it.
|
|
if res := New(g).ShortestPath("A", "C", Options{IncludeReferences: true}); !res.Found {
|
|
t.Fatalf("expected path with references, got %+v", res.Gap)
|
|
}
|
|
// references excluded drops the wiring hop.
|
|
if res := New(g).ShortestPath("A", "C", Options{IncludeReferences: false}); res.Found {
|
|
t.Errorf("expected no pure-call path, got %+v", res.Paths)
|
|
}
|
|
}
|
|
|
|
func TestPathsToAnchor(t *testing.T) {
|
|
// c→anchor (len 1); a→b→anchor (len 2); d has no route to anchor.
|
|
g := newGraph("a", "b", "c", "d", "anchor")
|
|
calls(g, "a", "b")
|
|
calls(g, "b", "anchor")
|
|
calls(g, "c", "anchor")
|
|
|
|
got := New(g).PathsToAnchor([]string{"a", "c", "d", "anchor", "a"}, "anchor", Options{})
|
|
|
|
// d (no path) and anchor (== anchor) and the duplicate a are dropped.
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 anchored paths, got %d: %+v", len(got), got)
|
|
}
|
|
// Ordered shortest first: c (len 1) before a (len 2).
|
|
if got[0].Root != "c" || got[0].Path.Length != 1 {
|
|
t.Errorf("first should be c (len 1), got %+v", got[0])
|
|
}
|
|
if got[1].Root != "a" || got[1].Path.Length != 2 {
|
|
t.Errorf("second should be a (len 2), got %+v", got[1])
|
|
}
|
|
want := []string{"a", "b", "anchor"}
|
|
if len(got[1].Path.Nodes) != 3 {
|
|
t.Fatalf("a path nodes = %v", got[1].Path.Nodes)
|
|
}
|
|
for i := range want {
|
|
if got[1].Path.Nodes[i] != want[i] {
|
|
t.Errorf("a path = %v, want %v", got[1].Path.Nodes, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPathsToAnchor_EmptyAnchor(t *testing.T) {
|
|
g := newGraph("a", "anchor")
|
|
calls(g, "a", "anchor")
|
|
if got := New(g).PathsToAnchor([]string{"a"}, "", Options{}); got != nil {
|
|
t.Errorf("empty anchor should return nil, got %+v", got)
|
|
}
|
|
}
|