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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user