chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+686
View File
@@ -0,0 +1,686 @@
// Package callpath answers targeted A→B reachability questions over the
// CALLS-class call graph. It is the sibling of internal/dataflow (which walks
// the dataflow edges value_flow/arg_of/returns_to) and of query.Engine's
// single-source call BFS (get_call_chain): callpath traces a *targeted*
// shortest path from one symbol to another and, when no path exists, reports a
// structured why-unreachable diagnosis naming the dynamic-dispatch / external
// boundary where the chain dies.
//
// The engine uses balanced bidirectional BFS: it alternately expands the
// smaller of the two frontiers (one growing forward from the source over OUT
// edges, one growing backward from the sink over IN edges) so it touches
// O(b^(d/2)) nodes instead of O(b^d). BFS on the unweighted call graph yields a
// genuinely shortest path; the level-synchronised meeting test keeps that
// guarantee while collecting every equal-length route for the K-shortest case.
package callpath
import (
"fmt"
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// Bounds for the bidirectional search. These mirror the spirit of
// dataflow.DefaultMaxDepth/DefaultMaxPaths: the call-graph diameter is small,
// so the depth cap is a safety valve rather than a tuning knob.
const (
DefaultMaxDepth = 24
DefaultMaxNodes = 50000
DefaultMaxFrontier = 25
DefaultK = 1
// maxBoundaryHits caps the boundary-hit list so a hub source that calls
// hundreds of unresolved targets cannot bloat the gap report.
maxBoundaryHits = 64
)
// callEdgeKinds is the CALLS-class edge set the engine traces, mirroring
// get_callers/get_call_chain so the answer matches what agents already
// understand: EdgeCalls (direct invocation), EdgeMatches (cross-service
// producer/consumer bridge — lets a path cross repo/service boundaries) and
// EdgeReferences (method-value wiring: mux.HandleFunc, command tables, defer
// x.Cleanup — without it routing codebases look disconnected).
var callEdgeKinds = []graph.EdgeKind{graph.EdgeCalls, graph.EdgeMatches, graph.EdgeReferences}
// ReachReason classifies, for an unreachable pair, *why* the call graph fails
// to connect source→sink. It mirrors the structured-reason pattern of
// graph.ClassifyZeroEdge.
type ReachReason string
const (
ReasonSrcNotFound ReachReason = "src_not_found"
ReasonSinkNotFound ReachReason = "sink_not_found"
ReasonSrcNoOut ReachReason = "src_no_out_edges"
ReasonSinkNoIn ReachReason = "sink_no_in_edges"
ReasonDynamicDispatch ReachReason = "crosses_dynamic_dispatch"
ReasonExternalBoundary ReachReason = "crosses_external_boundary"
ReasonDepthExceeded ReachReason = "depth_exceeded"
ReasonDisconnected ReachReason = "disconnected"
)
// Options tunes a ShortestPath query. The zero value is valid: empty fields
// fall back to the package defaults / the full CALLS-class edge set.
type Options struct {
// EdgeKinds overrides the traced edge set. Empty uses callEdgeKinds.
EdgeKinds []graph.EdgeKind
// IncludeReferences, when false, drops EdgeReferences from the default
// edge set for a pure direct-call path. Ignored when EdgeKinds is set.
IncludeReferences bool
MaxDepth int
MaxNodes int
// K is the number of distinct shortest-length paths to return (default 1).
K int
MaxFrontier int
// MinTier prunes edges whose Origin tier is below the threshold during
// traversal (same semantics as flow_between's min_tier).
MinTier string
// WorkspaceID, when set, confines traversal to nodes in the same
// workspace, so cross-workspace noise does not leak into the gap report.
WorkspaceID string
}
// PathEdge is one hop on a returned path, carrying provenance so the agent
// sees whether the hop was compiler-verified (lsp), tree-sitter resolved (ast)
// or heuristic.
type PathEdge struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
Origin string `json:"origin,omitempty"`
Tier string `json:"tier,omitempty"`
}
// Path is one source→sink route. Length is the hop count (edges); a 0-length
// path means source == sink.
type Path struct {
Nodes []string `json:"nodes"`
Edges []PathEdge `json:"edges,omitempty"`
Length int `json:"length"`
Confidence float64 `json:"confidence"`
WorstTier string `json:"worst_tier,omitempty"`
}
// FrontierNode is one node on a search frontier, tagged with its BFS depth.
type FrontierNode struct {
ID string `json:"id"`
Depth int `json:"depth"`
}
// BoundaryHit records a neighbour the forward search refused to traverse
// because it was an unresolved/dynamic-dispatch target or an external/stub
// boundary — precisely the sites that make the call graph un-connectable.
type BoundaryHit struct {
From string `json:"from"`
Target string `json:"target"`
Reason string `json:"reason"`
EdgeKind string `json:"edge_kind"`
}
// Gap is the why-unreachable diagnosis returned when no path exists.
type Gap struct {
Reason ReachReason `json:"reason"`
Message string `json:"message"`
FurthestFromSource []FrontierNode `json:"furthest_from_source,omitempty"`
NearestToSink []FrontierNode `json:"nearest_to_sink,omitempty"`
BoundaryHits []BoundaryHit `json:"boundary_hits,omitempty"`
ForwardReached int `json:"forward_reached"`
BackwardReached int `json:"backward_reached"`
}
// Result is the engine's answer. Exactly one of Paths / Gap is populated.
type Result struct {
Found bool `json:"found"`
SrcID string `json:"source_id"`
SinkID string `json:"sink_id"`
Paths []Path `json:"paths,omitempty"`
Gap *Gap `json:"gap,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// Engine is the call-path query backend. It holds a reference to the graph and
// is concurrency-safe by relying only on graph.Store's read methods.
type Engine struct {
g graph.Store
}
// New returns an engine backed by the given graph.
func New(g graph.Store) *Engine { return &Engine{g: g} }
// ShortestPath returns the shortest A→B path over the CALLS-class call graph
// (and up to opts.K equal-length alternates), or a structured Gap diagnosing
// why none exists.
func (e *Engine) ShortestPath(src, sink string, opts Options) Result {
res := Result{SrcID: src, SinkID: sink}
if e == nil || e.g == nil || src == "" {
res.Gap = &Gap{Reason: ReasonSrcNotFound, Message: "source id is empty"}
return res
}
if sink == "" {
res.Gap = &Gap{Reason: ReasonSinkNotFound, Message: "sink id is empty"}
return res
}
maxDepth := orDefault(opts.MaxDepth, DefaultMaxDepth)
maxNodes := orDefault(opts.MaxNodes, DefaultMaxNodes)
k := orDefault(opts.K, DefaultK)
maxFrontier := orDefault(opts.MaxFrontier, DefaultMaxFrontier)
kindSet := e.kindSet(opts)
if e.g.GetNode(src) == nil {
res.Gap = &Gap{Reason: ReasonSrcNotFound, Message: fmt.Sprintf("%s is not in the graph", src)}
return res
}
if e.g.GetNode(sink) == nil {
res.Gap = &Gap{Reason: ReasonSinkNotFound, Message: fmt.Sprintf("%s is not in the graph", sink)}
return res
}
if src == sink {
res.Found = true
res.Paths = []Path{{Nodes: []string{src}, Length: 0, Confidence: 1}}
return res
}
// Pre-checks that make a path impossible regardless of search effort.
if !e.hasEdge(src, true, kindSet) {
res.Gap = e.simpleGap(ReasonSrcNoOut,
fmt.Sprintf("%s makes no calls — it is a leaf of the call graph, so nothing is reachable from it", src))
return res
}
if !e.hasEdge(sink, false, kindSet) {
res.Gap = e.simpleGap(ReasonSinkNoIn,
fmt.Sprintf("nothing calls %s — it has no incoming call edges, so it is unreachable from any source", sink))
return res
}
st := &search{
eng: e,
opts: opts,
kindSet: kindSet,
maxNodes: maxNodes,
fwdParent: map[string]*graph.Edge{src: nil},
bwdChild: map[string]*graph.Edge{sink: nil},
fwdDepth: map[string]int{src: 0},
bwdDepth: map[string]int{sink: 0},
boundarySee: map[string]bool{},
}
st.fwdFrontier = []string{src}
st.bwdFrontier = []string{sink}
st.lastFwd = st.fwdFrontier
st.lastBwd = st.bwdFrontier
fLevel, bLevel := 0, 0
depthExceeded := false
for len(st.fwdFrontier) > 0 && len(st.bwdFrontier) > 0 {
if fLevel+bLevel >= maxDepth {
depthExceeded = true
break
}
if len(st.fwdParent)+len(st.bwdChild) >= maxNodes {
st.truncated = true
break
}
var meetings []string
if len(st.fwdFrontier) <= len(st.bwdFrontier) {
st.fwdFrontier, meetings = st.expandForward(fLevel)
fLevel++
if len(st.fwdFrontier) > 0 {
st.lastFwd = st.fwdFrontier
}
} else {
st.bwdFrontier, meetings = st.expandBackward(bLevel)
bLevel++
if len(st.bwdFrontier) > 0 {
st.lastBwd = st.bwdFrontier
}
}
if len(meetings) > 0 {
paths := st.buildPaths(src, meetings, k)
if len(paths) > 0 {
res.Found = true
res.Paths = paths
res.Truncated = st.truncated
return res
}
}
}
// No meeting: the pair is unreachable. The balanced search stops as soon
// as either frontier empties, which can leave the *other* side barely
// explored — so the gap report would carry a one-sided picture. Complete
// both reach cones (bounded by depth/node caps) purely to populate a
// symmetric "A reaches … / … reaches B" diagnosis. This cannot surface a
// missed path: forward and backward apply identical boundary/tier/scope
// pruning, so any node reachable both ways would already have met.
if !depthExceeded && !st.truncated {
for len(st.fwdFrontier) > 0 && fLevel < maxDepth && len(st.fwdParent)+len(st.bwdChild) < maxNodes {
st.fwdFrontier, _ = st.expandForward(fLevel)
fLevel++
if len(st.fwdFrontier) > 0 {
st.lastFwd = st.fwdFrontier
}
}
for len(st.bwdFrontier) > 0 && bLevel < maxDepth && len(st.fwdParent)+len(st.bwdChild) < maxNodes {
st.bwdFrontier, _ = st.expandBackward(bLevel)
bLevel++
if len(st.bwdFrontier) > 0 {
st.lastBwd = st.bwdFrontier
}
}
}
res.Gap = st.buildGap(maxFrontier, depthExceeded)
res.Truncated = st.truncated
return res
}
// search holds the mutable state of one bidirectional sweep.
// AnchoredPath pairs a pack root with its shortest call-path to the anchor.
type AnchoredPath struct {
Root string `json:"root"`
Path Path `json:"path"`
}
// PathsToAnchor returns, for each root that can reach the anchor over the
// CALLS-class graph, the shortest root→anchor path. It is the multi-root entry
// the in-pack call-paths section uses: the anchor is the focus symbol and the
// roots are the pack's other seed symbols, so the result shows how each seed
// reaches the focus. Roots that cannot reach the anchor (and the anchor itself)
// are omitted, duplicate roots are de-duped, and results are ordered shortest
// path first so the tightest connections lead.
func (e *Engine) PathsToAnchor(roots []string, anchor string, opts Options) []AnchoredPath {
if e == nil || e.g == nil || anchor == "" {
return nil
}
seen := map[string]bool{}
var out []AnchoredPath
for _, root := range roots {
if root == "" || root == anchor || seen[root] {
continue
}
seen[root] = true
res := e.ShortestPath(root, anchor, opts)
if !res.Found || len(res.Paths) == 0 {
continue
}
out = append(out, AnchoredPath{Root: root, Path: res.Paths[0]})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Path.Length != out[j].Path.Length {
return out[i].Path.Length < out[j].Path.Length
}
return out[i].Root < out[j].Root
})
return out
}
type search struct {
eng *Engine
opts Options
kindSet map[graph.EdgeKind]bool
maxNodes int
fwdParent map[string]*graph.Edge // node → edge that discovered it forward (edge.To == node)
bwdChild map[string]*graph.Edge // node → edge toward the sink (edge.From == node)
fwdDepth map[string]int
bwdDepth map[string]int
fwdFrontier []string
bwdFrontier []string
lastFwd []string
lastBwd []string
boundary []BoundaryHit
boundarySee map[string]bool
truncated bool
}
func (s *search) expandForward(level int) (next []string, meetings []string) {
for _, u := range s.fwdFrontier {
for _, ed := range s.eng.g.GetOutEdges(u) {
if !s.kindSet[ed.Kind] {
continue
}
v := ed.To
if _, seen := s.fwdParent[v]; seen {
continue
}
if reason, isB := classifyBoundary(v); isB {
s.addBoundary(u, v, reason, string(ed.Kind))
continue
}
if !s.tierOK(ed) || !s.eng.scopeOK(v, s.opts) {
continue
}
s.fwdParent[v] = ed
s.fwdDepth[v] = level + 1
next = append(next, v)
if _, ok := s.bwdChild[v]; ok {
meetings = append(meetings, v)
}
}
}
return next, meetings
}
func (s *search) expandBackward(level int) (next []string, meetings []string) {
for _, u := range s.bwdFrontier {
for _, ed := range s.eng.g.GetInEdges(u) {
if !s.kindSet[ed.Kind] {
continue
}
w := ed.From
if _, seen := s.bwdChild[w]; seen {
continue
}
// A boundary node is never a real caller; skip without recording
// (boundary hits are a forward-search concept — they name the
// dynamic-dispatch sites the source's reach terminates at).
if _, isB := classifyBoundary(w); isB {
continue
}
if !s.tierOK(ed) || !s.eng.scopeOK(w, s.opts) {
continue
}
s.bwdChild[w] = ed
s.bwdDepth[w] = level + 1
next = append(next, w)
if _, ok := s.fwdParent[w]; ok {
meetings = append(meetings, w)
}
}
}
return next, meetings
}
func (s *search) tierOK(ed *graph.Edge) bool {
if s.opts.MinTier == "" {
return true
}
return graph.MeetsMinTier(edgeOriginOf(ed), s.opts.MinTier)
}
func (s *search) addBoundary(from, target, reason, edgeKind string) {
if s.boundarySee[target] || len(s.boundary) >= maxBoundaryHits {
return
}
s.boundarySee[target] = true
s.boundary = append(s.boundary, BoundaryHit{From: from, Target: target, Reason: reason, EdgeKind: edgeKind})
}
// buildPaths reconstructs distinct shortest-length paths through the supplied
// meeting nodes. Among the meetings discovered in the meeting level it keeps
// only those whose total length equals the minimum, then dedupes by node
// sequence, ranks by (length asc, confidence desc) and returns up to k.
func (s *search) buildPaths(src string, meetings []string, k int) []Path {
type cand struct {
total int
node string
}
cands := make([]cand, 0, len(meetings))
minTotal := 1 << 30
for _, m := range meetings {
t := s.fwdDepth[m] + s.bwdDepth[m]
cands = append(cands, cand{total: t, node: m})
if t < minTotal {
minTotal = t
}
}
seen := map[string]bool{}
var paths []Path
for _, c := range cands {
if c.total != minTotal {
continue
}
p := s.reconstruct(src, c.node)
key := strings.Join(p.Nodes, ">")
if seen[key] {
continue
}
seen[key] = true
paths = append(paths, p)
}
sort.SliceStable(paths, func(i, j int) bool {
if paths[i].Length != paths[j].Length {
return paths[i].Length < paths[j].Length
}
return paths[i].Confidence > paths[j].Confidence
})
if k > 0 && len(paths) > k {
paths = paths[:k]
}
return paths
}
// reconstruct splices the forward chain (src→meet) and the backward chain
// (meet→sink) into one path.
func (s *search) reconstruct(src, meet string) Path {
var fwd []*graph.Edge
for cur := meet; ; {
ed := s.fwdParent[cur]
if ed == nil {
break
}
fwd = append(fwd, ed)
cur = ed.From
}
// fwd is meet→…→src; reverse to src→…→meet.
for i, j := 0, len(fwd)-1; i < j; i, j = i+1, j-1 {
fwd[i], fwd[j] = fwd[j], fwd[i]
}
var bwd []*graph.Edge
for cur := meet; ; {
ed := s.bwdChild[cur]
if ed == nil {
break
}
bwd = append(bwd, ed)
cur = ed.To
}
all := append(fwd, bwd...)
nodes := make([]string, 0, len(all)+1)
nodes = append(nodes, src)
edges := make([]PathEdge, 0, len(all))
score := 1.0
worst := ""
for _, ed := range all {
nodes = append(nodes, ed.To)
origin := edgeOriginOf(ed)
tier := graph.ResolvedBy(origin)
edges = append(edges, PathEdge{From: ed.From, To: ed.To, Kind: string(ed.Kind), Origin: origin, Tier: tier})
score *= graph.EdgeTierScore(origin, ed.Kind)
worst = mergeWorstTier(worst, tier)
}
return Path{Nodes: nodes, Edges: edges, Length: len(all), Confidence: score, WorstTier: worst}
}
func (s *search) buildGap(maxFrontier int, depthExceeded bool) *Gap {
g := &Gap{
FurthestFromSource: s.frontierNodes(s.lastFwd, s.fwdDepth, maxFrontier),
NearestToSink: s.frontierNodes(s.lastBwd, s.bwdDepth, maxFrontier),
BoundaryHits: s.boundary,
ForwardReached: len(s.fwdParent) - 1,
BackwardReached: len(s.bwdChild) - 1,
}
hasDyn, hasExt := false, false
var dynNames []string
for _, b := range s.boundary {
if b.Reason == boundaryDynamicDispatch {
hasDyn = true
if len(dynNames) < 3 {
dynNames = append(dynNames, graph.UnresolvedName(b.Target))
}
} else {
hasExt = true
}
}
switch {
case depthExceeded:
g.Reason = ReasonDepthExceeded
g.Message = fmt.Sprintf("the search hit the depth bound before the forward reach (%d) and backward reach (%d) met — raise --depth or narrow the endpoints",
g.ForwardReached, g.BackwardReached)
case hasDyn:
g.Reason = ReasonDynamicDispatch
g.Message = fmt.Sprintf("%s reaches %d functions and %s is reachable from %d, but the forward reach terminates at %d dynamic-dispatch call site(s) (e.g. %s) that the resolver never bound — try find_implementations on the interface, or rerun with a lower min_tier",
s.srcLabel(), g.ForwardReached, s.sinkLabel(), g.BackwardReached, len(dynNames), strings.Join(dynNames, ", "))
case hasExt:
g.Reason = ReasonExternalBoundary
g.Message = fmt.Sprintf("%s reaches %d functions and %s is reachable from %d, but the chain leaves the indexed code at an external/stdlib boundary — the call graph cannot connect them through resolved code",
s.srcLabel(), g.ForwardReached, s.sinkLabel(), g.BackwardReached)
default:
g.Reason = ReasonDisconnected
g.Message = fmt.Sprintf("%s reaches %d functions and %s is reachable from %d, but the two reachable sets are disjoint — no call path connects them",
s.srcLabel(), g.ForwardReached, s.sinkLabel(), g.BackwardReached)
}
return g
}
func (s *search) srcLabel() string { return shortLabel(s.fwdRoot()) }
func (s *search) sinkLabel() string { return shortLabel(s.bwdRoot()) }
func (s *search) fwdRoot() string {
for id, e := range s.fwdParent {
if e == nil {
return id
}
}
return ""
}
func (s *search) bwdRoot() string {
for id, e := range s.bwdChild {
if e == nil {
return id
}
}
return ""
}
func (s *search) frontierNodes(ids []string, depth map[string]int, max int) []FrontierNode {
if len(ids) == 0 {
return nil
}
out := make([]FrontierNode, 0, len(ids))
for _, id := range ids {
out = append(out, FrontierNode{ID: id, Depth: depth[id]})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if max > 0 && len(out) > max {
out = out[:max]
}
return out
}
// simpleGap builds a one-line gap for the trivial impossible cases.
func (e *Engine) simpleGap(reason ReachReason, msg string) *Gap {
return &Gap{Reason: reason, Message: msg}
}
func (e *Engine) kindSet(opts Options) map[graph.EdgeKind]bool {
kinds := opts.EdgeKinds
if len(kinds) == 0 {
// The default edge set mirrors get_callers (calls + matches +
// references). Callers wanting a pure direct-call path pass
// IncludeReferences=false, which drops the method-value wiring edges.
kinds = callEdgeKinds
if !opts.IncludeReferences {
kinds = []graph.EdgeKind{graph.EdgeCalls, graph.EdgeMatches}
}
}
set := make(map[graph.EdgeKind]bool, len(kinds))
for _, k := range kinds {
set[k] = true
}
return set
}
// hasEdge reports whether id has at least one edge of a traced kind in the
// given direction (forward = out edges).
func (e *Engine) hasEdge(id string, forward bool, kindSet map[graph.EdgeKind]bool) bool {
var edges []*graph.Edge
if forward {
edges = e.g.GetOutEdges(id)
} else {
edges = e.g.GetInEdges(id)
}
for _, ed := range edges {
if kindSet[ed.Kind] {
return true
}
}
return false
}
func (e *Engine) scopeOK(id string, opts Options) bool {
if opts.WorkspaceID == "" {
return true
}
n := e.g.GetNode(id)
if n == nil || n.WorkspaceID == "" {
return true
}
return n.WorkspaceID == opts.WorkspaceID
}
// boundary reason labels. dynamic_dispatch is special-cased in the gap
// classifier; the rest come straight from graph.StubKind.
const boundaryDynamicDispatch = "dynamic_dispatch"
// classifyBoundary maps a neighbour id to a boundary reason, or returns
// isBoundary=false for an ordinary in-graph node.
func classifyBoundary(id string) (reason string, isBoundary bool) {
if graph.IsUnresolvedTarget(id) {
return boundaryDynamicDispatch, true
}
if strings.HasPrefix(id, "external::") {
return "external_namespace", true
}
if k := graph.StubKind(id); k != "" {
return k, true
}
return "", false
}
// edgeOriginOf returns the stamped Origin, falling back to DefaultOriginFor so
// back-compat graphs classify cleanly (identical to dataflow.edgeOrigin).
func edgeOriginOf(e *graph.Edge) string {
if e.Origin != "" {
return e.Origin
}
src, _ := e.Meta["semantic_source"].(string)
return graph.DefaultOriginFor(e.Kind, e.Confidence, src)
}
// tierRank orders the coarse tier labels worst→best for mergeWorstTier.
var tierRank = map[string]int{"heuristic": 0, "ast": 1, "lsp": 2}
// mergeWorstTier returns the weaker of two coarse tier labels.
func mergeWorstTier(a, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
if tierRank[b] < tierRank[a] {
return b
}
return a
}
func shortLabel(id string) string {
if i := strings.LastIndex(id, "::"); i >= 0 {
return id[i+2:]
}
return id
}
func orDefault(v, def int) int {
if v <= 0 {
return def
}
return v
}
+324
View File
@@ -0,0 +1,324 @@
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)
}
}