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

1679 lines
57 KiB
Go

package query
import (
"sort"
"strconv"
"strings"
"time"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
"github.com/zzet/gortex/internal/search/rerank"
)
// SearchProvider is a function that returns the current search backend.
// This allows the engine to always use the latest backend even when the
// indexer replaces it (e.g., wrapping BM25 in HybridBackend for embeddings).
type SearchProvider func() search.Backend
// Engine provides higher-level query operations over the graph.
//
// The graph is held as a `graph.Reader` rather than a concrete
// `*graph.Graph` so the same engine instance can serve both base-
// graph queries and overlay-aware queries (an `*graph.OverlaidView`
// also implements `graph.Reader`). `WithReader` returns a shallow
// clone that swaps the reader; the MCP overlay middleware uses it
// to scope a tool call to the calling session's shadow view without
// constructing a fresh Engine per request.
type Engine struct {
g graph.Reader
searchProvider SearchProvider
rerank *rerank.Pipeline
}
// WithReader returns a shallow clone of the engine that reads
// through r instead of the original graph. The search provider and
// rerank pipeline are shared with the source engine. Pass the
// base graph reader to undo a previous swap.
func (e *Engine) WithReader(r graph.Reader) *Engine {
if e == nil {
return nil
}
clone := *e
clone.g = r
return &clone
}
// Reader returns the engine's currently-bound graph reader. Tool
// handlers that need to walk the same view the engine sees use this
// to keep their direct-graph reads consistent with the engine's
// internal walks.
func (e *Engine) Reader() graph.Reader { return e.g }
// NewEngine creates a query engine wrapping the given graph. The
// default 11-signal rerank.Pipeline is wired in; callers wanting a
// custom signal set / weights override via SetRerank.
func NewEngine(g graph.Store) *Engine {
return &Engine{g: g, rerank: rerank.NewDefault()}
}
// SetRerank installs a custom rerank pipeline. Pass nil to disable
// the 11-signal pass and fall back to the BM25-rank-only ordering.
func (e *Engine) SetRerank(p *rerank.Pipeline) { e.rerank = p }
// Rerank returns the installed pipeline. May be nil.
func (e *Engine) Rerank() *rerank.Pipeline { return e.rerank }
// ApplyRerankWeights overlays a per-signal weight map (typically
// loaded from `.gortex.yaml::search::weights`) onto the engine's
// rerank pipeline. Keys not present in the map keep their default
// weight; setting a key to 0 disables that signal. No-op when the
// engine has no pipeline or the map is empty.
func (e *Engine) ApplyRerankWeights(weights map[string]float64) {
if e.rerank == nil || len(weights) == 0 {
return
}
for name, w := range weights {
e.rerank.SetWeight(name, w)
}
}
// SetSearch sets a static search backend (for backward compatibility).
func (e *Engine) SetSearch(s search.Backend) {
e.searchProvider = func() search.Backend { return s }
}
// SetSearchProvider sets a dynamic search provider that is called on every query.
func (e *Engine) SetSearchProvider(p SearchProvider) {
e.searchProvider = p
}
// getSearch returns the current search backend.
func (e *Engine) getSearch() search.Backend {
if e.searchProvider == nil {
return nil
}
return e.searchProvider()
}
// GetSymbol returns a node by ID.
func (e *Engine) GetSymbol(id string) *graph.Node {
return e.g.GetNode(id)
}
// GetOutEdges returns outgoing edges for a node.
func (e *Engine) GetOutEdges(nodeID string) []*graph.Edge {
return e.g.GetOutEdges(nodeID)
}
// GetInEdges returns incoming edges for a node.
func (e *Engine) GetInEdges(nodeID string) []*graph.Edge {
return e.g.GetInEdges(nodeID)
}
// FindSymbols returns nodes matching the name, optionally filtered by kind.
func (e *Engine) FindSymbols(name string, kinds ...graph.NodeKind) []*graph.Node {
candidates := e.g.FindNodesByName(name)
if len(kinds) == 0 {
return candidates
}
kindSet := make(map[graph.NodeKind]bool, len(kinds))
for _, k := range kinds {
kindSet[k] = true
}
var filtered []*graph.Node
for _, n := range candidates {
if kindSet[n.Kind] {
filtered = append(filtered, n)
}
}
return filtered
}
// GetFileSymbolsCounts returns the file's symbols and the count of
// edges adjacent to them, without materialising the edges themselves.
// Use it instead of GetFileSymbols when the caller only needs an
// edge total (gcx + compact output paths in get_file_summary), since
// the disk backends can collapse the edge round-trip into a server-
// side aggregate that's orders of magnitude cheaper than shipping
// every row back over cgo.
//
// Backends that implement graph.FileSubGraphCountReader handle the
// count server-side; others fall through to a full GetFileSymbols call
// and report len(sg.Edges) (correct, just not cheap).
func (e *Engine) GetFileSymbolsCounts(filePath string) *SubGraph {
if pd, ok := e.g.(graph.FileSubGraphCountReader); ok {
nodes, edgeCount := pd.GetFileSubGraphCounts(filePath)
if len(nodes) == 0 {
return &SubGraph{}
}
return &SubGraph{
Nodes: nodes,
TotalNodes: len(nodes),
TotalEdges: edgeCount,
}
}
sg := e.GetFileSymbols(filePath)
if sg == nil {
return &SubGraph{}
}
// Strip edges — the caller asked for counts only and we don't
// want stale edge buffers riding back on the SubGraph.
sg.Edges = nil
return sg
}
// GetFileSymbols returns the file node, every symbol the file
// defines or contains, and every edge adjacent to any of them.
//
// Backends that implement graph.FileSubGraphReader (the on-disk
// store, for instance) handle the whole walk in one method call so
// they can express the symbol enumeration as a primary-key probe +
// adjacency walk instead of a property-filter scan over Node.
// Backends without the capability fall through to the
// GetFileNodes + GetOut/InEdgesByNodeIDs trio — equivalent on the
// in-memory graph (the per-id lookups are already O(1)).
func (e *Engine) GetFileSymbols(filePath string) *SubGraph {
if pd, ok := e.g.(graph.FileSubGraphReader); ok {
nodes, edges := pd.GetFileSubGraph(filePath)
if len(nodes) == 0 {
return &SubGraph{}
}
return &SubGraph{
Nodes: nodes, Edges: edges,
TotalNodes: len(nodes), TotalEdges: len(edges),
}
}
nodes := e.g.GetFileNodes(filePath)
if len(nodes) == 0 {
return &SubGraph{}
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
}
outByID := e.g.GetOutEdgesByNodeIDs(ids)
inByID := e.g.GetInEdgesByNodeIDs(ids)
var edges []*graph.Edge
for _, id := range ids {
edges = append(edges, outByID[id]...)
edges = append(edges, inByID[id]...)
}
return &SubGraph{
Nodes: nodes, Edges: dedup(edges),
TotalNodes: len(nodes), TotalEdges: len(edges),
}
}
// dependencyEdgeKinds is the allowlist BFS follows for both
// GetDependencies (outgoing) and GetDependents (incoming). It covers
// the call-graph triple (imports/calls/references) plus the
// infrastructure edges (depends_on / configures / mounts / exposes /
// uses_env) so that "what does this Resource depend on" and "what
// depends on this ConfigMap" walks land on the manifest surface,
// not just the code surface.
var dependencyEdgeKinds = []graph.EdgeKind{
graph.EdgeImports, graph.EdgeCalls, graph.EdgeReferences,
graph.EdgeDependsOn, graph.EdgeConfigures, graph.EdgeMounts,
graph.EdgeExposes, graph.EdgeUsesEnv,
}
// GetDependencies returns outgoing dependencies (imports, calls,
// references, plus infrastructure edges) up to depth hops.
func (e *Engine) GetDependencies(nodeID string, opts QueryOptions) *SubGraph {
return e.bfs(nodeID, opts, true, dependencyEdgeKinds)
}
// GetDependents returns incoming dependents (blast radius) up to depth hops.
func (e *Engine) GetDependents(nodeID string, opts QueryOptions) *SubGraph {
return e.bfs(nodeID, opts, false, dependencyEdgeKinds)
}
// GetCallChain traces the call graph forward from a function. Follows
// EdgeCalls for intra-service traversal and EdgeMatches to cross service
// boundaries — a consumer function's outbound HTTP/gRPC/topic call is
// linked to the provider's handler via a matcher-produced edge, so the
// same BFS walks straight through.
func (e *Engine) GetCallChain(funcID string, opts QueryOptions) *SubGraph {
return e.bfs(funcID, opts, true, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeMatches})
}
// GetCallers returns all callers of a function. Traverses EdgeCalls,
// EdgeMatches, and EdgeReferences in reverse:
// - EdgeCalls: direct `foo()` invocations.
// - EdgeMatches: cross-service producer/consumer pairing from the matcher
// (HTTP / gRPC / topic) — a provider handler's callers include every
// consumer (possibly in another repo) that resolves to it.
// - EdgeReferences: method-value references (`mux.HandleFunc("/p", h.foo)`,
// command tables, callback maps, `defer x.Cleanup`). The handler isn't
// called *at this site*, but it's wired in here — semantically a caller.
// Without this kind, every routing-style codebase looks like its handlers
// have zero callers.
func (e *Engine) GetCallers(funcID string, opts QueryOptions) *SubGraph {
return e.bfs(funcID, opts, false, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeMatches, graph.EdgeReferences})
}
// GetTesters returns the test functions that exercise a symbol via
// the persistent EdgeTests edges baked at index time. Direct
// inverse-edge walk; one hop, no BFS. Returns an empty slice when
// the symbol has no test coverage or when the index pre-dates the
// EdgeTests pass.
func (e *Engine) GetTesters(symbolID string) []*graph.Node {
edges := e.g.GetInEdges(symbolID)
var out []*graph.Node
for _, edge := range edges {
if edge.Kind != graph.EdgeTests {
continue
}
if n := e.g.GetNode(edge.From); n != nil {
out = append(out, n)
}
}
return out
}
// FindImplementations returns all types implementing an interface.
func (e *Engine) FindImplementations(interfaceID string) []*graph.Node {
return e.FindImplementationsMinTier(interfaceID, "")
}
// FindOverrides returns the methods that override the given method
// (i.e. children with EdgeOverrides → methodID). One-hop walk over
// the type-hierarchy edges.
func (e *Engine) FindOverrides(methodID string) []*graph.Node {
return e.FindOverridesMinTier(methodID, "")
}
// FindOverridesMinTier filters override edges by minimum origin tier.
// Pass graph.OriginLSPDispatch to restrict to LSP-confirmed overrides.
func (e *Engine) FindOverridesMinTier(methodID, minTier string) []*graph.Node {
edges := e.g.GetInEdges(methodID)
out := make([]*graph.Node, 0, len(edges))
for _, edge := range edges {
if edge.Kind != graph.EdgeOverrides {
continue
}
if minTier != "" {
origin := edge.Origin
if origin == "" {
origin = graph.DefaultOriginFor(edge.Kind, edge.Confidence, "")
}
if !graph.MeetsMinTier(origin, minTier) {
continue
}
}
if n := e.g.GetNode(edge.From); n != nil {
out = append(out, n)
}
}
return out
}
// FindOverridden returns the parent-class / interface methods that
// the given method overrides (i.e. methodID -EdgeOverrides-> targets).
func (e *Engine) FindOverridden(methodID string) []*graph.Node {
edges := e.g.GetOutEdges(methodID)
out := make([]*graph.Node, 0, len(edges))
for _, edge := range edges {
if edge.Kind != graph.EdgeOverrides {
continue
}
if n := e.g.GetNode(edge.To); n != nil {
out = append(out, n)
}
}
return out
}
// FindImplementationsMinTier is FindImplementations filtered by the origin
// tier of the implements-edge. Pass "" for no filter; pass
// graph.OriginLSPDispatch (or higher) to restrict to compiler-verified
// interface dispatches.
func (e *Engine) FindImplementationsMinTier(interfaceID, minTier string) []*graph.Node {
edges := e.g.GetInEdges(interfaceID)
var impls []*graph.Node
for _, edge := range edges {
if edge.Kind != graph.EdgeImplements {
continue
}
if minTier != "" {
origin := edge.Origin
if origin == "" {
src, _ := edge.Meta["semantic_source"].(string)
origin = graph.DefaultOriginFor(edge.Kind, edge.Confidence, src)
}
if !graph.MeetsMinTier(origin, minTier) {
continue
}
}
if n := e.g.GetNode(edge.From); n != nil {
impls = append(impls, n)
}
}
return impls
}
// FindUsages returns all nodes that reference a symbol.
func (e *Engine) FindUsages(nodeID string) *SubGraph {
return e.FindUsagesScoped(nodeID, QueryOptions{})
}
// FindUsagesScoped is FindUsages with an optional workspace scope.
// When opts.WorkspaceID is set, only callers from that workspace are
// returned — i.e. find_usages on a tuck symbol returns hits only
// from tuck. Empty WorkspaceID preserves the legacy global-graph
// behaviour.
func (e *Engine) FindUsagesScoped(nodeID string, opts QueryOptions) *SubGraph {
edges := e.g.GetInEdges(nodeID)
nodeMap := make(map[string]*graph.Node)
var filtered []*graph.Edge
// First pass: collect every From id whose edge kind qualifies as
// a usage. We need the From *Node for the workspace / test
// filters below, but the legacy loop fetched it with one GetNode
// per edge — on a disk backend that's one query round-trip per
// inbound edge, which for hot symbols (hundreds of callers) was
// the dominant cost of find_usages. Pre-filter the kinds, then
// batch the lookup so the disk backend issues one query instead
// of N. The target nodeID rides on the same batch so the
// "include the target node itself" step at the end of this
// function does not need its own per-id call.
fromIDs := make([]string, 0, len(edges)+1)
seenFrom := make(map[string]struct{}, len(edges))
for _, edge := range edges {
if !isUsageEdgeKind(edge.Kind) {
continue
}
if _, dup := seenFrom[edge.From]; dup {
continue
}
seenFrom[edge.From] = struct{}{}
fromIDs = append(fromIDs, edge.From)
}
fromIDs = append(fromIDs, nodeID)
fromByID := e.g.GetNodesByIDs(fromIDs)
// Framework-contract edges (a @Configuration class "provides" a @Bean
// factory method, a handler "handles" a route, an emitter "produces" a
// topic) are the plumbing graph that analyze kind=routes / contracts
// consume — not usages of the code symbol they land on. When the queried
// symbol is a callable code symbol, drop these incoming edges from its
// usage set; they stay in the graph for the contract analyzers. On a
// DI-token / infra / contract target the same kinds ARE the meaningful
// relationship (find_usages on a token returns its providers), so they
// still pass.
dropContractOnCode := isCodeSymbolKind(fromByID[nodeID])
for _, edge := range edges {
// EdgeProvides + EdgeConsumes carry DI token relationships —
// `@Inject(TOKEN)` and `{ provide: TOKEN, useValue: ... }`
// both resolve into one of these, so find_usages on a token
// returns its providers and consumers alongside the usual
// call/reference/instantiate edges.
//
// Infrastructure edges complete the picture: find_usages
// on a ConfigMap returns workloads that consume it via
// `envFrom` (EdgeConfigures) or mount it (EdgeMounts);
// find_usages on a config_key returns workloads / Dockerfile
// stages that declare they use it (EdgeUsesEnv) plus code
// callers via the legacy reads_config path; find_usages on a
// Service returns Ingresses routing to it (EdgeDependsOn);
// find_usages on an Image returns workloads pulling it.
if isUsageEdgeKind(edge.Kind) {
if dropContractOnCode && isFrameworkContractEdgeKind(edge.Kind) {
continue
}
from := fromByID[edge.From]
if opts.hasScopeFilter() && (from == nil || !opts.ScopeAllows(from)) {
continue
}
if opts.ExcludeTests && isTestSource(from) {
continue
}
filtered = append(filtered, edge)
if from != nil {
nodeMap[from.ID] = from
}
}
}
// Expand any edge carrying Meta["call_sites"] into one usage row per site
// (a synthesized producer keeps one edge and records its extra sites there;
// see internal/graph/call_sites.go). Done here so both the GCX and
// plain-JSON find_usages paths render from the same edge slice.
filtered = expandCallSites(filtered)
// Include the target node itself (already in the batch above).
if n := fromByID[nodeID]; n != nil {
if !opts.hasScopeFilter() || opts.ScopeAllows(n) {
nodeMap[n.ID] = n
}
}
nodes := make([]*graph.Node, 0, len(nodeMap))
for _, n := range nodeMap {
nodes = append(nodes, n)
}
// Sort by ID — nodeMap is a map, so the extraction order is
// otherwise randomised per call and leaks into the result set.
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
return &SubGraph{
Nodes: nodes, Edges: filtered,
TotalNodes: len(nodes), TotalEdges: len(filtered),
}
}
// GetCluster returns the immediate neighbourhood within radius hops (bidirectional).
func (e *Engine) GetCluster(nodeID string, opts QueryOptions) *SubGraph {
return e.bfs(nodeID, opts, true, nil) // nil = all edge kinds, bidirectional
}
// SearchSymbols performs full-text search across all nodes.
// When a search backend is configured, uses BM25/Bleve ranking with
// camelCase-aware tokenization. Falls back to substring matching otherwise.
func (e *Engine) SearchSymbols(query string, limit int) []*graph.Node {
return e.SearchSymbolsScoped(query, limit, QueryOptions{})
}
// SearchSymbolsRanked is SearchSymbolsScoped that returns the full
// rerank.Candidate slice instead of just the nodes — callers can read
// the per-signal contributions and the final score off each candidate.
// rctx is optional session context (frecency / combo / feedback /
// repo + project locality); pass nil to score with structural signals
// only.
func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, rctx *rerank.Context) []*rerank.Candidate {
if limit <= 0 {
limit = 20
}
fetchLimit := limit
if opts.hasScopeFilter() {
fetchLimit = limit * 4
if fetchLimit > 200 {
fetchLimit = 200
}
}
// Engine-side rctx wins over the opts-piggybacked one (the explicit
// arg is the load-bearing path for callers that build the context
// inline). Callers (the MCP search_symbols handler) that build the
// rctx upstream and want both BM25 calls to share the same edge-
// cache seeding pass it through opts.RerankContext instead.
gatherCtx := rctx
if gatherCtx == nil {
gatherCtx = opts.RerankContext
}
var cands []*rerank.Candidate
if s := e.getSearch(); s != nil && s.Count() > 0 {
cands = e.gatherBackendCandidates(query, fetchLimit, opts, gatherCtx)
} else {
start := time.Now()
nodes := e.searchSubstring(query, fetchLimit)
if opts.SearchTimings != nil {
opts.SearchTimings.FallbackMS += time.Since(start).Milliseconds()
}
cands = make([]*rerank.Candidate, 0, len(nodes))
for i, n := range nodes {
cands = append(cands, &rerank.Candidate{Node: n, TextRank: i, VectorRank: -1})
}
}
if opts.hasScopeFilter() {
kept := cands[:0]
for _, c := range cands {
if !opts.ScopeAllows(c.Node) {
continue
}
kept = append(kept, c)
}
cands = kept
}
// Cross-repo RRF: when the candidate set spans repositories, the
// per-channel ranks are reassigned repo by repo so each repo's
// strongest hits compete on even footing. The rerank's RRF-kernel
// bm25 and semantic signals then fuse across repos rather than
// ranking within one merged corpus. No-op for a single-repo set.
crossRepoRerank(cands)
if e.rerank != nil && !opts.SkipInnerRerank {
ctx := rctx
if ctx == nil {
ctx = &rerank.Context{}
}
ctx.Graph = e.g
// When the caller supplied opts.RerankContext (the bundle-
// seeding handler), inherit its cached edges so this per-call
// rerank's prepare can read them — saves the 2 batched edge
// fetches per BM25 fan-out on the bundle hot path. Session
// signals stay scoped to the OUTER rerank (the one the handler
// runs against the merged candidate set); the inner rerank
// gets a structural-only context plus the bundle-cached edges.
if rctx == nil && opts.RerankContext != nil {
ctx.InheritEdgeCacheFrom(opts.RerankContext)
}
rerankStart := time.Now()
e.rerank.Rerank(query, cands, ctx)
if opts.SearchTimings != nil {
opts.SearchTimings.EngineRerankMS += time.Since(rerankStart).Milliseconds()
}
// Post-rerank exact-cosine refinement. The rank-based
// SemanticSignal scores the vector channel by RRF rank and
// discards the raw cosine the store computed; this stage
// recovers it by embedding the query once and re-ordering the
// ranked head against the candidates' stored vectors. Strictly
// best-effort: refineByCosine is a no-op whenever the vector
// channel is inactive, so a text-only search is unaffected.
if opts.CosineRerank {
cands = e.RefineByCosine(query, cands, opts.CosineTopN)
}
}
if len(cands) > limit {
cands = cands[:limit]
}
return cands
}
// RefineByCosine runs the post-rerank cosine refinement against the
// engine's current embedder and vector store. It resolves the embedder
// from the active search backend and the stored vectors from the graph
// reader; when either is unavailable it returns cands unchanged.
// Exposed so callers that run their own merged rerank (the MCP
// search_symbols handler) can reuse the exact same refinement after
// their final rerank pass.
func (e *Engine) RefineByCosine(query string, cands []*rerank.Candidate, topN int) []*rerank.Candidate {
embedder := backendEmbedder(e.getSearch())
if embedder == nil {
return cands
}
vectors, ok := e.g.(graph.VectorSearcher)
if !ok {
return cands
}
return refineByCosine(query, cands, embedder, vectors, topN)
}
// SearchSymbolsScoped is SearchSymbols with the optional
// workspace/project scope. When opts.WorkspaceID is set, results
// outside that scope are filtered out and the search re-fetches as
// needed to fill the requested limit. Empty scope preserves the
// legacy global behaviour.
func (e *Engine) SearchSymbolsScoped(query string, limit int, opts QueryOptions) []*graph.Node {
cands := e.SearchSymbolsRanked(query, limit, opts, nil)
out := make([]*graph.Node, 0, len(cands))
for _, c := range cands {
out = append(out, c.Node)
}
return out
}
// gatherBackendCandidates fetches BM25 + (optional) vector results,
// dedups them across channels, and supplements with exact-name /
// substring / bigram-rescue matches. Each candidate carries its
// 0-based TextRank and VectorRank (or -1 when the channel didn't
// return it) so the rerank pipeline can score per channel.
//
// Bundle fast path: when the backend implements
// SymbolBundleSearcherBackend, BM25 hits + their Node payload + their
// in/out edges all arrive in one engine round-trip. The bundle's
// edges seed rctx (when non-nil) so the rerank pipeline's prepare
// pass can skip its own batched fetch entirely. Vector channel IDs
// (which don't carry edges in the bundle) still route through the
// per-call GetNodesByIDs + GetIn/OutEdgesByNodeIDs path; bundle and
// vector candidates merge into one rerank slice.
//
// Fallback (no bundle support): the legacy path — Search() / channel
// for IDs, GetNodesByIDs to materialise. On a disk backend
// the bundle fast path collapses 3 round-trips (FTS + nodes +
// the rerank's 2 edge fetches) into 4 server-side queries with no
// engine→rerank boundary crossings; the GetNodesByIDs cost goes
// away entirely for the BM25 hits.
func (e *Engine) gatherBackendCandidates(query string, limit int, opts QueryOptions, rctx *rerank.Context) []*rerank.Candidate {
backend := e.getSearch()
timings := opts.SearchTimings
// Bundle fast path. The SymbolBundleSearcherBackend assertion
// chains through Swappable → HybridBackend → SymbolSearcherBackend
// in production; both Swappable and HybridBackend forward when
// the inner backend supports it. Vector IDs still need the
// per-call materialise — bundles don't carry vector hits.
var (
textResults []search.SearchResult
vectorIDs []string
bundleHandled bool
bundleNodeByID = make(map[string]*graph.Node)
)
if bsb, ok := backend.(search.SymbolBundleSearcherBackend); ok {
// Pull the vector channel separately when present. Bundles
// cover BM25 only; the engine merges vector hits below.
// VectorChannelOnly avoids re-running the text BM25 path —
// the bundle already returned the BM25 hits and their full
// node + edge payload. Falling back to SearchChannels here
// would double-pay the FTS query cost per BM25 fan-out.
type vectorOnly interface {
VectorChannelOnly(query string, limit int) ([]string, search.ChannelTimings)
}
vectorOnlyBackend, vectorOnlyOK := backend.(vectorOnly)
bundleStart := time.Now()
bundles := bsb.SearchSymbolBundles(query, limit*2)
if timings != nil {
timings.BundleMS += time.Since(bundleStart).Milliseconds()
}
if len(bundles) > 0 {
bundleHandled = true
textResults = make([]search.SearchResult, 0, len(bundles))
outSeed := make(map[string][]*graph.Edge, len(bundles))
inSeed := make(map[string][]*graph.Edge, len(bundles))
for _, b := range bundles {
if b.Node == nil {
continue
}
bundleNodeByID[b.Node.ID] = b.Node
textResults = append(textResults, search.SearchResult{ID: b.Node.ID, Score: b.Score})
outSeed[b.Node.ID] = b.OutEdges
inSeed[b.Node.ID] = b.InEdges
}
// Seed the rerank context's edge caches so prepare() can
// skip its own batched fetch for the bundle-covered IDs.
// preSeeded=true is the contract that prepare's batched
// edge fetch is now redundant — see rerank.Context for the
// invariant the engine relies on (the next caller's
// candidate set is fully covered by these maps for the
// BM25 hits; vector / substring fallback hits are still
// served by the per-candidate accessor fallback).
if rctx != nil {
rctx.SeedEdgeCaches(inSeed, outSeed, true)
}
}
// Vector channel: only when the bundle path took the BM25
// branch. Otherwise the fallback path below pulls both.
// VectorChannelOnly skips the BM25 re-run (the bundle already
// returned text hits + their full payload); a few hundred
// microseconds of embed + ANN, not a second FTS query.
//
// opts.SkipVectorChannel suppresses the embed + ANN entirely.
// The MCP handler flips this on for identifier-shape queries
// (QueryClassSymbol / Path / Signature) where the rerank's
// classWeightTable already proves semantic contributes near-
// zero signal vs the BM25 channel — see classWeightTable in
// internal/search/rerank/query_kind.go.
if vectorOnlyOK && !opts.SkipVectorChannel {
vecIDs, stats := vectorOnlyBackend.VectorChannelOnly(query, limit*2)
vectorIDs = vecIDs
if timings != nil {
timings.EmbedMS += stats.EmbedMS
timings.VectorSearchMS += stats.VectorSearchMS
}
}
}
// Legacy / fallback path: bundle backend absent OR returned no
// hits. Pull text + vector channels separately when the backend
// exposes them (HybridBackend). Otherwise treat plain Search()
// output as text-only. The wall-clock for the backend search
// call lands on the outer caller's BM25*MS bucket — measuring
// around the engine boundary captures the full per-call cost
// without double-counting against the post-call GetNodesByIDs /
// FindNodesByName / Fallback phases that this function
// instruments individually below.
if !bundleHandled {
type timedChan interface {
SearchChannelsTimed(query string, limit int) ([]search.SearchResult, []string, search.ChannelTimings)
}
switch {
case opts.SkipVectorChannel:
// Identifier-shape fast path: skip the vector channel
// (no embed, no ANN) and run text-only Search. The cost
// saved is the per-call embedder + vector index hit; the
// rerank's classWeightTable proves it's not earning its
// keep for these query classes.
textStart := time.Now()
textResults = backend.Search(query, limit*2)
if timings != nil {
timings.TextBackendMS += time.Since(textStart).Milliseconds()
}
default:
if tc, ok := backend.(timedChan); ok {
var stats search.ChannelTimings
textResults, vectorIDs, stats = tc.SearchChannelsTimed(query, limit*2)
if timings != nil {
timings.TextBackendMS += stats.TextMS
timings.EmbedMS += stats.EmbedMS
timings.VectorSearchMS += stats.VectorSearchMS
}
} else if cs, ok := backend.(search.ChannelSearcher); ok {
textStart := time.Now()
textResults, vectorIDs = cs.SearchChannels(query, limit*2)
if timings != nil {
timings.TextBackendMS += time.Since(textStart).Milliseconds()
}
} else {
textStart := time.Now()
textResults = backend.Search(query, limit*2)
if timings != nil {
timings.TextBackendMS += time.Since(textStart).Milliseconds()
}
}
}
}
// Collect every ID NOT covered by the bundle path (vector hits +
// fallback path's text hits) and materialise them with one
// batched fetch. Empty IDs are tolerated — the batch lookup
// ignores them and the per-id insert short-circuits below.
idBatch := make([]string, 0, len(textResults)+len(vectorIDs))
for _, r := range textResults {
if r.ID != "" {
if _, covered := bundleNodeByID[r.ID]; covered {
continue
}
idBatch = append(idBatch, r.ID)
}
}
for _, id := range vectorIDs {
if id != "" {
if _, covered := bundleNodeByID[id]; covered {
continue
}
idBatch = append(idBatch, id)
}
}
getNodesStart := time.Now()
nodeByID := e.g.GetNodesByIDs(idBatch)
if timings != nil {
timings.GetNodesMS += time.Since(getNodesStart).Milliseconds()
}
if nodeByID == nil {
// GetNodesByIDs returns nil for empty input — we still need a
// non-nil map below to merge the bundle's nodes into.
nodeByID = make(map[string]*graph.Node, len(bundleNodeByID))
}
// Merge the bundle's already-materialised nodes into the same
// lookup map the per-candidate insert step below reads from.
for id, n := range bundleNodeByID {
nodeByID[id] = n
}
idx := make(map[string]int) // node ID → slice index for dedup
cands := make([]*rerank.Candidate, 0, len(textResults)+len(vectorIDs))
insert := func(id string, textRank, vectorRank int) {
if id == "" {
return
}
node := nodeByID[id]
if node == nil || node.Kind == graph.KindFile || node.Kind == graph.KindImport {
return
}
if pos, ok := idx[id]; ok {
c := cands[pos]
if textRank >= 0 && (c.TextRank < 0 || textRank < c.TextRank) {
c.TextRank = textRank
}
if vectorRank >= 0 && (c.VectorRank < 0 || vectorRank < c.VectorRank) {
c.VectorRank = vectorRank
}
return
}
idx[id] = len(cands)
cands = append(cands, &rerank.Candidate{
Node: node, TextRank: textRank, VectorRank: vectorRank,
})
}
for rank, r := range textResults {
insert(r.ID, rank, -1)
}
for rank, id := range vectorIDs {
insert(id, -1, rank)
}
// Stop early when the BM25 + vector union has already exceeded the
// requested width; the supplementary tiers below are a fill, not a
// boost.
if len(cands) >= limit*2 {
return cands
}
// Exact-name matches that BM25 might rank low — splice them in at
// the tail of the text channel so they're still text-ranked. The
// caller can suppress this when the query string is known to never
// match a literal Name (the combined-OR fan-out's concatenated bag
// of expansion terms, for example) — saves the query round-trip
// that would unconditionally return zero rows.
if !opts.SkipExactNameSplice {
findNameStart := time.Now()
for _, n := range e.g.FindNodesByName(query) {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
if _, seen := idx[n.ID]; seen {
continue
}
idx[n.ID] = len(cands)
cands = append(cands, &rerank.Candidate{Node: n, TextRank: len(textResults), VectorRank: -1})
}
if timings != nil {
timings.FindNameMS += time.Since(findNameStart).Milliseconds()
}
}
// Substring fallback for remaining slots — strictly TextRank=-1
// (the rerank pipeline still considers them via signature/recency
// signals, but BM25 can't speak to them). The store-side
// FindNodesByNameContaining pushes the predicate into the backend
// index instead of materialising every node over cgo and filtering
// in Go — the old AllNodes loop is broken at Linux-kernel scale
// (10M+ symbols, hundreds of MB of nodes per query). We over-fetch
// by a small slack factor so dedup against existing cands still
// leaves room to fill `limit`.
if len(cands) < limit {
fallbackStart := time.Now()
fetch := (limit - len(cands)) * 2
if fetch < limit {
fetch = limit
}
subMatches := e.g.FindNodesByNameContaining(query, fetch)
// Stable ordering — backends may return in catalog order, which
// is not a meaningful relevance signal here.
sort.Slice(subMatches, func(i, j int) bool { return subMatches[i].ID < subMatches[j].ID })
for _, n := range subMatches {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
if _, seen := idx[n.ID]; seen {
continue
}
idx[n.ID] = len(cands)
cands = append(cands, &rerank.Candidate{Node: n, TextRank: -1, VectorRank: -1})
if len(cands) >= limit {
break
}
}
if timings != nil {
timings.FallbackMS += time.Since(fallbackStart).Milliseconds()
}
}
// Bigram-overlap typo rescue. Same gates as the legacy path:
// nothing else surfaced, query is one indivisible 4+ char token,
// backend can provide candidates. The bigram backend also returns
// raw IDs — batch-materialise them too rather than fall back to
// per-id GetNode. A query with a separator OR an internal-uppercase
// camelCase boundary is decomposable, so it is left for the handler's
// leaf-decomposition rescue (more precise than fuzzy bigram overlap) —
// the bigram tier serves true atomic-token typos only.
if len(cands) == 0 && len(query) >= 4 && !strings.ContainsAny(query, " /.:_-") && !hasInternalUppercase(query) {
if bg, ok := backend.(bigramProvider); ok {
keys := len(query) - 1
minOverlap := (keys + 1) / 2
if minOverlap < 3 {
minOverlap = 3
}
bigramIDs := bg.BigramCandidates(query, minOverlap)
// Skip the batch fetch entirely when the bigram backend
// returned nothing — otherwise we'd issue an empty query
// round-trip.
if len(bigramIDs) > 0 {
bigramNodes := e.g.GetNodesByIDs(bigramIDs)
for _, id := range bigramIDs {
if _, seen := idx[id]; seen {
continue
}
node := bigramNodes[id]
if node == nil || node.Kind == graph.KindFile || node.Kind == graph.KindImport {
continue
}
idx[id] = len(cands)
cands = append(cands, &rerank.Candidate{Node: node, TextRank: -1, VectorRank: -1})
if len(cands) >= limit {
break
}
}
}
}
}
return cands
}
// bigramProvider is satisfied by backends that expose a typo-tolerant
// rescue list. Declared here (not in search) so the engine can adopt
// rescue without the search interface changing; any backend that can
// provide bigram candidates just has to implement this method.
type bigramProvider interface {
BigramCandidates(query string, minOverlap int) []string
}
func (e *Engine) searchSubstring(query string, limit int) []*graph.Node {
lower := strings.ToLower(query)
exact := e.g.FindNodesByName(query)
type scored struct {
node *graph.Node
score int
}
var results []scored
seen := make(map[string]bool)
for _, n := range exact {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
seen[n.ID] = true
results = append(results, scored{n, 0})
}
allNodes := e.g.AllNodes()
for _, n := range allNodes {
if seen[n.ID] || n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
nameLower := strings.ToLower(n.Name)
idLower := strings.ToLower(n.ID)
if strings.HasPrefix(nameLower, lower) {
results = append(results, scored{n, 1})
} else if strings.Contains(nameLower, lower) {
results = append(results, scored{n, 2})
} else if strings.Contains(idLower, lower) {
results = append(results, scored{n, 3})
} else {
continue
}
seen[n.ID] = true
}
sort.Slice(results, func(i, j int) bool {
if results[i].score != results[j].score {
return results[i].score < results[j].score
}
if len(results[i].node.Name) != len(results[j].node.Name) {
return len(results[i].node.Name) < len(results[j].node.Name)
}
// Final tie-break on node ID — equal (score, name-length)
// pairs would otherwise resolve in random map-iteration order.
return results[i].node.ID < results[j].node.ID
})
out := make([]*graph.Node, 0, limit)
for i, r := range results {
if i >= limit {
break
}
out = append(out, r.node)
}
return out
}
// SearchSymbolsInRepo performs full-text search filtered to a specific repository.
func (e *Engine) SearchSymbolsInRepo(query string, repoPrefix string, limit int) []*graph.Node {
if limit <= 0 {
limit = 20
}
// Fetch extra results since some will be filtered out.
candidates := e.SearchSymbols(query, limit*2)
var out []*graph.Node
for _, n := range candidates {
if n.RepoPrefix == repoPrefix {
out = append(out, n)
if len(out) >= limit {
break
}
}
}
return out
}
// GetFileSymbolsInRepo returns all symbols defined in a file, scoped to a specific repository.
func (e *Engine) GetFileSymbolsInRepo(filePath string, repoPrefix string) *SubGraph {
sg := e.GetFileSymbols(filePath)
var nodes []*graph.Node
for _, n := range sg.Nodes {
if n.RepoPrefix == repoPrefix {
nodes = append(nodes, n)
}
}
var edges []*graph.Edge
nodeSet := make(map[string]bool, len(nodes))
for _, n := range nodes {
nodeSet[n.ID] = true
}
for _, edge := range sg.Edges {
if nodeSet[edge.From] || nodeSet[edge.To] {
edges = append(edges, edge)
}
}
return &SubGraph{
Nodes: nodes,
Edges: dedup(edges),
TotalNodes: len(nodes),
TotalEdges: len(edges),
}
}
// AllNodes returns all nodes in the graph.
func (e *Engine) AllNodes() []*graph.Node {
return e.g.AllNodes()
}
// Stats returns summary statistics for the graph.
func (e *Engine) Stats() *graph.GraphStats {
s := e.g.Stats()
return &s
}
// bfs performs breadth-first traversal from nodeID.
// If forward is true, follows outgoing edges; if false, follows incoming.
// If edgeKinds is nil, follows all edge kinds bidirectionally (for cluster).
// hasInternalUppercase reports whether s carries a camelCase boundary — an
// uppercase letter anywhere but the first byte. Such a query decomposes into
// multiple leaf tokens, so the bigram typo-rescue tier defers to the handler's
// leaf-decomposition rescue for it.
func hasInternalUppercase(s string) bool {
for i := 1; i < len(s); i++ {
if s[i] >= 'A' && s[i] <= 'Z' {
return true
}
}
return false
}
// defaultDispatchFanout bounds how many overriders one interface/abstract
// method expands to during polymorphic dispatch expansion, so a hub interface
// with hundreds of implementors cannot blow up a call-chain walk.
const defaultDispatchFanout = 24
func (e *Engine) bfs(nodeID string, opts QueryOptions, forward bool, edgeKinds []graph.EdgeKind) *SubGraph {
if opts.Depth <= 0 {
opts.Depth = 3
}
if opts.Limit <= 0 {
opts.Limit = 50
}
seed := e.g.GetNode(nodeID)
if opts.hasScopeFilter() && (seed == nil || !opts.ScopeAllows(seed)) {
return &SubGraph{}
}
bidir := edgeKinds == nil
kindSet := make(map[graph.EdgeKind]bool, len(edgeKinds))
for _, k := range edgeKinds {
kindSet[k] = true
}
// Prefer a single-round-trip BFS capability (the disk backend lowers it
// to one recursive CTE) over the layer-by-layer GetOutEdges / GetInEdges
// walk below, when the walk needs none of the per-layer hooks the flat
// hop-set cannot carry: workspace scope, test exclusion, dispatch
// expansion, or a bidirectional cluster walk. The in-memory graph
// implements the same capability, so both backends take this path and
// agree by construction; a backend without it (or a capability error)
// falls through to the Go walk, which stays the correctness oracle.
if capStore, ok := e.g.(graph.BFSCapable); ok && !bidir && len(edgeKinds) > 0 &&
!opts.hasScopeFilter() && !opts.ExcludeTests && !opts.IncludeDispatch {
if sg, ok := e.bfsViaCapability(capStore, nodeID, opts, forward, edgeKinds, kindSet); ok {
return sg
}
}
visited := map[string]bool{nodeID: true}
var allNodes []*graph.Node
var allEdges []*graph.Edge
truncated := false
// On a forward call-graph walk, record dropped dynamic-dispatch /
// unresolved out-edges as epistemic boundaries so get_call_chain can flag
// the reachable set as a floor rather than silently undercounting.
recordBoundaries := forward && !bidir && kindSet[graph.EdgeCalls]
var boundaries []graph.EpistemicBoundary
boundarySeen := map[string]bool{}
if seed != nil {
// The seed enters only after the scope gate above; neighbours
// discovered by traversal pass through the same scope check in
// admit.
allNodes = append(allNodes, seed)
}
// admit is the single place edge/node bookkeeping lives, shared by
// the batched and per-node expansion paths. It records the edge
// (unless the node budget is already full — the legacy code grew
// allEdges without bound, so a high-degree hub could pin gigabytes
// of edge structs), then admits a new, in-scope, non-test neighbour
// and returns its id to enqueue ("" = skip).
admit := func(edge *graph.Edge, neighborID string, neighbor *graph.Node) string {
// Skip unresolved/external targets — but on a call-graph walk, record
// the dropped dynamic-dispatch / external target as an epistemic
// boundary first, so the reachable set is honestly flagged as a floor.
if graph.IsUnresolvedTarget(neighborID) || strings.HasPrefix(neighborID, "external::") {
if recordBoundaries && edge != nil {
if reason, ok := graph.ClassifyDroppedTarget(neighborID, edge.Kind); ok {
key := edge.From + "\x00" + neighborID
if !boundarySeen[key] && len(boundaries) < 50 {
boundarySeen[key] = true
target := neighborID
if graph.IsUnresolvedTarget(neighborID) {
target = graph.UnresolvedName(neighborID)
}
boundaries = append(boundaries, graph.EpistemicBoundary{
SeedID: edge.From,
Target: target,
EdgeKind: string(edge.Kind),
Reason: reason,
Direction: "callees",
})
}
}
}
return ""
}
// Once the node budget is full, stop recording edges too: the
// result is already truncated and an unbounded allEdges is the
// memory-blowup vector this guard closes.
if len(allNodes) >= opts.Limit {
truncated = true
return ""
}
// ExcludeTests drops neighbours flagged as tests during a reverse
// traversal — a no-op for forward/bidirectional walks.
if opts.ExcludeTests && !forward && !bidir && isTestSource(neighbor) {
return ""
}
// Workspace/project scope: neighbours outside the bound scope are
// dropped along with the edge that pointed at them.
if opts.hasScopeFilter() && neighbor != nil && !opts.ScopeAllows(neighbor) {
return ""
}
allEdges = append(allEdges, edge)
if visited[neighborID] {
return ""
}
visited[neighborID] = true
if neighbor == nil {
return ""
}
allNodes = append(allNodes, neighbor)
return neighborID
}
// A backend that implements graph.FrontierExpander (the on-disk
// store) returns a whole frontier's edges + neighbour nodes in one
// round-trip — no GetNode per edge, no meta decode. Bidirectional
// (cluster) walks and capability-less backends (the in-memory graph,
// whose reads are already O(1)) keep the per-node path.
expander, batched := e.g.(graph.FrontierExpander)
batched = batched && !bidir && len(edgeKinds) > 0
frontier := []string{nodeID}
for depth := 0; depth < opts.Depth && len(frontier) > 0 && len(allNodes) < opts.Limit; depth++ {
var next []string
if batched {
for _, h := range expander.ExpandFrontier(frontier, forward, edgeKinds, opts.Limit) {
if h.Edge == nil {
continue
}
neighborID := h.Edge.To
if !forward {
neighborID = h.Edge.From
}
if id := admit(h.Edge, neighborID, h.Neighbor); id != "" {
next = append(next, id)
}
if len(allNodes) >= opts.Limit {
truncated = true
break
}
}
} else {
for _, cur := range frontier {
var edges []*graph.Edge
switch {
case bidir:
edges = append(e.g.GetOutEdges(cur), e.g.GetInEdges(cur)...)
case forward:
edges = e.g.GetOutEdges(cur)
default:
edges = e.g.GetInEdges(cur)
}
for _, edge := range edges {
if !bidir && !kindSet[edge.Kind] {
continue
}
var neighborID string
switch {
case forward || bidir:
if edge.From == cur {
neighborID = edge.To
} else if bidir {
neighborID = edge.From
} else {
continue
}
default:
if edge.To == cur {
neighborID = edge.From
} else {
continue
}
}
// One GetNode per neighbour (the legacy path fetched
// it twice — scope check, then materialise).
var neighbor *graph.Node
if !graph.IsUnresolvedTarget(neighborID) && !strings.HasPrefix(neighborID, "external::") {
neighbor = e.g.GetNode(neighborID)
}
if id := admit(edge, neighborID, neighbor); id != "" {
next = append(next, id)
}
if len(allNodes) >= opts.Limit {
truncated = true
break
}
}
if len(allNodes) >= opts.Limit {
break
}
}
}
// Polymorphic dispatch expansion: on a forward call walk, a node that
// is an interface / abstract method is also expanded through its
// EdgeOverrides IN-edges to the concrete implementations, so the trace
// auto-reaches the impls. The override edge is recorded unchanged (not
// faked into a call), gated by DispatchMinTier and capped per method.
if opts.IncludeDispatch && forward && len(allNodes) < opts.Limit {
fanout := opts.DispatchFanout
if fanout <= 0 {
fanout = defaultDispatchFanout
}
for _, cur := range frontier {
expanded := 0
for _, edge := range e.g.GetInEdges(cur) {
if edge.Kind != graph.EdgeOverrides {
continue
}
if opts.DispatchMinTier != "" {
origin := edge.Origin
if origin == "" {
origin = graph.DefaultOriginFor(edge.Kind, edge.Confidence, "")
}
if !graph.MeetsMinTier(origin, opts.DispatchMinTier) {
continue
}
}
var impl *graph.Node
if !graph.IsUnresolvedTarget(edge.From) && !strings.HasPrefix(edge.From, "external::") {
impl = e.g.GetNode(edge.From)
}
if id := admit(edge, edge.From, impl); id != "" {
next = append(next, id)
}
expanded++
if expanded >= fanout || len(allNodes) >= opts.Limit {
break
}
}
if len(allNodes) >= opts.Limit {
break
}
}
}
frontier = next
}
// ExpandFrontier returns meta-free neighbours; a full-detail caller
// (e.g. one reading Meta["signature"]) gets them re-hydrated in one
// batched round-trip. Brief callers (smart_context's ring, step-7)
// skip this — stripMeta would drop the meta anyway.
if batched && opts.Detail != "brief" && len(allNodes) > 1 {
if hyd, ok := e.g.(interface {
GetNodesByIDs(ids []string) map[string]*graph.Node
}); ok {
ids := make([]string, 0, len(allNodes))
for _, n := range allNodes {
ids = append(ids, n.ID)
}
if full := hyd.GetNodesByIDs(ids); full != nil {
for i, n := range allNodes {
if fn := full[n.ID]; fn != nil {
allNodes[i] = fn
}
}
}
}
}
sg := &SubGraph{
Nodes: allNodes,
Edges: allEdges,
TotalNodes: len(visited),
TotalEdges: len(allEdges),
Truncated: truncated,
}
if len(boundaries) > 0 {
sg.Boundaries = boundaries
sg.LowerBound = graph.LowerBoundCaveat(boundaries)
}
if opts.Detail == "brief" {
stripMeta(sg)
}
return sg
}
func stripMeta(sg *SubGraph) {
for _, n := range sg.Nodes {
n.Meta = nil
}
}
// bfsViaCapability builds the same SubGraph the layer walk would, but
// seeds it from a backend BFSCapable.BFS hop-set fetched in one
// round-trip. It is taken only for non-bidirectional, kind-filtered walks
// without workspace scope / test exclusion / dispatch expansion (gated by
// the caller in bfs); ok=false signals a capability error so the caller
// falls back to the in-memory layer walk.
//
// The reachable hop-set is materialised into nodes (one batched
// GetNodesByIDs) and discovery edges (one batched adjacency fetch of the
// expanded frontier, reused for the callee-boundary scan on forward call
// walks). Node / edge selection mirrors the layer walk's admit(): the
// seed always enters, unresolved / external-prefixed targets never do.
func (e *Engine) bfsViaCapability(
capStore graph.BFSCapable,
nodeID string,
opts QueryOptions,
forward bool,
edgeKinds []graph.EdgeKind,
kindSet map[graph.EdgeKind]bool,
) (*SubGraph, bool) {
dir := graph.DirectionForward
if !forward {
dir = graph.DirectionBackward
}
hops, err := capStore.BFS([]string{nodeID}, dir, edgeKinds, opts.Depth, opts.Limit)
if err != nil {
return nil, false
}
ids := make([]string, 0, len(hops))
var expandedIDs []string
for _, h := range hops {
ids = append(ids, h.NodeID)
// A node at depth < maxDepth had its neighbours followed — its
// adjacency carries both the discovery edges of its children and
// (forward) the dropped dynamic-dispatch out-edges for boundaries.
if h.Depth < opts.Depth {
expandedIDs = append(expandedIDs, h.NodeID)
}
}
nodesByID := e.g.GetNodesByIDs(ids)
var adj map[string][]*graph.Edge
if forward {
adj = e.g.GetOutEdgesByNodeIDs(expandedIDs)
} else {
adj = e.g.GetInEdgesByNodeIDs(expandedIDs)
}
allNodes := make([]*graph.Node, 0, len(hops))
allEdges := make([]*graph.Edge, 0, len(hops))
for _, h := range hops {
if h.ParentID == "" { // seed: always enters, regardless of scope
if n := nodesByID[h.NodeID]; n != nil {
allNodes = append(allNodes, n)
}
continue
}
if graph.IsUnresolvedTarget(h.NodeID) || strings.HasPrefix(h.NodeID, "external::") {
continue
}
n := nodesByID[h.NodeID]
if n == nil {
continue
}
allNodes = append(allNodes, n)
if edge := matchDiscoveryEdge(adj[h.ParentID], h, forward); edge != nil {
allEdges = append(allEdges, edge)
}
}
sg := &SubGraph{
Nodes: allNodes,
Edges: allEdges,
TotalNodes: len(allNodes),
TotalEdges: len(allEdges),
Truncated: opts.Limit > 0 && len(hops) >= opts.Limit,
}
// A forward call walk flags its reachable set as a floor when an
// expanded node drops a dynamic-dispatch / external out-edge — the same
// boundaries the layer walk records inline during admit(), rebuilt from
// the expanded frontier's out-edges (already fetched into adj).
if forward && kindSet[graph.EdgeCalls] {
if bs := calleeBoundariesFromAdjacency(expandedIDs, adj, kindSet); len(bs) > 0 {
sg.Boundaries = bs
sg.LowerBound = graph.LowerBoundCaveat(bs)
}
}
if opts.Detail == "brief" {
stripMeta(sg)
}
return sg, true
}
// matchDiscoveryEdge finds, in a parent node's adjacency slice, the real
// edge that the hop's (ParentID, NodeID, EdgeKind) discovery tuple names,
// so the SubGraph carries the persisted edge (with its provenance) rather
// than a synthetic one. Forward: parent -> node; backward: node -> parent.
func matchDiscoveryEdge(edges []*graph.Edge, h graph.BFSHop, forward bool) *graph.Edge {
for _, e := range edges {
if e == nil || e.Kind != h.EdgeKind {
continue
}
if forward {
if e.From == h.ParentID && e.To == h.NodeID {
return e
}
} else if e.From == h.NodeID && e.To == h.ParentID {
return e
}
}
return nil
}
// calleeBoundariesFromAdjacency reconstructs the epistemic boundaries a
// forward call walk records: for each expanded node, every out-edge of a
// traversed kind whose target the walk could not follow (an unresolved /
// external-prefixed stub) is one boundary, deduplicated by (source,
// target) and capped. Mirrors the inline recordBoundaries path in bfs:
// same kind gate (the walk's kindSet), same dropped-target set, same
// "callees" direction, no SeedName.
func calleeBoundariesFromAdjacency(
expandedIDs []string,
outAdj map[string][]*graph.Edge,
kindSet map[graph.EdgeKind]bool,
) []graph.EpistemicBoundary {
seen := make(map[string]bool)
var out []graph.EpistemicBoundary
for _, id := range expandedIDs {
for _, edge := range outAdj[id] {
if edge == nil || !kindSet[edge.Kind] {
continue
}
if !graph.IsUnresolvedTarget(edge.To) && !strings.HasPrefix(edge.To, "external::") {
continue
}
reason, ok := graph.ClassifyDroppedTarget(edge.To, edge.Kind)
if !ok {
continue
}
key := edge.From + "\x00" + edge.To
if seen[key] {
continue
}
seen[key] = true
target := edge.To
if graph.IsUnresolvedTarget(edge.To) {
target = graph.UnresolvedName(edge.To)
}
out = append(out, graph.EpistemicBoundary{
SeedID: edge.From,
Target: target,
EdgeKind: string(edge.Kind),
Reason: reason,
Direction: "callees",
})
if len(out) >= 50 {
return out
}
}
}
return out
}
// expandCallSites fans an edge that carries additional Meta["call_sites"]
// into one usage edge per recorded site (the primary stays in FilePath/Line).
// Rows are deduped by (from, file, line) so a site that already exists as its
// own edge is never double-counted; real edges are emitted first so they win
// over a call_sites clone at the same location. Cheap no-op when no edge in
// the set carries call_sites (the overwhelmingly common case).
func expandCallSites(edges []*graph.Edge) []*graph.Edge {
hasSites := false
for _, e := range edges {
if len(graph.CallSites(e)) > 0 {
hasSites = true
break
}
}
if !hasSites {
return edges
}
key := func(from, file string, line int) string {
return from + "\x00" + file + ":" + strconv.Itoa(line)
}
seen := make(map[string]struct{}, len(edges))
out := make([]*graph.Edge, 0, len(edges))
// Pass 1: the real edges, deduped by location.
for _, e := range edges {
k := key(e.From, e.FilePath, e.Line)
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
out = append(out, e)
}
// Pass 2: expand call_sites, skipping any location already emitted.
for _, e := range edges {
for _, site := range graph.CallSites(e) {
file, line := graph.SplitCallSite(site)
if file == "" {
continue
}
k := key(e.From, file, line)
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
clone := *e
clone.FilePath = file
clone.Line = line
out = append(out, &clone)
}
}
return out
}
// isUsageEdgeKind reports whether an edge kind counts as a "usage"
// for FindUsages — the same predicate the legacy inline if-chain
// evaluated. Hoisted into a function so the kind set can be reused
// across the pre-filter pass and the materialisation pass without
// drifting.
func isUsageEdgeKind(k graph.EdgeKind) bool {
switch k {
case graph.EdgeCalls, graph.EdgeReferences,
graph.EdgeInstantiates,
graph.EdgeReturns, graph.EdgeTypedAs,
graph.EdgeImplements, graph.EdgeExtends,
graph.EdgeComposes,
// Import / re-export statements ARE usages: every LSP reference
// set counts the `from x import name` / `export {name} from …`
// line, and a symbol whose only consumers import it through a
// façade otherwise looks unused. These edges point at symbol
// nodes only where extractors bind them (Python, JS/TS); Go
// imports target package nodes, so Go results are unaffected.
graph.EdgeImports, graph.EdgeReExports,
graph.EdgeProvides, graph.EdgeConsumes,
graph.EdgeReadsConfig, graph.EdgeWritesConfig,
graph.EdgeUsesEnv, graph.EdgeConfigures,
graph.EdgeMounts, graph.EdgeExposes,
graph.EdgeDependsOn:
return true
}
return false
}
// isFrameworkContractEdgeKind reports whether an edge kind is framework
// plumbing — the contract graph analyze kind=routes / contracts / models
// consume — rather than a genuine code usage. find_usages suppresses these
// when the queried symbol is a callable code symbol (a @Bean factory method's
// only "usage" was the provides contract edge from its @Configuration class),
// while keeping them for DI-token / topic / table / route contract targets.
func isFrameworkContractEdgeKind(k graph.EdgeKind) bool {
switch k {
case graph.EdgeProvides, graph.EdgeConsumes,
graph.EdgeHandlesRoute, graph.EdgeProducesTopic, graph.EdgeConsumesTopic,
graph.EdgeModelsTable, graph.EdgeRendersChild:
return true
}
return false
}
// isCodeSymbolKind reports whether a node is a callable code symbol —
// a function or method (constructors are methods). find_usages drops
// framework-contract incoming edges only for these targets.
func isCodeSymbolKind(n *graph.Node) bool {
return n != nil && (n.Kind == graph.KindFunction || n.Kind == graph.KindMethod)
}
// isTestSource reports whether a node was flagged as a test by the
// indexer's test-edge pass. Used by QueryOptions.ExcludeTests to drop
// callers/users that originate in tests, leaving production callers.
func isTestSource(n *graph.Node) bool {
if n == nil || n.Meta == nil {
return false
}
v, _ := n.Meta["is_test"].(bool)
return v
}
func dedup(edges []*graph.Edge) []*graph.Edge {
if len(edges) == 0 {
return edges
}
// Struct key avoids the per-edge string concatenation the old
// implementation paid (e.From + "->" + e.To + ":" + kind) — on a
// 4 000-edge file the alloc storm dominated GetFileSymbols.
type dedupKey struct {
from string
to string
kind graph.EdgeKind
}
seen := make(map[dedupKey]struct{}, len(edges))
out := make([]*graph.Edge, 0, len(edges))
for _, e := range edges {
if e == nil {
continue
}
k := dedupKey{from: e.From, to: e.To, kind: e.Kind}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, e)
}
return out
}