Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

687 lines
21 KiB
Go

// 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
}