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

3977 lines
149 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package resolver
import (
"fmt"
"iter"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
const unresolvedPrefix = "unresolved::"
// resolveProfileStarted guards the one-shot GORTEX_RESOLVE_CPUPROFILE capture
// so only the first full resolve pass is profiled.
var resolveProfileStarted atomic.Bool
// ResolveStats holds counts from a resolution pass.
type ResolveStats struct {
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
External int `json:"external"`
// PendingBefore / PendingAfter record the pending-edge count before and
// after the scope filter (see SetScope). Diagnostic only — the
// warm-restart master-resolve log surfaces them so a scoped pass's
// reduction is visible. Zero (omitted) on the unscoped whole-graph path.
PendingBefore int `json:"pending_before,omitempty"`
PendingAfter int `json:"pending_after,omitempty"`
}
// Resolver resolves unresolved edge targets to actual graph node IDs.
//
// dirIndex / lastDirIndex are scratch maps populated for the duration
// of a single ResolveAll/ResolveFile pass so resolveImport can look up
// candidate file nodes in O(1) instead of scanning the whole graph per
// import edge. On large repos (vscode ≈ 150k nodes / 5k imports) the
// old full scan made ResolveAll the dominant cost of a cold index
// (8m of a 9m wall-clock). Maps are cleared between passes.
//
// mu serializes ResolveAll and ResolveFile because both reset and
// repopulate the scratch maps as part of their first step. Without
// it, two concurrent file-watcher debounce goroutines firing on the
// same per-repo Indexer (each calls Resolver.ResolveFile via
// Indexer.IndexFile) crash the daemon with "concurrent map writes"
// in buildDirIndexes.
type Resolver struct {
graph graph.Store
logger *zap.Logger
dirIndex map[string][]*graph.Node
lastDirIndex map[string][]*graph.Node
// receiverTypeIdxByDir memoizes, per package directory, the Go type index
// the per-file method-receiver rebind builds. On a scoped tail that visits
// every file of a D-file package, building it once per package (O(D)) rather
// than once per file (O(D^2) GetFileNodes) removes the quadratic. Cleared
// with dirIndex — it is only valid while the type nodes it indexes are held
// stable for the duration of one ResolveAll/ResolveFile pass.
receiverTypeIdxByDir map[string]map[pkgKey]string
// cppIncludeDirs maps a repo-relative C/C++ source file to its ordered
// include search path (the `-I` / `-isystem` dirs from compile_commands.json),
// so a quoted/angle include resolves against the real compiler dir set
// (deterministic, collision-breaking) before the suffix-unique fallback.
// Populated by the indexer via SetCppIncludeDirs before ResolveAll.
cppIncludeDirs map[string][]string
// cppFallbackDirs is the heuristic include-root search path used when a
// repo has no compile_commands.json: conventional dirs (include/src/inc/
// api/lib) plus top-level header dirs, in priority order. The ordered
// probe runs against it so collisions break deterministically even with
// no compile DB. Populated by the indexer via SetCppFallbackIncludeDirs.
cppFallbackDirs []string
// providesForIdx maps `provides_for: AbstractName` (from @Module
// useClass entries) → the set of concrete class names bound to it.
// Populated once at the start of ResolveAll; consulted in O(1) by
// resolveMethodCall's DI-binding fallback instead of re-walking
// graph.AllEdges per call edge. Nil outside a resolution pass and
// empty-but-non-nil when the graph has no @Module bindings, so
// callers can short-circuit with len().
providesForIdx map[string]map[string]struct{}
// reachableDirsByFile maps caller-file ID → set of directories
// reachable from that file (own dir directories of files reached
// via EdgeImports). Populated once at the start of ResolveAll/
// ResolveFile; consulted by resolveMethodCall to drop candidates
// that live in packages the caller doesn't import. Without this,
// the name-only fallback picks an arbitrary alphabetically-first
// candidate across the whole graph, which produced bugs like
// `RegisterAll` resolving to `OverlayManager.Register` simply
// because "OverlayManager" sorts before "Registry".
reachableDirsByFile map[string]map[string]struct{}
// dirByFilePath memoises filepath.Dir(path) for every indexed file,
// built once alongside reachableDirsByFile. filterByReachability runs in
// the parallel resolver workers and otherwise recomputes filepath.Dir
// per candidate per edge — ~20% of resolution CPU on a large TS monorepo
// (filepathlite.Dir/Clean dominate). Read-only after build, so the
// workers share it lock-free.
dirByFilePath map[string]string
// depModuleIndex bridges Go imports to dep::<module> contract
// nodes emitted from go.mod. Keyed by RepoPrefix (the dep node's
// owning repo) so we never link an import in repo A to a dep
// declared by repo B's go.mod. Each entry list is sorted by
// modulePath length descending so longest-prefix wins when
// modules nest (e.g. aws-sdk-go-v2 vs aws-sdk-go-v2/service/s3).
// Without this index, every dep::* contract node sits in the
// graph with zero incoming edges — go.mod records the dependency
// but no edge points consumers at it. Built once per Resolve*
// pass, torn down at the end.
depModuleIndex map[string][]depModuleEntry
// mu serialises resolution phases against the shared graph.
// Pointer so every Resolver built from the same graph.Store
// locks the same mutex — necessary for MultiIndexer's per-repo
// goroutines, each of which spawns its own Resolver instance.
// Without the shared lock, concurrent ResolveAll passes race on
// edge mutations (resolveImport writes e.To while another
// goroutine iterates via graph.AllEdges()).
mu *sync.Mutex
// validateLiveness turns on the concurrent-edit guard on the chunked
// ResolveAll path: it releases mu between chunks so an interactive edit
// can interleave and evict an edge the pass already resolved. With it on,
// the per-chunk apply and guardCrossPackageCallEdges skip an evicted edge
// (reindexing one half-resurrects it and can panic). Off (the default and
// every non-chunked path) it is a no-op — nothing mutates the graph
// mid-pass. Set only inside ResolveAll.
validateLiveness bool
// bulkMode is set true by ResolveAll for the duration of its parallel
// worker fan-out and dropped around the inter-chunk mutex yield. While it
// is on, resolveEdge skips the synchronous per-edge tryResolveViaLSP
// round-trip: an LSP definition lookup serialises inside the helper, so a
// TS/JS-dense chunk otherwise degenerates to serial-LSP-latency × chunk
// size while every other worker idles at the barrier. The heuristic
// cascade still runs; edges it leaves unresolved that the helper could
// bind are collected and resolved once, off the barrier, in a deferred
// batch after the loop (see resolveDeferredLSP). Dropped around the yield
// so an interactive ResolveFile that interleaves on a shared Resolver
// instance still gets inline LSP precision. Independent of scope /
// validateLiveness; never set on any single-file path.
bulkMode bool
// lookupCache holds per-pass batched results from GetNodesByIDs /
// FindNodesByNames. Populated by ResolveAll/ResolveFile before
// the worker fan-out and cleared on return. Workers consult these
// maps first; misses fall through to the underlying Store.
//
// Without the cache, the resolver fires ~3-10 store point lookups
// per pending edge — across 10-30k unresolved edges that's 100k+
// queries, each one a round trip on disk backends (~ms each).
// With the cache the same information lands in two batched
// queries per pass.
nodeByID map[string]*graph.Node
nodesByName map[string][]*graph.Node
nodesByQualName map[string]*graph.Node
// importFilesByCaller memoises, per caller file, the set of file
// paths that file imports (direct EdgeImports targets plus files
// reached through transitive EdgeReExports barrel hops). Built
// lazily inside the parallel resolve workers — importFilesMu guards
// it — and cleared with the per-pass lookup caches. Consulted by
// pickImportEvidenceCallee to disambiguate bare JS/TS calls; see
// import_evidence.go for the precedence design.
importFilesByCaller map[string]map[string]struct{}
importFilesMu sync.RWMutex
// incrementalSkip holds the source-shapes of a single re-resolved file's
// out-edges that were already unresolved before the edit; the forward
// pass skips them. Set/cleared around ResolveFileAndIncoming by the
// single-file index path. nil on every batch/whole-graph pass.
incrementalSkip map[string]struct{}
// lspHelper, when non-nil, is consulted before falling back to
// AST heuristics for cross-file dispatch in languages whose
// helper-reported extensions match (today: TS/JS/JSX/TSX via
// tsserver). See lsp_helper.go for the contract. Set via
// SetLSPHelper before ResolveAll runs.
lspHelper LSPHelper
// lspIndex caches a (filePath, oneBasedLine) → *graph.Node
// lookup table populated lazily on first LSP hit per pass so
// matchNodeByLocation runs in O(1) instead of scanning every
// node in the file. Cleared between passes.
lspIndex map[lspLocKey]*graph.Node
lspIndexMu sync.RWMutex
// npmAlias, when non-nil, rewrites a JS/TS import specifier that
// matches an npm-alias dependency key in the importing file's
// nearest-ancestor package.json. See npm_alias.go for the
// contract. Set via SetNpmAliasResolver before ResolveAll runs.
npmAlias NpmAliasResolver
// pathAlias, when non-nil, expands a JS/TS tsconfig/jsconfig
// `compilerOptions.paths` / `baseUrl` import specifier to the
// repo-prefixed file stem it targets. See jsts_imports.go for the
// contract. Set via SetPathAliasResolver before ResolveAll runs.
pathAlias PathAliasResolver
// workspaceMembers, when non-nil, maps a file path to the
// package-manager workspace it belongs to. Used to break a
// same-named import collision in favour of the candidate that
// shares the importing file's workspace. See
// workspace_membership.go for the contract. Set via
// SetWorkspaceMembership before ResolveAll runs.
workspaceMembers WorkspaceMembership
// scope, when non-empty, restricts the next ResolveAll pass to the
// pending edges that could resolve into one of the named repo
// prefixes — the warm-restart optimisation that avoids a whole-graph
// resolve when only a few of many tracked repos re-indexed. nil or
// empty means whole-graph, exactly the pre-scoping behaviour. Set via
// SetScope. Independent of any backend bulk-mode flag.
scope map[string]struct{}
// stampTerminal, when true, lets a FULL (unscoped) ResolveAll durably mark
// the edges it concludes are permanently external / stdlib / definition-
// less so a later SCOPED warm resolve can skip re-feeding them (see
// terminal.go). Only the whole-graph master resolve — which has global
// evidence — enables it; per-repo and single-file passes leave it false so
// a partially-indexed graph never stamps a false "no definition". Set via
// SetStampTerminal.
stampTerminal bool
}
// lspLocKey identifies a node by (filePath, 1-based line) and is the
// key for lspIndex. Tsserver's textDocument/definition reports the
// declaration's start position, which graph.Node.StartLine matches.
type lspLocKey struct {
filePath string
line int
}
// depModuleEntry pairs a Go module path (parsed from a dep:: contract
// node ID) with the node itself, so import-path prefix matches can
// jump straight to the target.
type depModuleEntry struct {
modulePath string
node *graph.Node
}
// New creates a Resolver for the given store. The returned Resolver
// shares store.ResolveMutex() with every other Resolver built from
// the same Store, so their ResolveAll / ResolveFile calls serialise
// end-to-end across cross-repo / temporal / external passes.
func New(g graph.Store) *Resolver {
return &Resolver{graph: g, mu: g.ResolveMutex(), logger: zap.NewNop()}
}
// SetLogger attaches a logger so ResolveAll emits pass-progress
// (pending count, periodic compute progress, compute/apply elapsed).
// A nil logger is replaced with a no-op so the resolver never panics
// when constructed without one (every direct caller of New gets Nop).
func (r *Resolver) SetLogger(l *zap.Logger) {
if l == nil {
l = zap.NewNop()
}
r.logger = l
}
// SetScope restricts the next ResolveAll pass to pending edges that could
// resolve into one of the given repo prefixes (see the scope field). A nil
// or empty map restores whole-graph resolution — byte-for-byte the
// pre-scoping behaviour. The scope persists across calls until reset,
// mirroring the other Set* configuration setters.
func (r *Resolver) SetScope(prefixes map[string]struct{}) {
r.scope = prefixes
}
// SetStampTerminal enables durable terminal-edge stamping for the next FULL
// (unscoped) ResolveAll pass (see the stampTerminal field and terminal.go). It
// is a no-op on scoped passes, which lack the global evidence to conclude an
// edge is permanently unbindable. Only the whole-graph master resolve should
// enable it.
func (r *Resolver) SetStampTerminal(on bool) {
r.stampTerminal = on
}
// SetGraph retargets the Resolver at a different Store. The indexer's
// in-memory shadow-swap path needs this: the Resolver is constructed
// against the disk Store at indexer-New time, but during IndexCtx the
// indexer reassigns its own graph pointer to an in-memory shadow.
// Without SetGraph the Resolver kept reading the (empty) disk Store
// and short-circuited on len(pending) == 0, silently disabling every
// resolver pass for backends that opt into the shadow swap.
//
// Holds the resolve mutex so a concurrent ResolveAll / ResolveFile
// can't observe a half-rotated graph reference, and switches mu to
// the new store's resolve mutex so subsequent passes serialise
// against any Resolver built directly on the new Store.
func (r *Resolver) SetGraph(g graph.Store) {
if g == nil {
return
}
oldMu := r.mu
if oldMu != nil {
oldMu.Lock()
}
r.graph = g
r.mu = g.ResolveMutex()
if oldMu != nil {
oldMu.Unlock()
}
}
// ResolveAll resolves all unresolved edges in the graph.
//
// Edge resolution is partitioned across runtime.NumCPU() workers.
// Each worker iterates a disjoint slice and calls resolveEdge, which:
//
// - mutates only its own e.To field (per-edge ownership, no
// write-write races between workers),
// - reads graph state via Find/Get methods that take per-shard
// RLocks (concurrent-safe),
// - calls graph.ReindexEdge which acquires write locks on three
// specific shards (e.From, oldTo, newTo) — concurrency between
// workers serialises only on shard collisions, not globally.
//
// Stats are aggregated per-worker and summed at the end so
// `Resolved++` etc. don't race. r.mu serialises ResolveAll calls
// against each other; nothing inside this function takes that lock.
func (r *Resolver) ResolveAll() *ResolveStats {
r.mu.Lock()
defer r.mu.Unlock()
r.buildDirIndexes()
defer r.clearDirIndexes()
r.buildDepModuleIndex()
defer r.clearDepModuleIndex()
r.buildProvidesForIndex()
defer r.clearProvidesForIndex()
r.buildReachabilityIndex()
defer r.clearReachabilityIndex()
defer r.clearLSPIndex()
// Backend-delegated resolution: when the store implements
// graph.BackendResolver, drain the bulk-tractable subset of the
// resolver's work via a sequence of queries that run
// inside the backend engine. ON BY DEFAULT — opt out with
// GORTEX_BACKEND_RESOLVER=0 (see backendResolverEnabled). ResolveAllBulk
// chains the per-rule methods (SameFile → SamePackage → ImportAware → …)
// in precision-descending order, so higher-precision rules bind first
// and unique-name fallback only resolves what nothing more specific
// covered.
//
// This is the disk-only / large-repo path: without it the Go worker
// pool's ~100k+ per-edge round trips dominate wall time. The bulk pass
// drains the name-equality-tractable edges in-engine before the Go pool
// runs on whatever's left. Errors are non-fatal — the Go resolver
// re-runs on the remainder.
if backendResolverEnabled() {
if br, ok := r.graph.(graph.BackendResolver); ok {
bulkStart := time.Now()
n, err := br.ResolveAllBulk()
r.logger.Info("resolver: backend bulk pass",
zap.Int("resolved", n),
zap.Duration("elapsed", time.Since(bulkStart)),
zap.Error(err))
}
}
// Use the predicate-shaped Store method so disk backends scan
// only the contiguous "unresolved::*" slice instead of pulling
// the whole edges table back to the client and filtering in Go.
// In-memory keeps the same cost as the old AllEdges()+prefix-check
// loop.
var pending []*graph.Edge
for e := range r.graph.EdgesWithUnresolvedTarget() {
pending = append(pending, e)
}
pendingBefore := len(pending)
// Scoped warm-restart resolve: when a set of changed repos is armed via
// SetScope, drop the pending edges that provably can't newly resolve into
// any of them. See filterPendingByScope for the conservative superset
// rule. nil / empty scope leaves pending untouched — exactly the
// whole-graph behaviour.
if len(r.scope) > 0 {
pending = filterPendingByScope(pending, r.scope)
}
// Terminal-edge skip: on a scoped warm pass, drop the edges a prior FULL
// pass durably flagged as permanently external / stdlib / definition-less
// (see terminal.go), unless they are anchored to a changed repo. A full /
// unscoped pass (scope empty), and the GORTEX_WARMUP_FULL_RESOLVE override,
// ignore the flag and re-examine everything so a stamp self-heals.
terminalSkipped := 0
if len(r.scope) > 0 && !warmupFullResolve() {
pending, terminalSkipped = filterTerminalSkip(pending, r.scope)
}
if len(pending) == 0 {
return &ResolveStats{PendingBefore: pendingBefore}
}
passStart := time.Now()
r.logger.Info("resolver: pass start",
zap.Int("pending", len(pending)),
zap.Int("terminal_skipped", terminalSkipped),
zap.Bool("backend_bulk", backendResolverEnabled()),
zap.String("shapes", pendingShapeSummary(pending)))
// Diagnostic: capture a CPU profile of the first full (unscoped) resolve
// pass when GORTEX_RESOLVE_CPUPROFILE names a path. Env-gated and one-shot
// so it never touches steady-state resolution.
if p := os.Getenv("GORTEX_RESOLVE_CPUPROFILE"); p != "" && len(r.scope) == 0 &&
resolveProfileStarted.CompareAndSwap(false, true) {
if f, err := os.Create(p); err == nil {
if pprof.StartCPUProfile(f) == nil {
defer pprof.StopCPUProfile()
}
}
}
var processed atomic.Int64
progressDone := make(chan struct{})
go func() {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-progressDone:
return
case <-t.C:
r.logger.Info("resolver: compute progress",
zap.Int64("processed", processed.Load()),
zap.Int("pending", len(pending)),
zap.Duration("elapsed", time.Since(passStart)))
}
}
}()
// Pre-warm the per-pass lookup cache. The resolver workers below
// will call store.GetNode for endpoints and store.FindNodesByName
// for resolution candidates — across 10-30k pending edges that's
// 100k+ individual queries on a disk backend
// (hundreds of seconds wall time). Collecting the
// IDs / names upfront and batch-loading them collapses those
// queries to ~10 chunked SELECT IN statements. Cleared on return
// via defer so callers outside ResolveAll see the empty caches and
// fall through to the underlying store on every lookup.
warmStart := time.Now()
r.warmLookupCache(pending)
defer r.clearLookupCache()
warmElapsed := time.Since(warmStart)
// Chunked compute + apply: process pending in super-chunks and release the
// resolve mutex between chunks so an interactive single-file edit can
// interleave instead of waiting out the whole pass. Each chunk's
// compute+apply runs under the lock (atomic, fresh reads); only the
// inter-chunk gap is unlocked, where the pass holds no partial graph state.
// resolveEdge mutates a clone, so the live edge is only written at the
// per-chunk apply, which skips an edge a yielded edit evicted (reindexing
// it half-resurrects it and can panic). Accumulated jobs/stats feed the
// post-resolve passes once, after the loop. GORTEX_RESOLVE_CHUNK=0 restores
// the whole-pass-locked path.
r.validateLiveness = resolveChunkEnabled()
// Bulk mode: the parallel workers below skip the synchronous per-edge LSP
// round-trip (see the bulkMode field) and instead collect the still-
// unresolved LSP-eligible edges into deferredLSP, which one deferred batch
// binds after the loop. Reset unconditionally on return so a leaked true
// can never disable inline LSP on a later single-file ResolveFile.
r.bulkMode = true
defer func() { r.bulkMode = false }()
var deferredLSP []deferredLSPEdge
superChunk := len(pending)
if r.validateLiveness {
if sz := resolveChunkSize(); sz < superChunk {
superChunk = sz
}
}
if superChunk < 1 {
superChunk = 1
}
var allJobs [][]reindexJob
var allStats []ResolveStats
reindexTotal := 0
for base := 0; base < len(pending); base += superChunk {
hi := base + superChunk
if hi > len(pending) {
hi = len(pending)
}
scPending := pending[base:hi]
workers := runtime.NumCPU()
if workers < 1 {
workers = 1
}
if workers > len(scPending) {
workers = len(scPending)
}
perWorkerStats := make([]ResolveStats, workers)
perWorkerJobs := make([][]reindexJob, workers)
perWorkerDeferred := make([][]deferredLSPEdge, workers)
var wg sync.WaitGroup
chunk := (len(scPending) + workers - 1) / workers
for w := 0; w < workers; w++ {
start := w * chunk
end := start + chunk
if end > len(scPending) {
end = len(scPending)
}
if start >= end {
continue
}
wg.Add(1)
go func(idx int, slice []*graph.Edge) {
defer wg.Done()
ws := &perWorkerStats[idx]
jobs := make([]reindexJob, 0, len(slice))
var deferred []deferredLSPEdge
for _, e := range slice {
// Capture LSP eligibility + the pre-heuristic identifier
// BEFORE resolveEdge runs: e.To is still the `unresolved::`
// stub here (the real edge is rewritten only in the apply
// phase below), so this sees the pre-heuristic target even
// for an edge the heuristic then confidently (mis)binds.
// Collecting EVERY LSP-eligible edge — not only the ones the
// heuristic leaves unresolved — is what preserves the LSP-
// first override the inline path applies: the post-loop batch
// re-binds via the type-aware helper, correcting a confident-
// but-wrong heuristic bind (see resolveDeferredLSP).
lspTarget, lspElig := r.lspDeferTarget(e)
clone := cloneEdgeForResolve(e)
oldTo, changed := r.resolveEdge(clone, ws)
processed.Add(1)
if changed {
// A now-resolved edge sheds any durable terminal-skip
// flag it carried (full self-healing pass): it has left
// the pending set, so a later scoped pass must not treat
// the flag as live. The cleared Meta rides the reindex
// below (To changed => the row is rewritten).
if !graph.IsUnresolvedTarget(clone.To) {
clearEdgeTerminal(clone)
}
jobs = append(jobs, reindexJob{
edge: e,
oldTo: oldTo,
newTo: clone.To,
kind: clone.Kind,
crossRepo: clone.CrossRepo,
confidence: clone.Confidence,
origin: clone.Origin,
meta: clone.Meta,
})
}
if lspElig {
// Bulk mode skipped the inline LSP round-trip; collect the
// edge for the post-loop deferred batch so the helper is
// consulted off the parallel worker barrier. Independent of
// `changed`: a heuristic-resolved edge is still deferred so
// LSP retains override authority.
deferred = append(deferred, deferredLSPEdge{edge: e, target: lspTarget})
}
releaseResolverClone(clone)
}
perWorkerJobs[idx] = jobs
perWorkerDeferred[idx] = deferred
}(w, scPending[start:end])
}
wg.Wait()
// Apply this chunk's mutations under the lock. An edit during a PRIOR
// inter-chunk yield may have evicted an edge this chunk resolved;
// reindexing it would half-resurrect it, so drop it (filter in place
// so allJobs carries only applied jobs for the post-passes).
reindexBatch := make([]graph.EdgeReindex, 0)
for i := range perWorkerJobs {
kept := perWorkerJobs[i][:0]
for _, j := range perWorkerJobs[i] {
if r.validateLiveness && !edgeStillLive(r.graph, j.edge) {
continue
}
j.edge.To = j.newTo
j.edge.Kind = j.kind
j.edge.CrossRepo = j.crossRepo
j.edge.Confidence = j.confidence
j.edge.Origin = j.origin
j.edge.Meta = j.meta
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: j.edge, OldTo: j.oldTo})
kept = append(kept, j)
}
perWorkerJobs[i] = kept
}
if len(reindexBatch) > 0 {
r.graph.ReindexEdges(reindexBatch)
reindexTotal += len(reindexBatch)
}
allJobs = append(allJobs, perWorkerJobs...)
allStats = append(allStats, perWorkerStats...)
for i := range perWorkerDeferred {
deferredLSP = append(deferredLSP, perWorkerDeferred[i]...)
}
// Hand the resolve mutex to any waiting interactive edit before the
// next chunk. Drop bulk mode across the hand-off so an interleaving
// single-file ResolveFile on a shared instance resolves LSP-first.
if r.validateLiveness && hi < len(pending) {
r.bulkMode = false
r.mu.Unlock()
runtime.Gosched()
r.mu.Lock()
r.bulkMode = true
}
}
close(progressDone)
loopElapsed := time.Since(passStart) - warmElapsed
// Deferred LSP batch: bind the still-unresolved LSP-eligible edges the
// parallel workers collected, now that the compute barrier is behind us.
// Runs before the tail attribution passes so external-call
// materialisation sees the LSP-resolved targets, exactly as the inline
// (non-bulk) path would.
lspDeferred := len(deferredLSP)
lspBatchResolved := 0
lspStart := time.Now()
if lspDeferred > 0 {
lspBatchResolved = r.resolveDeferredLSP(deferredLSP)
}
lspElapsed := time.Since(lspStart)
// Bulk mode covers only the parallel compute + the deferred LSP batch; the
// guard and tail attribution passes below run identically to the single-
// file path. (The deferred defer() is the panic-safety net.)
r.bulkMode = false
computeElapsed := time.Since(passStart)
r.logger.Info("resolver: compute done",
zap.Int("pending", len(pending)),
zap.Int("reindex_batch", reindexTotal),
zap.Int("super_chunk", superChunk),
zap.Int("lsp_deferred", lspDeferred),
zap.Int("lsp_batch_resolved", lspBatchResolved),
zap.Duration("warm_lookup", warmElapsed),
zap.Duration("compute_loop", loopElapsed),
zap.Duration("deferred_lsp", lspElapsed),
zap.Duration("elapsed", computeElapsed))
tailStart := time.Now()
// Cross-package name-match guard. The heuristic fallbacks above can
// resolve a call by name alone to a candidate in a package the
// caller never imports. Now that every EdgeImports edge in this pass
// is resolved, re-check each weak-tier call edge against the import
// closure and revert the ones whose target is unreachable. The
// closure is built once and shared; each job still carries its
// pre-resolution target so a reverted edge is restored exactly.
guarded := 0
// The guard consults the import-reachability closure only for the caller
// files of the jobs the compute loop resolved. On a scoped warm pass those
// jobs live in the changed repos (plus any repo whose bare-name call
// resolved in place), so build the closure for just those repos rather than
// scanning every file + import edge in the workspace — the entries for the
// queried callers stay byte-identical, so the guard's verdicts are
// unchanged. An empty scope builds the whole-graph closure.
if closure := r.importClosureForJobs(allJobs); len(closure) > 0 {
for i := range allJobs {
guarded += r.guardCrossPackageCallEdges(allJobs[i], closure)
}
}
tAfterGuard := time.Now()
// Post-resolution Go attribution passes: method-receiver rebind, bare-name
// and generic-param binding, builtin + external-call materialisation. Each
// pass carries its own rationale on its definition; the order is
// load-bearing (bare-name binding precedes builtin attribution so a local
// named `len` shadows the builtin). On a scoped warm restart the same five
// passes run per-file over just the changed repos: the per-file equivalents
// reproduce the whole-graph effect without the whole-graph index builds
// (scanning every KindLocal, every Go type) that dominate a warm restart,
// and unchanged repos are already in their post-full-resolve steady state,
// so their edges are no-ops here.
// Past a per-repo file budget the per-file dispatch's O(files) store round
// trips (plus the per-package type-index builds behind the receiver rebind)
// cost more than a single whole-graph streaming sweep. The two produce the
// identical edge set — the attribution passes are idempotent and re-running
// them over an unchanged repo is a no-op — so a large changed repo is routed
// through the streaming path instead of the per-file storm.
if len(r.scope) == 0 || r.scopedTailExceedsFileBudget() {
r.runFileAttributionPassesLocked()
} else {
for _, fp := range r.scopedFiles() {
r.runFileAttributionPassesForFileLocked(fp)
}
}
tAfterAttrib := time.Now()
// Relative-import resolution for Python and Dart files. Runs
// before module attribution so internal-target stems never get
// mis-mapped to a phantom pypi/pub package.
ldStart := time.Now()
r.resolveRelativeImports()
ld1 := time.Now()
// Lua / Luau `require(...)` binding. Same settle window as the relative
// imports above; resolveRelativeImports never touches Lua, so this lands
// the Lua module/instance requires onto their indexed file nodes.
r.resolveLuaRequires()
ld2 := time.Now()
// Razor / Blazor `@using` namespace-cascade binding. Same settle window;
// binds simple-type references reachable only via an imported namespace.
r.resolveRazorUsings()
ld3 := time.Now()
// Module attribution for ecosystems without a CGO type-checker
// path (Python, Dart, …). Runs serially on the post-resolution
// graph so it sees the final `external::*` set after the
// dep-module bridge has had its chance.
r.attributeNonGoModuleImports()
ld4 := time.Now()
// Java override-dispatch fan-out. An ambiguous member call on a
// supertype-typed receiver (`x.toString()` with two candidate
// overrides) stays unresolved after the cross-package guard reverts
// the name-only guess; this pass fans it out to every override in the
// hierarchy, the call-hierarchy semantics the language server presents.
// Runs after the guard so its ast_inferred edges are never reverted.
r.resolveJavaOverrideDispatch()
ld5 := time.Now()
// PHP dispatch resolution: bind ambiguous member/scoped calls the guard
// left unresolved via the class hierarchy — parent::/self:: up the extends
// chain, and interface/abstract/trait override families fanned out to
// every implementation. Same post-guard placement as the Java pass.
r.resolvePHPOverrideDispatch()
ld6 := time.Now()
// Diagnostic sub-phase breakdown of lang_dispatch_reconcile. Several of
// these passes independently EdgesByKind-scan the SAME kind (EdgeImports:
// relative_imports, lua_imports, razor_using, module_attribution all scan
// it) — this line exists to catch a future regression there, the same
// blind spot go_attribution's breakdown covers for its own six passes.
r.logger.Info("resolver: lang-dispatch sub-passes",
zap.Duration("relative_imports", ld1.Sub(ldStart)),
zap.Duration("lua_requires", ld2.Sub(ld1)),
zap.Duration("razor_usings", ld3.Sub(ld2)),
zap.Duration("nongo_module_imports", ld4.Sub(ld3)),
zap.Duration("java_override_dispatch", ld5.Sub(ld4)),
zap.Duration("php_override_dispatch", ld6.Sub(ld5)))
// Terminal-edge reconciliation: only a FULL (unscoped) pass has the global
// evidence to conclude an edge is permanently unbindable, so it durably
// stamps the newly-terminal edges and un-stamps any that regained a
// candidate. Gated to the master resolve via SetStampTerminal so a
// partially-indexed per-repo pass never stamps a false "no definition".
if r.stampTerminal && len(r.scope) == 0 {
stamped, unstamped := r.reconcileTerminalStamps()
if stamped > 0 || unstamped > 0 {
r.logger.Info("resolver: terminal stamps",
zap.Int("stamped", stamped),
zap.Int("unstamped", unstamped))
}
}
// Diagnostic sub-phase breakdown of the whole ResolveAll pass. The
// compute loop is parallel; the tail passes (guard, Go/lang attribution,
// dispatch, terminal reconcile) run serially under the resolve lock and
// are otherwise unlogged — this is the split used to target cold-index
// resolve optimisation.
r.logger.Info("resolver: pass complete",
zap.Duration("total", time.Since(passStart)),
zap.Duration("warm_lookup", warmElapsed),
zap.Duration("compute_loop", loopElapsed),
zap.Duration("deferred_lsp", lspElapsed),
zap.Duration("guard", tAfterGuard.Sub(tailStart)),
zap.Duration("go_attribution", tAfterAttrib.Sub(tAfterGuard)),
zap.Duration("lang_dispatch_reconcile", time.Since(tAfterAttrib)))
total := &ResolveStats{}
for i := range allStats {
total.Resolved += allStats[i].Resolved
total.Unresolved += allStats[i].Unresolved
total.External += allStats[i].External
}
// A guarded edge was counted as resolved by the fallback that
// produced it; reverting it moves the tally back to unresolved.
if guarded > 0 {
if total.Resolved >= guarded {
total.Resolved -= guarded
} else {
total.Resolved = 0
}
total.Unresolved += guarded
}
// Fold the deferred LSP batch into the pass total. Each edge was tallied
// Unresolved by the heuristic cascade that left it pending; binding it in
// the batch moves it back to Resolved, matching the non-bulk path where
// the inline LSP win would have counted a Resolved and no Unresolved.
if lspBatchResolved > 0 {
total.Resolved += lspBatchResolved
if total.Unresolved >= lspBatchResolved {
total.Unresolved -= lspBatchResolved
} else {
total.Unresolved = 0
}
}
total.PendingBefore = pendingBefore
total.PendingAfter = len(pending)
return total
}
// filterPendingByScope keeps only the pending edges a scoped ResolveAll must
// reconsider for the given changed-repo set. It is a conservative superset:
// an unchanged repo's own edges that are already resolved never appear in
// EdgesWithUnresolvedTarget, and the ones that remain unresolved there stay
// unresolved whether or not this pass reconsiders them — so dropping them is
// a pure work saving with no effect on the final resolved edge set. Filters
// in place; the returned slice reuses pending's backing array.
func filterPendingByScope(pending []*graph.Edge, scope map[string]struct{}) []*graph.Edge {
out := pending[:0]
for _, e := range pending {
if e == nil {
continue
}
if edgeInResolveScope(e, scope) {
out = append(out, e)
}
}
return out
}
// edgeInResolveScope reports whether a scoped ResolveAll pass must reconsider
// a pending edge. An edge is in scope when any of three rules hold:
//
// (a) it originates in a changed repo (its source could re-target),
// (b) its unresolved target is repo-qualified to a changed repo, or
// (c) its target is a bare, unqualified unresolved::Name — which could
// newly bind into any changed repo, so it is always reconsidered.
//
// Everything else — an edge from an unchanged repo whose target is
// repo-qualified to another unchanged repo — is excluded.
func edgeInResolveScope(e *graph.Edge, scope map[string]struct{}) bool {
// (a) Source repo is in scope.
if _, ok := scope[graph.RepoPrefixOfID(e.From)]; ok {
return true
}
// Repo prefix the target is pinned to, over both the unresolved
// (`<repo>::unresolved::Name`) and the general stub (`<repo>::kind::…`)
// encodings. Never a literal HasPrefix check — the helpers normalise the
// bare and repo-qualified forms.
targetRepo := graph.UnresolvedRepoPrefix(e.To)
if targetRepo == "" {
targetRepo = graph.StubRepoPrefix(e.To)
}
if targetRepo == "" {
// (c) Bare, unqualified unresolved::Name — could resolve anywhere.
return true
}
// (b) Target repo-qualified to a changed repo.
_, ok := scope[targetRepo]
return ok
}
// edgeFromInScope reports whether an edge's source repo is within the active
// resolve scope. An empty scope (whole-graph resolve) returns true for every
// edge, so a scoped tail pass degenerates to today's behaviour. Backs the
// post-resolve passes that have no per-file sibling: an unchanged repo's edges
// are already in their post-full-resolve steady state, so re-running these
// passes over them is a no-op and skipping them is a pure work saving.
func (r *Resolver) edgeFromInScope(from string) bool {
if len(r.scope) == 0 {
return true
}
_, ok := r.scope[graph.RepoPrefixOfID(from)]
return ok
}
// scopedTailFileBudget bounds how many files a single changed repo may hold
// before the scoped per-file attribution tail is abandoned for the whole-graph
// streaming passes. Above it the per-file store round trips dominate one
// streaming sweep, so a large changed repo among small siblings would otherwise
// make the "incremental" warm restart's pre-ready phase slower than a full one.
// A var (not const) so tests can drive both branches on small fixtures.
var scopedTailFileBudget = 2000
// scopedTailExceedsFileBudget reports whether any repo in the active scope holds
// more KindFile nodes than scopedTailFileBudget. It reads the already-built
// dirIndex (no extra store materialization) and early-returns as soon as one
// repo crosses the budget, so a large changed repo never pays the per-file
// dispatch's O(files) query storm just to discover it should have streamed.
// Callers gate on len(r.scope) > 0 first.
func (r *Resolver) scopedTailExceedsFileBudget() bool {
perRepo := make(map[string]int, len(r.scope))
for _, nodes := range r.dirIndex {
for _, n := range nodes {
if n == nil {
continue
}
prefix := n.RepoPrefix
if prefix == "" {
prefix = graph.RepoPrefixOfID(n.ID)
}
if _, ok := r.scope[prefix]; !ok {
continue
}
perRepo[prefix]++
if perRepo[prefix] > scopedTailFileBudget {
return true
}
}
}
return false
}
// scopedFiles returns the file paths of every KindFile node owned by a repo in
// the active resolve scope. Callers gate on len(r.scope) > 0 first, so an empty
// scope yields nothing. Backs the per-file dispatch of the post-resolve Go
// attribution passes on a scoped warm restart.
func (r *Resolver) scopedFiles() []string {
var files []string
for prefix := range r.scope {
for _, n := range r.graph.GetRepoNodes(prefix) {
if n != nil && n.Kind == graph.KindFile && n.FilePath != "" {
files = append(files, n.FilePath)
}
}
}
return files
}
// importClosureForJobs builds the import-reachability closure the cross-package
// guard consults. On an unscoped pass it is the whole-graph closure. On a
// scoped pass it is restricted to the repos that own the resolved jobs' caller
// nodes — the only files the guard ever queries — which keeps the closure
// entries for those callers byte-identical to the whole-graph build while
// skipping the unchanged workspace's file + import scan.
func (r *Resolver) importClosureForJobs(allJobs [][]reindexJob) map[string]map[string]struct{} {
if len(r.scope) == 0 {
return r.buildImportClosure()
}
repos := make(map[string]struct{})
for i := range allJobs {
for j := range allJobs[i] {
if e := allJobs[i][j].edge; e != nil {
repos[graph.RepoPrefixOfID(e.From)] = struct{}{}
}
}
}
return r.buildImportClosureFiltered(repos)
}
// buildDirIndexes builds two lookup maps for resolveImport. Populated
// once per ResolveAll / ResolveFile pass and torn down after.
//
// - dirIndex keys on filepath.Dir(file.FilePath) for exact
// importPath == dir matches.
// - lastDirIndex keys on the last path component of that directory
// so an import of "logger" matches any file under .../logger/.
func (r *Resolver) buildDirIndexes() {
r.dirIndex = make(map[string][]*graph.Node, 128)
r.lastDirIndex = make(map[string][]*graph.Node, 128)
// NodesByKind pushes the file-kind filter into the store; disk
// backends iterate just the file nodes instead of every node.
for n := range r.graph.NodesByKind(graph.KindFile) {
dir := filepath.Dir(n.FilePath)
r.dirIndex[dir] = append(r.dirIndex[dir], n)
last := lastPathComponent(dir)
if last != "" && last != dir {
r.lastDirIndex[last] = append(r.lastDirIndex[last], n)
}
}
}
func (r *Resolver) clearDirIndexes() {
r.dirIndex = nil
r.lastDirIndex = nil
r.receiverTypeIdxByDir = nil
}
// warmLookupCache batches the per-edge GetNode / FindNodesByName
// queries the worker loop would otherwise fire serially. We collect
// every From/To node ID across the pending slice and the bare
// identifier name embedded in each `unresolved::*` target, then issue
// the two batched queries the Store exposes. Workers consult the
// resulting maps via cachedGetNode / cachedFindNodesByName; misses
// fall through to the underlying store.
func (r *Resolver) warmLookupCache(pending []*graph.Edge) {
if len(pending) == 0 {
return
}
warmStart := time.Now()
idSet := make(map[string]struct{}, len(pending)*2)
nameSet := make(map[string]struct{}, len(pending))
qualNameSet := make(map[string]struct{})
for _, e := range pending {
if e == nil {
continue
}
if e.From != "" {
idSet[e.From] = struct{}{}
}
// e.To still carries the "unresolved::" (or multi-repo
// "<repoPrefix>::unresolved::") prefix. Strip it with
// UnresolvedName, then reduce to the bare identifier the cascade
// resolvers actually look up ("*.m" -> "m", "extern::p::S" ->
// "S"). Seeding the embedded identifier — NOT the raw stub id,
// which matches no node — is what lets the worker's
// cachedFindNodesByName(InRepo) HIT instead of firing one
// FindNodesByName(InRepo) query per edge (the warmup storm).
if name := identifierFromTarget(graph.UnresolvedName(e.To)); name != "" {
nameSet[name] = struct{}{}
}
// Receiver types drive the method/field disambiguation passes
// (receiverIsInterface, same-receiver field/method preference);
// seed them too so those lookups hit the cache (or its
// authoritative negative) instead of falling through to a
// per-edge FindNodesByName.
if rt := edgeReceiverType(e); rt != "" {
nameSet[rt] = struct{}{}
}
// Import targets resolve by qualified name: resolveImport's first
// lookup is GetNodeByQualName(importPath), an unindexed scan per
// import edge on a disk backend. Seed the import path so it hits the
// qual-name cache (or its authoritative negative) instead.
if t := graph.UnresolvedName(e.To); strings.HasPrefix(t, "import::") {
if qn := strings.TrimPrefix(t, "import::"); qn != "" {
qualNameSet[qn] = struct{}{}
}
}
}
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
names := make([]string, 0, len(nameSet))
for n := range nameSet {
names = append(names, n)
}
// Both the id and name key sets run to ~0.5M keys on a cold warmup, and
// each backed store call was a single serial round trip before the first
// progress tick — the silent 3-6s start-up gap. Split each into per-CPU
// batches issued concurrently: both graph backends serve concurrent point
// reads safely (in-memory takes per-shard RLocks; sqlite pools NumCPU WAL
// reader connections), which is the same property the resolver's worker
// fan-out below already depends on.
r.nodeByID = r.parallelGetNodesByIDs(ids)
r.nodesByName = r.parallelFindNodesByNames(names)
// Authoritative negatives: a name we queried that has NO node in the
// graph (stdlib / external method calls — *.QueryRow, *.Errorf,
// *.Fatalf, *.StringVar, … — dominate the pending set) must be
// recorded as an empty result, not left absent. Absence means "not
// pre-warmed" so the cached lookup falls through to a per-edge
// FindNodesByName scan of the unindexed name column; across 200k+
// external-method stubs that fall-through IS the warmup hang.
// Backfilling the negative makes the pre-warmed name set
// authoritative — the lookup returns empty without touching the store.
if r.nodesByName == nil {
r.nodesByName = make(map[string][]*graph.Node, len(nameSet))
}
for n := range nameSet {
if _, ok := r.nodesByName[n]; !ok {
r.nodesByName[n] = nil
}
}
// Fold every candidate node returned by the name lookup into the
// id cache too: when a worker picks a candidate and the
// downstream guard (cross_pkg / cross_repo) calls GetNode on the
// chosen target, the cache should hit instead of falling through
// to a per-id store call.
if r.nodeByID == nil && len(r.nodesByName) > 0 {
r.nodeByID = make(map[string]*graph.Node, len(r.nodesByName))
}
for _, hits := range r.nodesByName {
for _, n := range hits {
if n == nil || n.ID == "" {
continue
}
if _, ok := r.nodeByID[n.ID]; !ok {
r.nodeByID[n.ID] = n
}
}
}
// Pre-warm the import qual-name cache + record authoritative negatives,
// so resolveImport's GetNodeByQualName hits the cache instead of
// scanning the unindexed qual_name column once per import edge.
if len(qualNameSet) > 0 {
qns := make([]string, 0, len(qualNameSet))
for q := range qualNameSet {
qns = append(qns, q)
}
r.nodesByQualName = r.graph.GetNodesByQualNames(qns)
if r.nodesByQualName == nil {
r.nodesByQualName = make(map[string]*graph.Node, len(qualNameSet))
}
for q := range qualNameSet {
if _, ok := r.nodesByQualName[q]; !ok {
r.nodesByQualName[q] = nil
}
}
}
// Make the previously-silent warm phase observable — the batched store
// reads over ~0.5M keys land before the first compute progress tick.
r.logger.Info("resolver: warm lookup cache",
zap.Int("ids", len(ids)),
zap.Int("names", len(names)),
zap.Int("qual_names", len(qualNameSet)),
zap.Duration("elapsed", time.Since(warmStart)))
}
// parallelGetNodesByIDs is the concurrent form of Store.GetNodesByIDs used to
// pre-warm the resolver's per-pass id cache: it splits ids into up to NumCPU
// batches issued on their own goroutines and merges the per-batch maps. ids is
// already deduped by warmLookupCache, so a key lands in exactly one batch and
// the merge never has to reconcile collisions. Small inputs fall through to a
// single call, where the goroutine + merge overhead would dominate.
func (r *Resolver) parallelGetNodesByIDs(ids []string) map[string]*graph.Node {
batches := lookupWarmBatches(len(ids))
if batches <= 1 {
return r.graph.GetNodesByIDs(ids)
}
parts := make([]map[string]*graph.Node, batches)
chunk := (len(ids) + batches - 1) / batches
var wg sync.WaitGroup
for b := 0; b < batches; b++ {
start := b * chunk
end := start + chunk
if end > len(ids) {
end = len(ids)
}
if start >= end {
continue
}
wg.Add(1)
go func(bi int, sub []string) {
defer wg.Done()
parts[bi] = r.graph.GetNodesByIDs(sub)
}(b, ids[start:end])
}
wg.Wait()
out := make(map[string]*graph.Node, len(ids))
for _, m := range parts {
for k, v := range m {
out[k] = v
}
}
return out
}
// parallelFindNodesByNames is the concurrent form of Store.FindNodesByNames.
// names is deduped by warmLookupCache, so each name resolves in exactly one
// batch and the merge is a plain key copy (no per-name slice concat across
// batches). Small inputs fall through to a single call.
func (r *Resolver) parallelFindNodesByNames(names []string) map[string][]*graph.Node {
batches := lookupWarmBatches(len(names))
if batches <= 1 {
return r.graph.FindNodesByNames(names)
}
parts := make([]map[string][]*graph.Node, batches)
chunk := (len(names) + batches - 1) / batches
var wg sync.WaitGroup
for b := 0; b < batches; b++ {
start := b * chunk
end := start + chunk
if end > len(names) {
end = len(names)
}
if start >= end {
continue
}
wg.Add(1)
go func(bi int, sub []string) {
defer wg.Done()
parts[bi] = r.graph.FindNodesByNames(sub)
}(b, names[start:end])
}
wg.Wait()
out := make(map[string][]*graph.Node, len(names))
for _, m := range parts {
for k, v := range m {
out[k] = v
}
}
return out
}
// lookupWarmBatches picks the goroutine count for the parallel warm-cache
// helpers: one per minLookupWarmBatch keys, capped at NumCPU, and 1 (serial)
// below the threshold so tiny key sets skip the fan-out overhead entirely.
func lookupWarmBatches(n int) int {
const minLookupWarmBatch = 4096
if n <= minLookupWarmBatch {
return 1
}
batches := (n + minLookupWarmBatch - 1) / minLookupWarmBatch
if cpus := runtime.NumCPU(); batches > cpus {
batches = cpus
}
if batches < 1 {
batches = 1
}
return batches
}
func (r *Resolver) clearLookupCache() {
r.nodeByID = nil
r.nodesByName = nil
r.nodesByQualName = nil
r.importFilesMu.Lock()
r.importFilesByCaller = nil
r.importFilesMu.Unlock()
}
// cachedGetNode returns the node for id, consulting the per-pass
// lookup cache first and falling through to the underlying store on
// miss. The cache is a positive-only fast path — absence means "not
// pre-warmed", not "doesn't exist", so a miss still asks the store.
// Outside a ResolveAll pass the cache is nil and every call goes
// straight to the store.
func (r *Resolver) cachedGetNode(id string) *graph.Node {
if id == "" {
return nil
}
if r.nodeByID != nil {
if n, ok := r.nodeByID[id]; ok {
return n
}
}
return r.graph.GetNode(id)
}
// cachedFindNodesByName returns the candidates for name, consulting
// the per-pass cache first and falling through to the store on miss.
// Returns the in-cache slice directly when hit — callers MUST treat
// the result as read-only.
func (r *Resolver) cachedFindNodesByName(name string) []*graph.Node {
if name == "" {
return nil
}
if r.nodesByName != nil {
if hits, ok := r.nodesByName[name]; ok {
return hits
}
}
return r.graph.FindNodesByName(name)
}
// cachedGetNodeByQualName serves resolveImport's qual-name lookup from the
// per-pass cache. A pre-warmed qual_name with no node returns nil
// (authoritative negative — most import paths have no matching package
// node, and the unindexed per-edge GetNodeByQualName scan for them was a
// cold-warmup compute storm); a qual_name absent from the cache falls
// through to the store.
func (r *Resolver) cachedGetNodeByQualName(qualName string) *graph.Node {
if qualName == "" {
return nil
}
if r.nodesByQualName != nil {
if n, ok := r.nodesByQualName[qualName]; ok {
return n
}
}
return r.graph.GetNodeByQualName(qualName)
}
// cachedFindNodesByNameInRepo is the repo-scoped twin of
// cachedFindNodesByName: name-matched candidates whose RepoPrefix == repo,
// served from the per-pass name cache (filtered in Go) so the
// method/function/type/field cascade doesn't fire one
// FindNodesByNameInRepo query per pending edge — the warmup storm that
// the multi-repo prefixed-stub population (100k+ edges) turned into a
// hang. Falls through to the store on a cache miss, preserving
// correctness; the cache is positive-only (absence means "not
// pre-warmed", not "doesn't exist").
func (r *Resolver) cachedFindNodesByNameInRepo(name, repo string) []*graph.Node {
if name == "" {
return nil
}
if r.nodesByName != nil {
if hits, ok := r.nodesByName[name]; ok {
// Count repo matches before allocating. When every hit is in
// repo -- always so in single-repo mode, and common otherwise --
// hand back the cached slice (capped so a caller's append cannot
// scribble into the cache) instead of copying it. The per-call
// copy + growslice here was the largest allocation source during
// resolution on a large index; callers treat the result as
// read-only.
match := 0
for _, n := range hits {
if n != nil && n.RepoPrefix == repo {
match++
}
}
switch {
case match == 0:
return nil
case match == len(hits):
return hits[:len(hits):len(hits)]
}
out := make([]*graph.Node, 0, match)
for _, n := range hits {
if n != nil && n.RepoPrefix == repo {
out = append(out, n)
}
}
return out
}
}
return r.graph.FindNodesByNameInRepo(name, repo)
}
// buildDepModuleIndex collects every dep::<module-path> contract node
// (one per non-indirect `require` line in a tracked go.mod) and groups
// them by the owning repo's prefix so resolveImport can bridge a Go
// import to the dep node it satisfies. Entries are sorted by
// modulePath length descending, which keeps longest-prefix-wins for
// nested modules (e.g. importing "github.com/aws/aws-sdk-go-v2/service/s3"
// must hit the s3 dep, not the parent aws-sdk-go-v2 dep).
//
// Skips dep IDs of the form `dep::<repoName>::<shortName>`, which
// GoModExtractor emits when the dependency is itself a tracked sibling
// repo — those resolve through the cross-repo file graph instead and
// have no module path embedded in the ID.
func (r *Resolver) buildDepModuleIndex() {
by := make(map[string][]depModuleEntry)
for n := range r.graph.NodesByKind(graph.KindContract) {
if !strings.HasPrefix(n.ID, "dep::") {
continue
}
mp := strings.TrimPrefix(n.ID, "dep::")
if mp == "" || strings.Contains(mp, "::") {
continue
}
by[n.RepoPrefix] = append(by[n.RepoPrefix], depModuleEntry{
modulePath: mp,
node: n,
})
}
for k := range by {
entries := by[k]
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].modulePath) > len(entries[j].modulePath)
})
}
r.depModuleIndex = by
}
func (r *Resolver) clearDepModuleIndex() {
r.depModuleIndex = nil
}
// lookupDepModule returns the dep::<module> contract node whose
// module path is a prefix of importPath, scoped to the caller's repo.
// Returns nil if no dep declaration covers this import.
func (r *Resolver) lookupDepModule(callerRepo, importPath string) *graph.Node {
for _, entry := range r.depModuleIndex[callerRepo] {
if importPath == entry.modulePath || strings.HasPrefix(importPath, entry.modulePath+"/") {
return entry.node
}
}
return nil
}
// buildPassIndexes builds the four per-pass lookup indexes every
// resolve pass needs and returns the matching teardown (which also
// drops the lazily-built LSP index). Factored so entry points that
// run several passes under one lock — the per-save ResolveFile +
// ResolveIncomingForFile pair — build them once instead of once per
// pass.
func (r *Resolver) buildPassIndexes() (clear func()) {
r.buildDirIndexes()
r.buildDepModuleIndex()
r.buildProvidesForIndex()
r.buildReachabilityIndex()
return func() {
r.clearDirIndexes()
r.clearDepModuleIndex()
r.clearProvidesForIndex()
r.clearReachabilityIndex()
r.clearLSPIndex()
}
}
// ResolveFile resolves unresolved edges originating from a specific file.
func (r *Resolver) ResolveFile(filePath string) *ResolveStats {
r.mu.Lock()
defer r.mu.Unlock()
clear := r.buildPassIndexes()
defer clear()
stats := &ResolveStats{}
r.resolveFileLocked(filePath, stats)
return stats
}
// ResolveFileAndIncoming runs the forward (this file's outgoing
// references) and reverse (other files' references to symbols defined
// here) passes under one lock with one build of the per-pass indexes.
// The per-save hot path calls this instead of ResolveFile +
// ResolveIncomingForFile back-to-back, which built and tore down the
// same four indexes twice per save.
func (r *Resolver) ResolveFileAndIncoming(filePath string) *ResolveStats {
r.mu.Lock()
defer r.mu.Unlock()
clear := r.buildPassIndexes()
defer clear()
// Warm the per-edge lookup cache for this file's pending forward and
// incoming edges. Without it the single-file path fires a fresh
// FindNodesByNameInRepo — a scanNode + meta-decode of every same-name
// candidate — once PER edge, re-materialising the same candidates for
// every edge that shares a name. Seeding the cache once (one batched
// FindNodesByNames, like ResolveAll) materialises each candidate once
// and the passes read it from memory.
r.warmLookupCache(r.pendingEdgesForFileAndIncoming(filePath))
defer r.clearLookupCache()
stats := &ResolveStats{}
r.resolveFileLocked(filePath, stats)
r.resolveIncomingLocked(filePath, stats)
return stats
}
// pendingEdgesForFileAndIncoming gathers the unresolved edges the forward
// and reverse passes will visit — the file's own outgoing unresolved
// edges plus the unresolved in-edges parked on the stub ids of the
// referenceable symbols this file defines. It mirrors the edge walks
// resolveFileEdgesLocked / resolveIncomingLocked perform, but only to seed
// warmLookupCache; the result feeds caching, never resolution directly.
func (r *Resolver) pendingEdgesForFileAndIncoming(filePath string) []*graph.Edge {
defNodes := r.graph.GetFileNodes(filePath)
var pending []*graph.Edge
seenNames := make(map[string]struct{}, len(defNodes))
defIDs := make([]string, 0, len(defNodes))
for _, n := range defNodes {
if n != nil {
defIDs = append(defIDs, n.ID)
}
}
outByNode := graph.OutEdgesForNodes(r.graph, defIDs)
for _, n := range defNodes {
if n == nil {
continue
}
for _, e := range outByNode[n.ID] {
if graph.IsUnresolvedTarget(e.To) && !r.incrementalSkipped(e) {
pending = append(pending, e)
}
}
if n.Name == "" || !graph.IsReferenceableSymbol(n.Kind) {
continue
}
if _, dup := seenNames[n.Name]; dup {
continue
}
seenNames[n.Name] = struct{}{}
keys := []string{graph.UnresolvedMarker + n.Name}
if n.RepoPrefix != "" {
keys = append(keys, n.RepoPrefix+"::"+graph.UnresolvedMarker+n.Name)
}
for _, key := range keys {
for _, e := range r.graph.GetInEdges(key) {
if graph.IsUnresolvedTarget(e.To) {
pending = append(pending, e)
}
}
}
}
return pending
}
// ResolveFilesAndIncoming runs the forward and reverse passes for a
// batch of files under one lock, one build of the per-pass indexes, and
// one run of the attribution passes. The affected-by re-resolution path
// uses this: calling ResolveFileAndIncoming per file would rebuild the
// four pass indexes and re-run the whole-graph attribution sweeps once
// per file, turning a bounded fan-out into N whole-graph passes.
func (r *Resolver) ResolveFilesAndIncoming(filePaths []string) *ResolveStats {
stats := &ResolveStats{}
if len(filePaths) == 0 {
return stats
}
r.mu.Lock()
defer r.mu.Unlock()
clear := r.buildPassIndexes()
defer clear()
for _, p := range filePaths {
r.resolveFileEdgesLocked(p, stats)
r.resolveIncomingLocked(p, stats)
}
r.runFileAttributionPassesLocked()
return stats
}
// resolveFileLocked is the forward-pass core. Caller holds r.mu and
// has built the per-pass indexes.
func (r *Resolver) resolveFileLocked(filePath string, stats *ResolveStats) {
r.resolveFileEdgesLocked(filePath, stats)
r.runFileAttributionPassesForFileLocked(filePath)
}
// fileOutEdges returns every outgoing edge of every node defined in
// filePath — the scope a single-file attribution pass needs in place of
// a whole-graph EdgesByKind sweep. Builtin / external / bare-name
// attributions all act on edges whose source is inside the edited file,
// so this is the complete candidate set for those passes.
func (r *Resolver) fileOutEdges(filePath string) []*graph.Edge {
nodes := r.graph.GetFileNodes(filePath)
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
}
byNode := graph.OutEdgesForNodes(r.graph, ids)
var out []*graph.Edge
for _, n := range nodes {
out = append(out, byNode[n.ID]...)
}
return out
}
// resolveFileEdgesLocked walks one file's outgoing unresolved edges and
// binds them, without the attribution tail — batch callers run the
// attribution passes once after the whole batch instead of once per
// file. Caller holds r.mu and has built the per-pass indexes.
func (r *Resolver) resolveFileEdgesLocked(filePath string, stats *ResolveStats) {
// Get all nodes in the file, then check their outgoing edges.
// Single-threaded path — collect mutations into a batch and flush
// in one ReindexEdges call after the file's edges are walked, so a
// per-file ResolveFile pass produces one Tx commit on disk
// backends instead of one per resolved edge. Resolved edges are
// also recorded as jobs so the cross-package guard can re-check
// (and, if needed, revert) the weak-tier ones.
var jobs []reindexJob
var reindexBatch []graph.EdgeReindex
nodes := r.graph.GetFileNodes(filePath)
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
}
byNode := graph.OutEdgesForNodes(r.graph, ids)
for _, n := range nodes {
edges := byNode[n.ID]
for _, e := range edges {
if !graph.IsUnresolvedTarget(e.To) {
continue
}
// Carry-over reference unchanged since the last resolve and
// already unresolved then — leave it for the incoming pass
// instead of re-running the cascade (the bulk of edit latency
// on reference-heavy files).
if r.incrementalSkipped(e) {
continue
}
oldTo, changed := r.resolveEdge(e, stats)
if changed {
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
jobs = append(jobs, reindexJob{
edge: e,
oldTo: oldTo,
newTo: e.To,
kind: e.Kind,
confidence: e.Confidence,
origin: e.Origin,
})
}
}
}
if len(reindexBatch) > 0 {
r.graph.ReindexEdges(reindexBatch)
}
// Cross-package name-match guard — same contract as in ResolveAll.
if len(jobs) > 0 {
if closure := r.buildImportClosure(); len(closure) > 0 {
if guarded := r.guardCrossPackageCallEdges(jobs, closure); guarded > 0 {
if stats.Resolved >= guarded {
stats.Resolved -= guarded
} else {
stats.Resolved = 0
}
stats.Unresolved += guarded
}
}
}
}
// runFileAttributionPassesLocked re-runs the attribution passes that
// ResolveAll runs. The per-file resolve paths handle incremental
// updates — a re-parse of one file emits fresh `unresolved::<name>`
// edges that haven't been seen by these passes yet, so without
// re-running them the incremental graph diverges from a cold re-index
// (caught by TestIncrementalReindex_ConvergesToFullIndex). Each pass is
// idempotent on already-rewritten edges (the `unresolved::` prefix
// check makes a second sweep a no-op). Caller holds r.mu.
func (r *Resolver) runFileAttributionPassesLocked() {
t0 := time.Now()
r.rebindGoMethodReceivers()
t1 := time.Now()
r.bindBareNameScopeRefs()
t2 := time.Now()
r.bindDataflowCalleeRefs()
t3 := time.Now()
r.bindGenericParamRefs()
t4 := time.Now()
r.attributeGoBuiltins()
t5 := time.Now()
r.attributeGoExternalCalls()
t6 := time.Now()
// Diagnostic sub-phase breakdown of the whole-graph attribution sweep,
// mirroring the framework-synthesizer per-pass timing — go_attribution
// was previously one opaque duration in "resolver: pass complete", so a
// future single-pass regression here had no per-pass breadcrumb.
r.logger.Info("resolver: attribution sub-passes",
zap.Duration("rebind_go_method_receivers", t1.Sub(t0)),
zap.Duration("bind_bare_name_scope_refs", t2.Sub(t1)),
zap.Duration("bind_dataflow_callee_refs", t3.Sub(t2)),
zap.Duration("bind_generic_param_refs", t4.Sub(t3)),
zap.Duration("attribute_go_builtins", t5.Sub(t4)),
zap.Duration("attribute_go_external_calls", t6.Sub(t5)))
}
// runFileAttributionPassesForFileLocked is the single-file equivalent of
// runFileAttributionPassesLocked. Builtin / external-call / bare-name
// attribution only ever rewrite edges originating in the edited file, so
// they run over that file's outgoing edges instead of sweeping the whole
// graph once per save — the dominant per-edit resolver cost on a large
// graph. The two passes that genuinely need cross-file context (method-
// receiver rebind reads the package's type index; generic-param binding)
// stay whole-graph; both are already batched and cheap. The pass ORDER
// matches runFileAttributionPassesLocked: bare-name binding runs before
// builtin attribution so a local named `len` shadows the builtin.
func (r *Resolver) runFileAttributionPassesForFileLocked(filePath string) {
r.rebindGoMethodReceiversForFile(filePath)
r.bindBareNameScopeRefsForFile(filePath)
r.bindDataflowCalleeRefsForFile(filePath)
r.bindGenericParamRefsForFile(filePath)
r.attributeGoBuiltinsForFile(filePath)
r.attributeGoExternalCallsForFile(filePath)
}
// ResolveIncomingForFile is the reverse of ResolveFile: instead of
// resolving the file's own OUTGOING references, it binds pending
// `unresolved::<Name>` edges in OTHER files that reference a symbol
// (re)defined in this file. After a definition is added or re-indexed,
// callers elsewhere still point at an unresolved stub — either one
// emitted at their own extraction time, or one restubIncomingRefs
// re-created when this file's prior concrete node was evicted. This
// rebinds them, scoped to this file's symbol names, so it costs
// O(references to those names), not a whole-graph ResolveAll. It uses
// the same reachability / import gates as ResolveFile (via resolveEdge),
// so an ambiguous name binds no differently and unsafe matches stay
// pending for the periodic ResolveAll.
func (r *Resolver) ResolveIncomingForFile(filePath string) *ResolveStats {
r.mu.Lock()
defer r.mu.Unlock()
clear := r.buildPassIndexes()
defer clear()
stats := &ResolveStats{}
r.resolveIncomingLocked(filePath, stats)
return stats
}
// resolveIncomingLocked is the core of the reverse pass. Caller holds
// r.mu and has built the per-pass indexes. For each distinct
// referenceable symbol name defined in filePath it looks up the pending
// edges parked under that name's unresolved-stub id — GetInEdges keyed
// by the `unresolved::<Name>` target, so no new index is needed: the
// stub id IS the in-edge bucket key — and runs the normal per-edge
// resolution against them. Both the bare and the `<repoPrefix>::`
// multi-repo stub forms are probed.
func (r *Resolver) resolveIncomingLocked(filePath string, stats *ResolveStats) {
defNodes := r.graph.GetFileNodes(filePath)
if len(defNodes) == 0 {
return
}
seen := make(map[string]struct{}, len(defNodes))
var stubKeys []string
for _, n := range defNodes {
if n == nil || n.Name == "" || !graph.IsReferenceableSymbol(n.Kind) {
continue
}
if _, dup := seen[n.Name]; dup {
continue
}
seen[n.Name] = struct{}{}
stubKeys = append(stubKeys, graph.UnresolvedMarker+n.Name)
if n.RepoPrefix != "" {
stubKeys = append(stubKeys, n.RepoPrefix+"::"+graph.UnresolvedMarker+n.Name)
}
}
if len(stubKeys) == 0 {
return
}
var reindexBatch []graph.EdgeReindex
var jobs []reindexJob
for _, key := range stubKeys {
for _, e := range r.graph.GetInEdges(key) {
if e == nil || !graph.IsUnresolvedTarget(e.To) {
continue
}
oldTo, changed := r.resolveEdge(e, stats)
// Restore the provenance the restub stashed when the stub rebound
// to the same target it had before the re-parse (unchanged binding
// keeps its verified tier); always drops the transient stash keys,
// so a rebind elsewhere / a still-unresolved stub carries only the
// honest tier the resolver just assigned. Runs before the job is
// captured so the cross-package guard below sees the real origin.
restored := graph.RestoreRestubProvenance(e)
switch {
case changed:
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
jobs = append(jobs, reindexJob{
edge: e,
oldTo: oldTo,
newTo: e.To,
kind: e.Kind,
confidence: e.Confidence,
origin: e.Origin,
})
case restored:
// The stub rebound to the SAME concrete target it already
// carried, so resolveEdge reported no To-change — but the
// restore mutated Origin/Tier/Confidence/Meta in place. A disk
// backend's GetInEdges handed us a freshly-decoded edge pointer,
// so that in-place mutation is lost unless it is written back.
// Re-index against the edge's own (unchanged) key to persist the
// restored provenance. Kept out of the cross-package guard's job
// list: its origin is the verified tier the restore re-applied,
// not a fresh name-only guess to second-guess.
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: e.To})
}
}
}
if len(reindexBatch) > 0 {
r.graph.ReindexEdges(reindexBatch)
}
// Same cross-package name-match guard ResolveFile applies: revert a
// weak-tier call edge whose freshly-bound target lives in a package
// the caller never imports.
if len(jobs) > 0 {
if closure := r.buildImportClosure(); len(closure) > 0 {
if guarded := r.guardCrossPackageCallEdges(jobs, closure); guarded > 0 {
if stats.Resolved >= guarded {
stats.Resolved -= guarded
} else {
stats.Resolved = 0
}
stats.Unresolved += guarded
}
}
}
}
// reindexJob captures the resolved state for an edge whose target
// changed during a parallel resolution pass.
//
// Workers operate on shallow clones of each edge (cloneEdgeForResolve
// below) so mutating helpers can write to the clone freely without
// racing with: (a) other workers reading neighbouring edges' fields
// during bucket maintenance, or (b) the serial post-pass that reads
// each edge's To via keyOf. Once the worker phase completes, the
// resolved fields (To, Kind, CrossRepo, Confidence, Origin, Meta) are
// copied onto the real edge and graph.ReindexEdge is called — both
// serially.
//
// Kind is propagated because resolveEdge may promote it after
// resolution (e.g. `*.foo` with EdgeReads that lands on a method gets
// promoted to EdgeReferences so get_callers / find_usages surface the
// method-value reference).
type reindexJob struct {
edge *graph.Edge
oldTo string
newTo string
kind graph.EdgeKind
crossRepo bool
confidence float64
origin string
meta map[string]any
}
// resolverClonePool recycles the *graph.Edge shells handed out by
// cloneEdgeForResolve. The clone is per-iteration garbage in the
// ResolveAll worker — Get / Put across the inner loop turns the per-
// edge alloc into pool churn. Profile #4 (post lineBytesUpTo) showed
// cloneEdgeForResolve still pulling its share of the resolver's flat
// CPU; pooling removes it. The cloned Edge's Meta map is intentionally
// NOT pooled — when a resolution succeeds, the map travels onto the
// real edge via reindexJob.meta and is owned there afterwards.
var resolverClonePool = sync.Pool{
New: func() any { return &graph.Edge{} },
}
// cloneEdgeForResolve returns a deep-enough copy of e for safe
// worker-local mutation by resolveEdge: every scalar / string field
// is value-copied; Meta is duplicated when present so a helper
// writing `clone.Meta["resolution"] = ...` doesn't mutate a map
// shared with the original (and therefore with other goroutines
// inspecting that map). Meta is the only reference-typed field on
// Edge that resolveEdge may write to today; any future Edge field
// of map / slice type will need handling here too.
//
// The returned *Edge must be released with releaseResolverClone once
// the worker is done with it (after any reindexJob has captured the
// Meta pointer). Forgetting to release just means the clone falls
// back to GC, not a leak.
func cloneEdgeForResolve(e *graph.Edge) *graph.Edge {
clone := resolverClonePool.Get().(*graph.Edge)
*clone = *e
if clone.Meta != nil {
dup := make(map[string]any, len(clone.Meta))
for k, v := range clone.Meta {
dup[k] = v
}
clone.Meta = dup
}
return clone
}
// releaseResolverClone returns a clone produced by cloneEdgeForResolve
// to the pool. Safe to call after the worker has copied any needed
// fields (To, Kind, Origin, Meta, …) into a reindexJob — the job
// retains its own references to those values, and the Edge shell is
// no longer needed. Zeroing prevents the next Get from seeing stale
// pointer fields the GC would otherwise be unable to reclaim.
func releaseResolverClone(clone *graph.Edge) {
if clone == nil {
return
}
*clone = graph.Edge{}
resolverClonePool.Put(clone)
}
// resolveEdge mutates e.To in place and returns the prior value
// when a resolution actually happened (i.e. e.To != oldTo). The
// caller decides whether to call graph.ReindexEdge immediately
// (single-threaded ResolveFile) or to defer the reindex (parallel
// ResolveAll). When nothing changed the returned bool is false.
func (r *Resolver) resolveEdge(e *graph.Edge, stats *ResolveStats) (oldTo string, changed bool) {
oldTo = e.To
// graph.UnresolvedName handles both `unresolved::Name` (legacy)
// and `<repoPrefix>::unresolved::Name` (multi-repo COPY rewrite).
// strings.TrimPrefix only stripped the bare form, leaving every
// multi-repo edge with target=full-id and no downstream pattern
// match — that was the root cause of find_usages returning zero
// callers across the whole gortex repo.
target := graph.UnresolvedName(e.To)
if target == "" {
// Not an unresolved stub at all — fall through with the raw
// id so the pattern dispatch below sees the original value.
target = strings.TrimPrefix(e.To, unresolvedPrefix)
}
// Resolve-time LSP hot-path. Consulted for TS/JS/JSX/TSX files
// (and any other languages a future helper claims via
// SupportsPath). When the LSP wins, the edge is stamped with
// OriginLSPResolved and resolved_by=lsp; the heuristic path is
// skipped. When it loses (no helper, no answer, no match), we
// fall through to the existing heuristic cascade unchanged so
// the edge still gets the best best-effort target.
//
// In bulk mode (a whole-graph ResolveAll warmup pass) the inline
// round-trip is skipped: the definition lookup serialises inside the
// helper, so paying it here parks the parallel workers on the helper
// lock. ResolveAll instead collects EVERY LSP-eligible edge and binds
// them in one deferred batch after the loop (see resolveDeferredLSP +
// lspDeferTarget). Deferring only the heuristic-unresolved edges would
// let a confident heuristic mis-bind escape LSP correction, so the batch
// re-binds heuristic-resolved edges too — retaining the LSP-first
// override this inline branch gives single-file paths, where bulkMode is
// false and an interactive edit still resolves LSP-first.
if !r.bulkMode && r.tryResolveViaLSP(e, target, stats) {
return oldTo, e.To != oldTo
}
switch {
case strings.HasPrefix(target, "grpc::"):
// gRPC client-stub call placeholder
// (`unresolved::grpc::<Service>::<Method>`). Landed on the
// server-side handler by the graph-wide ResolveGRPCStubCalls
// pass, which needs the whole graph plus InferImplements — the
// per-edge resolver can't see that. Leave the edge untouched.
return oldTo, false
case strings.HasPrefix(target, "pyrel::"):
// Python relative-import placeholder
// (`unresolved::pyrel::<projectRootedStem>`). The graph-wide
// resolveRelativeImports pass lands these on the matching
// KindFile node once the whole index is built; the per-edge
// resolver can't see project-layout context. Leave untouched
// so the post-pass owns rewriting.
return oldTo, false
case strings.HasPrefix(target, "import::"):
r.resolveImport(e, strings.TrimPrefix(target, "import::"), stats)
case strings.HasPrefix(target, "extern::"):
// Package-qualified call (json.NewEncoder): the parser attached
// the full import path + symbol so we don't have to guess a
// receiver type. resolveExtern accepts type candidates too, so a
// package-qualified embedded type (`extern::pkg::Base`) keeps
// its precise import-path evidence here rather than falling to
// the same-repo-only resolveTypeRef below.
r.resolveExtern(e, strings.TrimPrefix(target, "extern::"), stats)
case e.Kind == graph.EdgeExtends || e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeComposes ||
e.Kind == graph.EdgeReturns || e.Kind == graph.EdgeTypedAs:
// Type-hierarchy and type-position edges must land on a type
// or interface — never a function or method. Without this
// gate the default case routes them through resolveFunctionCall
// which happily matches any same-named function (e.g.
// `*tsitter.Language` as a return type landed on a method
// named `Language` instead of the `Language` type alias,
// hiding every cross-package type reference from the graph
// and making aliased types look completely unused). The four
// kinds covered here:
// - EdgeExtends/EdgeImplements/EdgeComposes: type hierarchy
// - EdgeReturns: function/method return types
// - EdgeTypedAs: parameter / variable / field declared types
// resolveTypeRef accepts only KindType / KindInterface
// candidates and is placed ahead of the `*.` cases so a
// selector-shaped supertype target can't slip into method
// resolution. extern:: targets are handled above — their
// import path is real cross-repo evidence.
r.resolveTypeRef(e, target, stats)
case strings.HasPrefix(target, "*.") && (e.Kind == graph.EdgeWrites || e.Kind == graph.EdgeReads):
// Field write/read: prefer a KindField candidate whose
// receiver matches the edge's receiver_type hint. Falls back
// to the method-resolution path when no field candidate
// lands — gives degraded-but-useful behaviour for graphs
// where the field-node pass hasn't caught up yet.
//
// When the fallback resolves to a method, the extractor's
// EdgeReads label was a placeholder for "selector used as a
// value" (e.g. `mux.HandleFunc("/p", h.foo)` — h.foo passed,
// not called). Promote to EdgeReferences so find_usages and
// get_callers surface the method-value reference. Writes stay
// as EdgeWrites: assigning a func value to a method-typed
// field slot is still a write semantically.
fieldName := strings.TrimPrefix(target, "*.")
if !r.resolveFieldRef(e, fieldName, stats) {
before := e.To
r.resolveMethodCall(e, fieldName, stats)
if e.Kind == graph.EdgeReads && e.To != before {
e.Kind = graph.EdgeReferences
}
}
case strings.HasPrefix(target, "*."):
// Method call or method-value reference (e.g. h.handleHealth)
r.resolveMethodCall(e, strings.TrimPrefix(target, "*."), stats)
case e.Kind == graph.EdgeProvides || e.Kind == graph.EdgeConsumes:
// DI-token reference — the target is a named value (injection
// token), usually an `export const`, that the resolver's
// function/method passes would miss because they only accept
// method/function candidates.
r.resolveTokenRef(e, target, stats)
case e.Kind == graph.EdgeRendersChild:
// A rendered child component (`<Button/>`) binds FIRST against the
// caller file's import bindings — ground truth that pins the exact
// component even when the name is ambiguous repo-wide — and only
// falls through to the name / dir-proximity cascade when the
// component is locally defined (no matching import).
r.resolveRendersChild(e, target, stats)
default:
// For instantiates/references edges, try to resolve as a type first;
// for calls edges, resolve as a function (original behavior).
if e.Kind == graph.EdgeInstantiates || e.Kind == graph.EdgeReferences {
r.resolveTypeOrFunc(e, target, stats)
} else if rp, _ := e.Meta["rust_path"].(string); strings.Contains(rp, "::") {
// A Rust qualified path call (`MatchStrategy::new`,
// `crate::mod::baz`) keeps its full path in Meta["rust_path"].
// The generic function-call cascade only sees the trailing
// segment ("new"), so it would mis-bind the call to the first
// same-named symbol. Leave it unresolved for the SynthRustScope
// pass, which reads the qualifier and binds the right owner.
break
} else {
before := e.To
r.resolveFunctionCall(e, target, stats)
// Promote EdgeReads → EdgeReferences when the resolved
// target is a function or method. The extractor emits
// EdgeReads for "bare identifier as value" (e.g. a cobra
// command's `RunE: runClean` or `&Command{RunE: runFoo}`),
// because at parse time it can't tell a function pointer
// from a variable read. Now that we know the target is a
// function, treat it as a reference so get_callers /
// find_usages surface the wire-up site. Without this,
// every CLI-wired command and command-table entry looks
// like dead code.
if e.Kind == graph.EdgeReads && e.To != before {
if n := r.cachedGetNode(e.To); n != nil && (n.Kind == graph.KindFunction || n.Kind == graph.KindMethod) {
e.Kind = graph.EdgeReferences
}
}
}
}
return oldTo, e.To != oldTo
}
// resolveExtern handles "extern::<importPath>::<symbol>" targets produced
// by the parser when a selector call's receiver matches an import alias.
//
// Resolution order:
// 1. Look for <symbol> defined in a file whose dir matches the import
// path — this catches cross-repo calls into another indexed tree
// (e.g. service A calls service B's exported function).
// 2. Otherwise, keep the package-qualified target so the UI can render
// "crosses web → encoding/json" instead of a bare em-dash. The
// prefix chosen encodes whether the path looks stdlib-like (no dot
// in first segment, for Go) vs a module path (dotted or vendored).
//
// Nothing is created as a graph node — these are bookkeeping strings,
// same as the existing "external::<path>" stubs for unresolved imports.
func (r *Resolver) resolveExtern(e *graph.Edge, spec string, stats *ResolveStats) {
sep := strings.LastIndex(spec, "::")
if sep < 0 {
// Malformed — treat as unresolved so we don't leak the
// "unresolved::extern::" prefix into the graph.
e.To = "external::" + spec
stats.External++
return
}
importPath := spec[:sep]
symbol := spec[sep+2:]
// Pass 1: does the symbol live in a file under this import path?
// Reuse dirIndex populated by buildDirIndexes — no extra scan.
// cachedFindNodesByName lands in the per-pass batch cache for
// the common worker hot path; falls through to the store when
// called outside ResolveAll.
callerRepo := r.callerRepoPrefix(e)
candidates := r.cachedFindNodesByName(symbol)
for _, c := range candidates {
if c.Kind != graph.KindFunction && c.Kind != graph.KindMethod && c.Kind != graph.KindType && c.Kind != graph.KindInterface {
continue
}
dir := r.dirFor(c.FilePath)
crossRepo := callerRepo != "" && c.RepoPrefix != "" && c.RepoPrefix != callerRepo
var matches bool
if crossRepo {
// Cross-repo extern call: require a precise import-path
// suffix match. The old loose last-component test
// (`*/go`) resolved every tree-sitter binding's
// `Language` to whichever repo sorted first.
matches = dirMatchesImport(dir, importPath)
} else {
matches = strings.HasSuffix(dir, "/"+lastPathComponent(importPath)) || dir == importPath || strings.HasSuffix(dir, importPath)
}
if matches {
e.To = c.ID
if crossRepo {
e.CrossRepo = true
}
stats.Resolved++
return
}
}
// Pass 2: classify the import path. "stdlib::" when the path looks
// like a Go stdlib package (no dot in the first segment and not a
// known module vendor prefix). "dep::" otherwise. Callers can treat
// both as external for edge-walk purposes. The stdlib stub carries
// the caller's repo prefix (see internal/graph/stub.go) so two repos
// pinned to different Go SDK versions get distinct fmt::Errorf nodes
// instead of one shared, version-conflated terminal.
if isStdlibLike(importPath) {
e.To = graph.StubID(callerRepo, graph.StubKindStdlib, importPath, symbol)
} else {
e.To = "dep::" + importPath + "::" + symbol
}
stats.External++
}
// isStdlibLike reports whether the import path looks like a Go stdlib
// package. Heuristic: the first path segment must have no dot (module
// paths like github.com/foo, golang.org/x, etc. always dot the first
// segment). Vetted against the list of real stdlib roots used by
// go/build — any new single-word non-stdlib package (very rare) is
// mis-classified as stdlib, which is cosmetic only.
func isStdlibLike(importPath string) bool {
first := importPath
if i := strings.Index(importPath, "/"); i >= 0 {
first = importPath[:i]
}
return first != "" && !strings.Contains(first, ".")
}
// pendingShapeSummary buckets a pending edge set by unresolved-target shape so
// the resolver's per-pass log shows where the work concentrates (extern stdlib
// vs external module, receiver-unknown method calls, bare names, imports).
// Diagnostic only — never influences resolution. Note extern_stdlib here uses
// the same isStdlibLike heuristic the resolver applies as a post-scan fallback,
// so it over-counts dot-less local modules; it is a coarse population estimate,
// not a resolution decision.
func pendingShapeSummary(pending []*graph.Edge) string {
var externStdlib, externDep, starMethod, importStub, qualified, bareName, other int
for _, e := range pending {
if e == nil {
continue
}
if !graph.IsUnresolvedTarget(e.To) {
other++
continue
}
name := graph.UnresolvedName(e.To)
switch {
case strings.HasPrefix(name, "extern::"):
rest := name[len("extern::"):]
if sep := strings.LastIndex(rest, "::"); sep >= 0 && isStdlibLike(rest[:sep]) {
externStdlib++
} else {
externDep++
}
case strings.HasPrefix(name, "*."):
starMethod++
case strings.HasPrefix(name, "import::"):
importStub++
case strings.Contains(name, "::"):
qualified++
default:
bareName++
}
}
return fmt.Sprintf("extern_stdlib=%d extern_dep=%d star_method=%d import=%d qualified=%d bare_name=%d other=%d",
externStdlib, externDep, starMethod, importStub, qualified, bareName, other)
}
func (r *Resolver) resolveImport(e *graph.Edge, importPath string, stats *ResolveStats) {
callerRepo := r.callerRepoPrefix(e)
// JS/TS relative + tsconfig-path-alias / baseUrl import: resolve the
// specifier onto the in-repo file (or exported symbol) it names. The
// dirIndex cascade below is package-directory-oriented and never
// matches a JS/TS file stem, so without this every cross-directory
// JS/TS import would fall through to an `external::*` stub — starving
// buildImportClosure of reachability and letting the cross-package
// guard revert the callers (issue #136). A no-op for non-JS/TS callers
// and for genuine third-party specifiers.
//
// This runs BEFORE the npm-alias rewrite, mirroring tsserver's
// precedence: `compilerOptions.paths` beats node_modules. A library
// whose test suite imports its own published name (zustand's tests
// import 'zustand', mapped by tsconfig paths onto ./src) must land on
// the in-repo source, not on its own installed dist inside
// node_modules.
if to := resolveJSTSImportTarget(r.cachedGetNode, r.pathAlias, jsTSImportCallerFile(e), importPath); to != "" {
e.To = to
if callerRepo != "" {
if n := r.cachedGetNode(to); n != nil && n.RepoPrefix != "" && n.RepoPrefix != callerRepo {
e.CrossRepo = true
}
}
stats.Resolved++
return
}
// npm-alias rewrite: a JS/TS import of a package.json alias key
// (`"shared": "npm:@acme/shared-lib@1.4.0"`) actually targets the
// real package. Rewrite the specifier before any further lookup so a
// locally-vendored `@acme/shared-lib` resolves to its real node
// instead of falling through to an external stub. A no-op for
// non-aliased specifiers and non-JS/TS callers.
importPath, npmAliased := rewriteNpmAliasImport(r.npmAlias, e.FilePath, importPath)
if npmAliased {
// The rewritten specifier may itself be tsconfig-paths/relative
// resolvable (an alias onto a workspace member).
if to := resolveJSTSImportTarget(r.cachedGetNode, r.pathAlias, jsTSImportCallerFile(e), importPath); to != "" {
e.To = to
if callerRepo != "" {
if n := r.cachedGetNode(to); n != nil && n.RepoPrefix != "" && n.RepoPrefix != callerRepo {
e.CrossRepo = true
}
}
stats.Resolved++
return
}
}
// Look for a package node with matching qualified name.
node := r.cachedGetNodeByQualName(importPath)
if node != nil {
e.To = node.ID
if callerRepo != "" && node.RepoPrefix != "" && node.RepoPrefix != callerRepo {
e.CrossRepo = true
}
stats.Resolved++
return
}
// Inverted-index lookup instead of a per-edge AllNodes() scan —
// the old scan was O(N) per import and the dominant cost of
// ResolveAll on large repos (e.g. vscode: 5k imports × 150k nodes
// = 750M comparisons per cold index). Falls back to a scan only
// when the indexes aren't populated (ResolveEdge invoked outside
// of ResolveAll/ResolveFile).
//
// When a package-manager workspace lookup is installed, all
// same-repo candidates are collected (not just the first) so a
// same-named collision across two workspace members can be broken
// in favour of the importer's own workspace. Without the lookup
// the first same-repo hit short-circuits the scan, preserving the
// pre-feature cost.
collectAll := r.workspaceMembers != nil
var sameRepo, crossRepoNode *graph.Node
var sameRepoAll []*graph.Node
consider := func(n *graph.Node) {
if n.Kind != graph.KindFile {
return
}
if callerRepo == "" || n.RepoPrefix == callerRepo {
if sameRepo == nil {
sameRepo = n
}
if collectAll {
sameRepoAll = append(sameRepoAll, n)
}
return
}
// Cross-repo file candidate: require a precise import-path
// suffix match. The lastDirIndex / full-scan fallbacks key on
// the last path component only, so without this gate an import
// of `.../tree-sitter-c/bindings/go` would resolve to whichever
// `*/bindings/go` directory sorts first.
if crossRepoNode == nil && dirMatchesImport(filepath.Dir(n.FilePath), importPath) {
crossRepoNode = n
}
}
// stop reports whether the candidate scan can short-circuit: once a
// same-repo hit is found and we are not collecting every candidate
// for workspace disambiguation.
stop := func() bool { return sameRepo != nil && !collectAll }
if r.dirIndex != nil {
for _, n := range r.dirIndex[importPath] {
consider(n)
if stop() {
break
}
}
if sameRepo == nil || collectAll {
for _, n := range r.lastDirIndex[lastPathComponent(importPath)] {
consider(n)
if stop() {
break
}
}
}
} else {
for n := range r.graph.NodesByKind(graph.KindFile) {
dir := filepath.Dir(n.FilePath)
if strings.HasSuffix(dir, lastPathComponent(importPath)) || dir == importPath {
consider(n)
if stop() {
break
}
}
}
}
if sameRepo != nil {
// Name-collision tie-break: when several same-repo files match
// a bare import name, prefer the one in the importing file's
// own package-manager workspace.
if ws := r.preferSameWorkspaceFile(e.FilePath, sameRepoAll); ws != nil {
sameRepo = ws
}
e.To = sameRepo.ID
stats.Resolved++
return
}
if crossRepoNode != nil {
e.To = crossRepoNode.ID
if callerRepo != "" && crossRepoNode.RepoPrefix != "" && crossRepoNode.RepoPrefix != callerRepo {
e.CrossRepo = true
}
stats.Resolved++
return
}
// No same- or cross-repo file matched. Before falling back to an
// `external::` stub, try the dep::<module> contract nodes from the
// caller's go.mod — that bridge is what gives third-party imports
// like "github.com/foo/bar/sub/pkg" an incoming edge on the
// dep::github.com/foo/bar node.
if depNode := r.lookupDepModule(callerRepo, importPath); depNode != nil {
e.To = depNode.ID
stats.Resolved++
return
}
// npm-alias sub-path: a rewritten import like `@acme/shared-lib/util`
// addresses a path inside the real package. Nothing matched the
// full path, so fall back to the package node itself — the
// cross-package edge belongs on the package regardless of which
// sub-module the importer reached for.
if npmAliased {
if pkg := npmPackagePrefix(importPath); pkg != "" {
if node := r.cachedGetNodeByQualName(pkg); node != nil {
e.To = node.ID
if callerRepo != "" && node.RepoPrefix != "" && node.RepoPrefix != callerRepo {
e.CrossRepo = true
}
stats.Resolved++
return
}
}
}
// External/unresolvable import — create a stub target ID.
e.To = "external::" + importPath
stats.External++
}
func (r *Resolver) resolveFunctionCall(e *graph.Edge, funcName string, stats *ResolveStats) {
callerRepo := r.callerRepoPrefix(e)
candidates := withoutReExportForwarders(r.cachedFindNodesByNameInRepo(funcName, callerRepo))
if len(candidates) == 0 {
// No same-repo candidate. A genuine cross-repo callee is left
// unresolved here for CrossRepoResolver — which alone carries the
// import-reachability + workspace-boundary evidence — to lift.
// Guessing "first function named X anywhere in the graph" is the
// exact name-collision bug this gate removes.
stats.Unresolved++
return
}
// Per-language scope-based static resolver. Consulted before the
// generic locality cascade so C file-static / C++ namespace +
// ADL / Java enclosing-class / PHP namespace + parent::/self::
// rules can land a precise binding when their evidence is strong.
// Returns nil when no language-specific rule applies; the cascade
// below then runs unchanged.
if pick := r.preferScopeCandidate(e, funcName, candidates); pick != nil {
e.To = pick.ID
e.Origin = graph.OriginASTResolved
e.Confidence = 0.92
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["resolution"] = "scope"
stats.Resolved++
return
}
// File-local candidates outrank everything below: a symbol defined in
// the caller's own file is strictly more local than a same-directory
// neighbour in every language (in Go both are package scope, so the
// same-file pick is equally valid; in module-scoped languages only the
// same-file symbol is in scope at all). Without this tier a same-named
// nested helper in a NEIGHBOURING test file captures the calls the
// caller's own helper should receive — zustand's persistSync tests
// bound to persistAsync's `createStore` helper purely by candidate
// iteration order.
var sameFile *graph.Node
sameFileCount := 0
for _, c := range candidates {
if (c.Kind == graph.KindFunction || c.Kind == graph.KindMethod) &&
c.FilePath != "" && c.FilePath == e.FilePath {
if sameFile == nil {
sameFile = c
}
sameFileCount++
}
}
if sameFile != nil {
e.To = sameFile.ID
// A bare-name call to the sole same-file definition is structurally
// unambiguous — grammar-grounded, no type system needed — so stamp it
// ast_resolved rather than leaving it to backfill to the name-only
// tier that find_usages suppresses by default. This is what keeps a
// freshly-added same-file call site (the common edit) visible instead
// of silently hidden. Two same-name same-file definitions (a func and
// a method sharing a name) stay untagged so suppression can still drop
// the ambiguous pick. Only genuine call edges are promoted; bare-value
// reads (cobra RunE wire-ups) keep the heuristic tier.
if sameFileCount == 1 && e.Kind == graph.EdgeCalls && e.Origin == "" {
e.Origin = graph.OriginASTResolved
e.Confidence = 0.9
}
stats.Resolved++
return
}
// Import-evidence disambiguation (JS/TS only; see import_evidence.go
// for the full precedence design). The ES module system has no ambient
// directory scope, so before the locality cascade below can bind a
// same-dir shadow — and before cross-dir ambiguity guesses or refuses —
// ask the caller file's import closure. When the caller imports exactly
// one candidate's file (directly or through re-export/barrel hops) that
// import statement is structural, AST-grade evidence of the binding:
// resolve to it at OriginASTResolved, the tier resolveRendersChild's
// import-binding path already uses. A module-local candidate blocks the
// pick; no import or several imported candidates fall through to the
// existing cascade unchanged.
if pick := r.pickImportEvidenceCallee(e.FilePath, funcName, candidates); pick != nil {
e.To = pick.ID
e.Origin = graph.OriginASTResolved
e.Confidence = 0.9
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["resolution"] = "import_closure"
stats.Resolved++
return
}
// Prefer same-package (same directory) match. When exactly one
// same-package function/method carries this name, a bare-name call to the
// sole same-package definition is structurally unambiguous, so stamp it
// ast_resolved (same rationale as the same-file pick above). Multiple
// same-name same-package candidates stay untagged so redundant-text
// suppression can still drop them. Method-name fan-out (x.Get()) never
// reaches here — it resolves in resolveMethodCall and stays text_matched,
// preserving that precision guard.
callerDir := r.dirFor(e.FilePath)
var samePkg *graph.Node
samePkgCount := 0
for _, c := range candidates {
if (c.Kind == graph.KindFunction || c.Kind == graph.KindMethod) &&
r.dirFor(c.FilePath) == callerDir {
if samePkg == nil {
samePkg = c
}
samePkgCount++
}
}
if samePkg != nil {
e.To = samePkg.ID
if samePkgCount == 1 && e.Kind == graph.EdgeCalls && e.Origin == "" {
e.Origin = graph.OriginASTResolved
e.Confidence = 0.9
}
stats.Resolved++
return
}
// Fall back to the first same-repo function/method match. This is a
// name-only guess (no directory/import evidence), so tag it text_matched
// — the weakest tier — so the redundant-text suppression drops it when a
// language server later confirms the real target, and the cross-package
// guard can revert it when unreachable. Same-file / same-directory picks
// above stay untagged (structural locality evidence) and survive.
for _, c := range candidates {
if c.Kind == graph.KindFunction || c.Kind == graph.KindMethod {
e.To = c.ID
if e.Origin == "" {
e.Origin = graph.OriginTextMatched
}
stats.Resolved++
return
}
}
// JS/TS last resort: an exported const initialised with a callable the
// extractor could not classify as a function (alias-cast exports like
// `export const persist = persistImpl as unknown as Persist`) lands as
// a KindVariable/KindConstant node, which the function/method loops
// above can never bind. Accept a TOP-LEVEL variable/constant callee —
// same-directory first, then a unique same-repo match; any ambiguity
// refuses so a local binding cannot capture an unrelated call.
if isJSTSPath(e.FilePath) {
if pick := pickTopLevelValueCallee(candidates, funcName, callerDir, r.dirFor); pick != nil {
e.To = pick.ID
e.Origin = graph.OriginASTInferred
e.Confidence = 0.7
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["resolution"] = "value_callee"
stats.Resolved++
return
}
}
stats.Unresolved++
}
// withoutReExportForwarders drops barrel re-export binding nodes from a
// call-resolution candidate set. A re-export node (`export { X } from './mod'`)
// is a transparent forwarder, never a callee — the call binds to the
// declaration it forwards, which is a separate same-named candidate. Leaving
// the forwarder in only adds a phantom candidate that turns an otherwise-clean
// import-evidence / value-callee pick into a refused ambiguity. Returns the
// input unchanged when it holds no forwarders (the common case), so non-JS/TS
// and non-barrel resolution pays nothing.
func withoutReExportForwarders(candidates []*graph.Node) []*graph.Node {
has := false
for _, c := range candidates {
if graph.IsReExportNode(c) {
has = true
break
}
}
if !has {
return candidates
}
out := make([]*graph.Node, 0, len(candidates))
for _, c := range candidates {
if !graph.IsReExportNode(c) {
out = append(out, c)
}
}
return out
}
// pickTopLevelValueCallee returns the variable/constant candidate a JS/TS
// call edge may bind to when no function/method candidate matched: only a
// top-level symbol (ID == <file>::<name>, i.e. not a local or an object
// member) is eligible, same-directory candidates win, and ambiguity at
// either tier returns nil so no false edge lands.
func pickTopLevelValueCallee(candidates []*graph.Node, funcName, callerDir string, dirFor func(string) string) *graph.Node {
var sameDir, repoWide *graph.Node
sameDirAmbiguous, repoAmbiguous := false, false
for _, c := range candidates {
if c.Kind != graph.KindVariable && c.Kind != graph.KindConstant {
continue
}
if c.ID != c.FilePath+"::"+funcName {
continue // nested/local binding — not a top-level value
}
if dirFor(c.FilePath) == callerDir {
if sameDir != nil {
sameDirAmbiguous = true
} else {
sameDir = c
}
}
if repoWide != nil {
repoAmbiguous = true
} else {
repoWide = c
}
}
if sameDir != nil && !sameDirAmbiguous {
return sameDir
}
if repoWide != nil && !repoAmbiguous {
return repoWide
}
return nil
}
// resolveTypeOrFunc resolves unresolved edges that could be either a type
// reference (composite literal, type assertion) or a function reference.
// It first tries to match a type/interface node, then falls back to functions.
// Candidates are restricted to the caller's own repo — a cross-repo
// match here would be a name-only guess; CrossRepoResolver handles the
// genuine cross-repo case with import-reachability evidence.
func (r *Resolver) resolveTypeOrFunc(e *graph.Edge, name string, stats *ResolveStats) {
callerRepo := r.callerRepoPrefix(e)
candidates := r.cachedFindNodesByNameInRepo(name, callerRepo)
if len(candidates) == 0 {
stats.Unresolved++
return
}
callerDir := r.dirFor(e.FilePath)
// Land the edge on the canonical type/interface definition (real,
// exported, top-level, non-test), preferring same-package only as a
// tiebreak. See bestTypeCandidate / resolveTypeRef for the rationale:
// without this an instantiate / reference edge for a widely-imported
// or builder-pattern type lands on whichever same-named rival sorts
// first, hiding all usage from the canonical definition node.
if best := bestTypeCandidate(candidates, callerDir); best != nil {
e.To = best.ID
stats.Resolved++
return
}
// If no type found, try as function (e.g., bare function name passed as value).
for _, c := range candidates {
if c.Kind == graph.KindFunction || c.Kind == graph.KindMethod {
if r.dirFor(c.FilePath) == callerDir {
e.To = c.ID
stats.Resolved++
return
}
}
}
for _, c := range candidates {
if c.Kind == graph.KindFunction || c.Kind == graph.KindMethod {
e.To = c.ID
stats.Resolved++
return
}
}
stats.Unresolved++
}
// resolveTypeRef resolves an extends / implements / composes edge to a
// type or interface node. It never accepts a function or method
// candidate — a type-hierarchy edge whose target is a function is
// always a misresolution (the bug that let `type EdgeKind string`
// "extend" a method named `string`). Candidates are restricted to the
// caller's own repo; a genuine cross-repo supertype is left unresolved
// for CrossRepoResolver.
func (r *Resolver) resolveTypeRef(e *graph.Edge, name string, stats *ResolveStats) {
// A selector-shaped target (`*.Base`, from an embedded `pkg.Base`)
// carries no usable package qualifier once it reaches here — strip
// the `*.` and resolve on the bare type name.
name = strings.TrimPrefix(name, "*.")
callerRepo := r.callerRepoPrefix(e)
candidates := r.cachedFindNodesByNameInRepo(name, callerRepo)
if len(candidates) == 0 {
stats.Unresolved++
return
}
callerDir := r.dirFor(e.FilePath)
// Land the edge on the canonical type/interface definition. The
// ranker prefers a real, exported, top-level, non-test definition
// over same-named rivals (external stubs, test/mock defs, private or
// nested member types), with same-package proximity folded in only as
// a tiebreak — so a same-directory test/stub no longer steals the
// edge from a cross-directory canonical def (the bug that made
// widely-imported and builder-pattern types look unused).
if best := bestTypeCandidate(candidates, callerDir); best != nil {
e.To = best.ID
stats.Resolved++
return
}
stats.Unresolved++
}
// resolveFieldRef lands an EdgeWrites/EdgeReads edge on a KindField
// node when the receiver type is known. Returns true when a field
// candidate was picked — caller falls back to method resolution
// otherwise (handles cases where the extractor labelled the edge as a
// write but the runtime target is actually a method/property).
func (r *Resolver) resolveFieldRef(e *graph.Edge, fieldName string, stats *ResolveStats) bool {
receiverType := edgeReceiverType(e)
candidates := r.cachedFindNodesByNameInRepo(fieldName, r.callerRepoPrefix(e))
if len(candidates) == 0 {
return false
}
callerDir := r.dirFor(e.FilePath)
// Pass 1: same-directory + exact-receiver-type field.
if receiverType != "" {
for _, c := range candidates {
if c.Kind == graph.KindField &&
r.dirFor(c.FilePath) == callerDir &&
nodeReceiverType(c) == receiverType {
e.To = c.ID
e.Confidence = 0.95
stats.Resolved++
return true
}
}
// Pass 2: exact-receiver-type field, any directory.
for _, c := range candidates {
if c.Kind == graph.KindField && nodeReceiverType(c) == receiverType {
e.To = c.ID
e.Confidence = 0.85
stats.Resolved++
return true
}
}
}
// Pass 3: caller is a method on type T, prefer a same-T field.
if callerNode := r.cachedGetNode(e.From); callerNode != nil && callerNode.Kind == graph.KindMethod {
callerRecv := nodeReceiverType(callerNode)
if callerRecv != "" {
for _, c := range candidates {
if c.Kind == graph.KindField && nodeReceiverType(c) == callerRecv {
e.To = c.ID
e.Confidence = 0.85
stats.Resolved++
return true
}
}
}
}
// Pass 4: same-directory field of any owner type — last resort
// before falling through to method resolution.
for _, c := range candidates {
if c.Kind == graph.KindField && r.dirFor(c.FilePath) == callerDir {
e.To = c.ID
e.Confidence = 0.6
stats.Resolved++
return true
}
}
return false
}
func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *ResolveStats) {
// Same-repo gate first: the per-repo Resolver never resolves a
// method call across a repo boundary by name. A cross-repo method
// call is left unresolved for CrossRepoResolver, which carries the
// import-reachability + workspace-boundary evidence.
rawCandidates := r.cachedFindNodesByNameInRepo(methodName, r.callerRepoPrefix(e))
if len(rawCandidates) == 0 {
if r.applyBuiltinIfKnown(e, methodName, stats) {
return
}
stats.Unresolved++
return
}
// Pass 0: import-reachability filter. Drop candidates whose package
// the caller's file does not import (or sit in). This collapses
// most cross-package name collisions before any later pass has to
// guess. The filter is conservative — when the index is missing or
// would empty the list, the original candidates pass through.
candidates := r.filterByReachability(e.FilePath, rawCandidates)
// Per-language scope rule lands binding when its evidence is
// strong (C static / C++ namespace + ADL / Java enclosing class /
// PHP parent::/self::/namespace). Empty return falls through to
// the existing receiver-type cascade unchanged.
if pick := r.preferScopeCandidate(e, methodName, candidates); pick != nil {
e.To = pick.ID
e.Origin = graph.OriginASTResolved
e.Confidence = 0.92
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["resolution"] = "scope"
stats.Resolved++
return
}
callerDir := r.dirFor(e.FilePath)
receiverType := edgeReceiverType(e)
// If we have a type hint, try exact type match first. These passes scan
// the UNFILTERED candidate set: a receiver typed as T binds to T's method
// regardless of whether the caller's file imports T's package. Import-
// reachability filtering can drop the receiver's own type when it lives in
// a sibling Maven directory (src/main vs src/test) the caller never
// imports, leaving a same-named method in the caller's own class as the
// only survivor — so the exact-type match must see every candidate, not
// just the reachable ones.
if receiverType != "" {
// An exact receiver-type match is structural evidence — the
// receiver's type is known and the method belongs to it — so it
// resolves at ast_resolved (not the name-only ast_inferred tier the
// cross-package guard reverts) and does not need import-reachability
// corroboration, which Rust re-exports and unsplit `use a::{b, c}`
// import groups routinely leave unresolved.
// Pass 1: same-directory + exact type match (highest confidence).
for _, c := range rawCandidates {
if c.Kind == graph.KindMethod &&
r.dirFor(c.FilePath) == callerDir &&
nodeReceiverType(c) == receiverType {
e.To = c.ID
e.Confidence = 0.95
e.Origin = graph.OriginASTResolved
stats.Resolved++
return
}
}
// Pass 2: exact type match, any directory, over the UNFILTERED
// candidate set with a uniqueness guard (so a same-named type in
// another package is never mis-picked).
var exact *graph.Node
for _, c := range rawCandidates {
if c.Kind == graph.KindMethod && nodeReceiverType(c) == receiverType {
if exact != nil && exact.ID != c.ID {
exact = nil
break
}
exact = c
}
}
if exact != nil {
e.To = exact.ID
e.Confidence = 0.85
e.Origin = graph.OriginASTResolved
stats.Resolved++
return
}
// Pass 2b: DI useClass binding. When receiver_type is an
// abstract/base class that has no method of this name (Passes
// 1 and 2 found nothing), look for a `provides_for: <type>`
// edge in the graph — that tells us which concrete class a
// @Module has bound this abstract to. Prefer candidate methods
// on that concrete. Without this, the final name-only fallback
// picks the first implementer alphabetically, which produced
// SmsNotifier.notify instead of the module-bound EmailNotifier
// on the NestJS DI fixture.
if bound := r.boundImplsFor(receiverType); len(bound) > 0 {
for _, c := range candidates {
if c.Kind != graph.KindMethod {
continue
}
recv := nodeReceiverType(c)
if _, ok := bound[recv]; !ok {
continue
}
e.To = c.ID
e.Confidence = 0.9
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["resolution"] = "useClass_binding"
stats.Resolved++
return
}
}
}
// Fallback: infer receiver type from the caller node.
// If the caller is a method on type X and there's a candidate method on
// type X with the same name, prefer it. This handles e.extractFunctions()
// where the type env doesn't have a hint for parameter-bound receivers.
callerNode := r.cachedGetNode(e.From)
if callerNode != nil && callerNode.Kind == graph.KindMethod {
callerRecv := nodeReceiverType(callerNode)
if callerRecv != "" {
// Same receiver type + same directory = very high confidence.
for _, c := range candidates {
if c.Kind == graph.KindMethod &&
r.dirFor(c.FilePath) == callerDir &&
nodeReceiverType(c) == callerRecv {
e.To = c.ID
e.Confidence = 0.9
stats.Resolved++
return
}
}
// Same receiver type, any directory.
for _, c := range candidates {
if c.Kind == graph.KindMethod && nodeReceiverType(c) == callerRecv {
e.To = c.ID
e.Confidence = 0.8
stats.Resolved++
return
}
}
}
}
// C# extension methods: an `x.Foo()` with no matching instance or
// interface member Foo may be a call to a `static Foo(this X x)`
// extension. Bind precisely (typed match, else unambiguous name);
// ambiguous stays unresolved rather than misattributing to a same-name
// method on an unrelated type.
if r.tryBindCSharpExtension(e, methodName, receiverType, rawCandidates, stats) {
return
}
// Locality fallback (replaces the previous alphabetical name-only
// pick). At this point candidates have survived Pass 0 — they all
// live in packages reachable from the caller. Prefer in this order:
//
// 1. Method, same directory — same package, definitely reachable.
// 2. Method, any reachable directory — exactly one survivor: take it.
// 3. Method, any reachable directory — multiple survivors: see below.
// 4. Function, same directory — pkg.Func() calls land here too.
// 5. Function, any reachable directory — same logic as methods.
//
// When step 3 finds multiple methods, we prefer the same-package one
// (locality bias is stronger than any cross-package signal we have
// without LSP). If no candidate is same-package, we still take the
// first reachable one — Pass 0 already guaranteed reachability, so
// this is no longer an arbitrary alphabetical choice across the
// whole graph but a choice within the caller's import closure.
var sameDirMethod, sameDirFunc, anyMethod, anyFunc *graph.Node
methodCount := 0
for _, c := range candidates {
// Extension methods are bound only by the type-directed extension
// rule above; a locality guess must never pick one, which would
// misattribute `x.Foo()` to an unrelated extension named Foo.
if isCSharpExtension(c) {
continue
}
switch c.Kind {
case graph.KindMethod:
methodCount++
if r.dirFor(c.FilePath) == callerDir && sameDirMethod == nil {
sameDirMethod = c
}
if anyMethod == nil {
anyMethod = c
}
case graph.KindFunction:
if r.dirFor(c.FilePath) == callerDir && sameDirFunc == nil {
sameDirFunc = c
}
if anyFunc == nil {
anyFunc = c
}
}
}
// Interface-dispatch annotation: when the receiver type names a
// graph interface and multiple reachable methods of this name
// exist, every candidate is a legal runtime target. Mark the edge
// so downstream consumers don't treat the picked target as
// definitive. Done before the locality picks so it applies whether
// the chosen target lands in the same-dir or any-dir bucket.
if methodCount > 1 && r.receiverIsInterface(receiverType) {
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta["dispatch"] = "interface"
// The pick below is a locality heuristic over legal runtime
// targets — no language server verified it. Stamping the LSP
// dispatch tier here let a guessed winner masquerade as
// semantic-provider evidence and poisoned min_tier filtering;
// ast_inferred is what this actually is. The LSP hierarchy
// pass upgrades (or fans out) the truly verified sites.
e.Origin = graph.OriginASTInferred
}
// A member call with exactly one method candidate for the name is a
// grounded inference, not a text-grade guess: there is nowhere else it
// could bind. Lift it to the ast_inferred tier so min_tier filtering and
// the cross-package guard treat it as the resolved target it is (the guard's
// lone-definition exception keeps it from being reverted). Statically-typed
// languages only (java, go) — see loneMemberLang.
if methodCount == 1 && anyMethod != nil && loneMemberLang(anyMethod.Language) && e.Origin == "" {
e.Origin = graph.OriginASTInferred
if e.Confidence == 0 {
e.Confidence = 0.7
}
}
// Every locality-fallback pick is a name-only guess: no receiver-type
// evidence tied the call to this method/function, only same-name +
// reachability. Tag it text_matched (unless the interface-dispatch or
// Java lone-definition branch above already stamped a tier) so
// redundant-text suppression drops it once a language server confirms the
// real target — a common method name like `Get` otherwise fans a call to
// every same-named method in the package. (Free-function calls resolve in
// resolveFunctionCall, whose same-directory pick stays untagged.)
if sameDirMethod != nil {
e.To = sameDirMethod.ID
if e.Origin == "" {
e.Origin = graph.OriginTextMatched
}
stats.Resolved++
return
}
if anyMethod != nil {
e.To = anyMethod.ID
if e.Origin == "" {
e.Origin = graph.OriginTextMatched
}
stats.Resolved++
return
}
if sameDirFunc != nil {
e.To = sameDirFunc.ID
if e.Origin == "" {
e.Origin = graph.OriginTextMatched
}
stats.Resolved++
return
}
if anyFunc != nil {
e.To = anyFunc.ID
if e.Origin == "" {
e.Origin = graph.OriginTextMatched
}
stats.Resolved++
return
}
// Name matched something, but not in a way we accepted. Give the
// built-in classifier a chance before declaring the edge dead —
// `arr.push` on an Array may also match an unrelated `push` method
// elsewhere in the graph, in which case we'd rather label it as a
// built-in than silently misresolve.
if r.applyBuiltinIfKnown(e, methodName, stats) {
return
}
stats.Unresolved++
}
// receiverIsInterface returns true when the named receiver type
// resolves to a graph node of kind interface. Used by the locality
// fallback to recognise interface-dispatch ambiguity rather than
// treat it as a single-target resolution. Empty receiver type returns
// false.
func (r *Resolver) receiverIsInterface(receiverType string) bool {
if receiverType == "" {
return false
}
for _, n := range r.cachedFindNodesByName(receiverType) {
if n.Kind == graph.KindInterface {
return true
}
}
return false
}
// applyBuiltinIfKnown routes an unresolvable method call to the
// built-in stub (`builtin::<lang>::<category>::<method>`) when the
// caller's language and the method name are both in our lookup tables.
// Returns true when the edge was rewritten; caller should skip its
// Unresolved increment in that case.
func (r *Resolver) applyBuiltinIfKnown(e *graph.Edge, methodName string, stats *ResolveStats) bool {
lang := langFromFilePath(e.FilePath)
if lang == "" {
return false
}
category, ok := classifyBuiltin(methodName, lang)
if !ok {
return false
}
e.To = graph.StubID(r.callerRepoPrefix(e), graph.StubKindBuiltin, lang, category, methodName)
stats.External++
return true
}
// resolveTokenRef resolves the target of an EdgeProvides / EdgeConsumes
// edge that refers to a DI injection token. Tokens are typically
// declared as `export const MY_TOKEN = '...'` (KindVariable) — the
// method/function passes skip them. We name-lookup and accept any kind,
// preferring same-directory matches so token names that happen to
// collide across unrelated files don't pull spurious edges.
func (r *Resolver) resolveTokenRef(e *graph.Edge, name string, stats *ResolveStats) {
// Same-repo gate: DI token names collide readily across unrelated
// repos ("TOKEN", "CONFIG", …); a cross-repo first-candidate pick
// is a name-only guess. CrossRepoResolver handles genuine cross-repo
// token references.
candidates := r.cachedFindNodesByNameInRepo(name, r.callerRepoPrefix(e))
if len(candidates) == 0 {
stats.Unresolved++
return
}
callerDir := r.dirFor(e.FilePath)
for _, c := range candidates {
if r.dirFor(c.FilePath) == callerDir {
e.To = c.ID
e.Confidence = 0.9
stats.Resolved++
return
}
}
// No same-dir hit: take the first same-repo candidate so find_usages
// can still surface the relationship. Confidence drops to reflect
// uncertainty.
e.To = candidates[0].ID
e.Confidence = 0.7
stats.Resolved++
}
// buildProvidesForIndex walks AllEdges once and materialises a map of
// abstract type → concrete class names declared via `@Module({ providers:
// [{ provide: X, useClass: Y }] })`. boundImplsFor then consults this
// index in O(1) per call edge instead of the O(E) scan that made this
// path the dominant serial cost on large repos — e.g. a vscode index
// had ~10k call edges triggering a full 30k-edge scan each, for 300M
// comparisons that found nothing (vscode has zero NestJS modules).
func (r *Resolver) buildProvidesForIndex() {
idx := make(map[string]map[string]struct{})
for ed := range r.graph.EdgesByKind(graph.EdgeProvides) {
if ed.Meta == nil {
continue
}
pf, _ := ed.Meta["provides_for"].(string)
if pf == "" {
continue
}
if b, _ := ed.Meta["binding"].(string); b != "useClass" {
continue
}
to := ed.To
var name string
if graph.IsUnresolvedTarget(to) {
name = graph.UnresolvedName(to)
} else if cut := strings.LastIndex(to, "::"); cut >= 0 {
name = to[cut+2:]
} else {
name = to
}
set, ok := idx[pf]
if !ok {
set = make(map[string]struct{})
idx[pf] = set
}
set[name] = struct{}{}
}
r.providesForIdx = idx
}
func (r *Resolver) clearProvidesForIndex() { r.providesForIdx = nil }
// SetCppIncludeDirs installs the per-source-file C/C++ include search path
// (repo-relative `-I` dirs from compile_commands.json) the relative-import
// resolver uses to bind quoted/angle includes against the real compiler dir
// set. The indexer calls this before ResolveAll; a nil/empty map leaves the
// resolver on its suffix-unique heuristic.
func (r *Resolver) SetCppIncludeDirs(perFile map[string][]string) {
r.cppIncludeDirs = perFile
}
// SetCppFallbackIncludeDirs installs the heuristic include-root search path
// used for repos with no compile_commands.json (conventional dirs plus
// top-level header dirs, in priority order). Consulted by the ordered probe
// only when no per-file / compile-DB dirs exist. The indexer calls this before
// ResolveAll; a nil/empty slice leaves the resolver on its suffix-unique net.
func (r *Resolver) SetCppFallbackIncludeDirs(dirs []string) {
r.cppFallbackDirs = dirs
}
// buildReachabilityIndex walks all EdgeImports edges once and records,
// for each caller file, the set of directories of imported (indexed)
// packages. Resolved import edges point at a file node directly;
// unresolved ones still carry `unresolved::import::<importPath>`,
// which we look up via the same dirIndex resolveImport uses, so the
// reachability index is correct even before import resolution races
// to completion in the parallel pass.
//
// Files always include their own directory in the reachable set so
// same-package calls survive the filter.
func (r *Resolver) buildReachabilityIndex() {
idx := make(map[string]map[string]struct{})
addDir := func(callerFileID, dir string) {
if callerFileID == "" || dir == "" {
return
}
set, ok := idx[callerFileID]
if !ok {
set = make(map[string]struct{})
idx[callerFileID] = set
}
set[dir] = struct{}{}
}
// Seed with each indexed file's own directory, and memoise the per-file
// dir so filterByReachability never recomputes filepath.Dir per edge.
dirByPath := make(map[string]string)
for n := range r.graph.NodesByKind(graph.KindFile) {
dir := filepath.Dir(n.FilePath)
dirByPath[n.FilePath] = dir
addDir(n.ID, dir)
}
// Materialise the import edges and batch-load the endpoints of the
// resolved ones (e.To naming a concrete node) in one GetNodesByIDs.
// A per-edge GetNode here is a query round-trip per import on a disk
// backend — the same batching buildImportClosure already applies.
// Unresolved / external targets never name an in-repo file node, so
// they're skipped from the batch (their directory comes from dirIndex
// or not at all).
var imports []*graph.Edge
ids := make(map[string]struct{})
for e := range r.graph.EdgesByKind(graph.EdgeImports) {
imports = append(imports, e)
if e.To == "" || graph.IsUnresolvedTarget(e.To) || strings.HasPrefix(e.To, "external::") {
continue
}
ids[e.To] = struct{}{}
}
var nodes map[string]*graph.Node
if len(ids) > 0 {
idList := make([]string, 0, len(ids))
for id := range ids {
idList = append(idList, id)
}
nodes = r.graph.GetNodesByIDs(idList)
}
for _, e := range imports {
var importedDir string
switch {
case graph.IsUnresolvedTarget(e.To) && strings.HasPrefix(graph.UnresolvedName(e.To), "import::"):
path := strings.TrimPrefix(graph.UnresolvedName(e.To), "import::")
if files := r.dirIndex[path]; len(files) > 0 {
importedDir = filepath.Dir(files[0].FilePath)
} else if last := lastPathComponent(path); last != "" {
if files := r.lastDirIndex[last]; len(files) > 0 {
importedDir = filepath.Dir(files[0].FilePath)
}
}
case strings.HasPrefix(e.To, "external::"):
// External / unindexed package — nothing to add.
default:
if n := nodes[e.To]; n != nil && n.Kind == graph.KindFile {
importedDir = filepath.Dir(n.FilePath)
}
}
if importedDir != "" {
addDir(e.From, importedDir)
}
}
r.reachableDirsByFile = idx
r.dirByFilePath = dirByPath
}
func (r *Resolver) clearReachabilityIndex() {
r.reachableDirsByFile = nil
r.dirByFilePath = nil
}
// dirFor returns filepath.Dir(path), served from the per-file memo built in
// buildReachabilityIndex (every indexed file is keyed) and falling back to a
// live computation for paths not in the index. The memo turns the per-edge
// filepath.Dir in filterByReachability — ~20% of resolution CPU on a large
// monorepo — into a map lookup.
func (r *Resolver) dirFor(path string) string {
if d, ok := r.dirByFilePath[path]; ok {
return d
}
return filepath.Dir(path)
}
// filterByReachability narrows candidates to those whose defining file
// sits in a package reachable from the caller file. "Reachable" means:
// (a) same directory as the caller (same package), or (b) directory of
// a file imported via EdgeImports. Returns the original list when the
// reachability index is unavailable (e.g. resolveEdge invoked outside
// a Resolve* pass) or when no candidate is reachable — better to keep
// candidates and let downstream passes handle them than to drop the
// edge in cases where the index is incomplete.
func (r *Resolver) filterByReachability(callerFileID string, candidates []*graph.Node) []*graph.Node {
if r.reachableDirsByFile == nil || callerFileID == "" {
return candidates
}
reachable, ok := r.reachableDirsByFile[callerFileID]
if !ok || len(reachable) == 0 {
return candidates
}
out := candidates[:0:0]
for _, c := range candidates {
if _, ok := reachable[r.dirFor(c.FilePath)]; ok {
out = append(out, c)
}
}
if len(out) == 0 {
return candidates
}
return out
}
// boundImplsFor returns the set of concrete class names bound to the
// abstract type `abstractName` via @Module({ providers: [{ provide: X,
// useClass: Y }] })` entries. Keys are class names (e.g. "EmailNotifier")
// so the caller can match against nodeReceiverType of method candidates.
// Empty when no binding exists.
func (r *Resolver) boundImplsFor(abstractName string) map[string]struct{} {
if abstractName == "" || len(r.providesForIdx) == 0 {
return nil
}
return r.providesForIdx[abstractName]
}
// edgeReceiverType extracts the receiver_type from Edge.Meta, if present.
func edgeReceiverType(e *graph.Edge) string {
if e.Meta == nil {
return ""
}
if rt, ok := e.Meta["receiver_type"].(string); ok {
return rt
}
return ""
}
// nodeReceiverType extracts the receiver type from a method Node.Meta.
func nodeReceiverType(n *graph.Node) string {
if n.Meta == nil {
return ""
}
if rt, ok := n.Meta["receiver"].(string); ok {
return rt
}
return ""
}
// resolverTestPathRe-equivalent: a path-shaped test detector that the
// candidate ranker uses to demote definitions that live in test sources.
// It covers the file-suffix conventions (Go `_test.`, JS/TS `.test.` /
// `.spec.`, Python `test_` prefix) and the directory conventions that the
// JVM ecosystems use heavily and that a base-name check alone would miss
// (`src/test/`, `src/jvmTest/`, `src/androidTest/`, `__tests__/`, …).
// Kept resolver-local so the resolver does not import internal/analysis
// (which depends on graph + resolver and would form an import cycle).
func isTestSourcePath(path string) bool {
if path == "" {
return false
}
lower := strings.ToLower(path)
base := strings.ToLower(filepath.Base(path))
switch {
case strings.Contains(base, "_test."),
strings.Contains(base, ".test."),
strings.Contains(base, ".spec."),
strings.HasPrefix(base, "test_"):
return true
}
// Directory conventions. The slashes are normalised to "/" already on
// the relative paths the indexer stores; guard the leading-segment
// case too so a top-level "test/" or "tests/" dir is caught.
for _, seg := range []string{
"/test/", "/tests/", "/__tests__/", "/testing/",
"/jvmtest/", "/androidtest/", "/commontest/", "/androidhosttest/",
"/src/test/", "/test-utils/",
} {
if strings.Contains(lower, seg) {
return true
}
}
if strings.HasPrefix(lower, "test/") || strings.HasPrefix(lower, "tests/") {
return true
}
return false
}
// nodeIsExportedType reports whether a type/interface candidate is part
// of the module's public surface. Two signals, in order: an explicit
// `Meta["visibility"]` stamped by the extractor (Kotlin/TS/Swift/Java/…
// emit "public" | "private" | "internal" | "protected"), and the
// Go/Rust capitalisation convention as a fallback for languages that do
// not stamp visibility. Unknown → treated as exported, so a candidate
// that simply lacks the metadata is never demoted below an explicitly
// private one.
func nodeIsExportedType(n *graph.Node) bool {
if n.Meta != nil {
if vis, ok := n.Meta["visibility"].(string); ok && vis != "" {
switch strings.ToLower(vis) {
case "private", "internal", "fileprivate":
return false
default:
return true
}
}
}
// Capitalisation fallback (Go exported, Rust `pub` types are PascalCase
// by convention but not enforced — capitalisation is a weak signal,
// only used to break ties when no visibility metadata exists).
if n.Name == "" {
return true
}
r := rune(n.Name[0])
return r < 'a' || r > 'z'
}
// nodeIsNestedType reports whether a type candidate is a member /
// nested type rather than a top-level definition — e.g. the inner
// `Foo.Builder` rather than the top-level `Foo`. Detected from the
// dotted qualified name (`Foo.Builder`) the extractor emits for nested
// types in languages that keep the enclosing-type prefix. Languages
// that flatten nested names to the bare leaf (so `Foo.Builder` is just
// `Builder`) carry no nesting signal here — those candidates tie and
// fall through to the deterministic id-order tiebreak.
func nodeIsNestedType(n *graph.Node) bool {
return strings.Contains(n.Name, ".")
}
// typeCandidateRank scores a type/interface candidate for an
// `unresolved::Name` type-position / reference / instantiate edge so
// the resolver lands the edge on the *canonical* definition — the same
// node search_symbols returns — rather than whichever same-named rival
// (an external/stub node, a test-file or mock definition, a private /
// nested member type) happens to sort first. Higher rank wins. The
// fields are weighted most-significant-first; same-package proximity is
// folded in by the caller as a final tiebreak so a genuinely local type
// still wins among otherwise-equal candidates without letting a
// same-directory test/stub beat a cross-directory canonical def.
func typeCandidateRank(n *graph.Node) int {
rank := 0
// (1) A real, in-repo definition beats a synthetic stub / external
// placeholder by the widest margin.
if !graph.IsStub(n.ID) && !nodeIsSyntheticOrExternal(n) {
rank += 1000
}
// (2) A non-test definition beats a test/mock definition.
if !isTestSourcePath(n.FilePath) {
rank += 100
}
// (3) A top-level type beats a nested / member type.
if !nodeIsNestedType(n) {
rank += 10
}
// (4) An exported / public type beats a private / internal one.
if nodeIsExportedType(n) {
rank += 1
}
return rank
}
// nodeIsSyntheticOrExternal reports whether a node is a synthetic
// placeholder (external-call terminal, import alias, re-export shell)
// rather than a real source definition. These carry explicit Meta flags
// stamped by the synthesis passes.
func nodeIsSyntheticOrExternal(n *graph.Node) bool {
if n.Meta == nil {
return false
}
for _, k := range []string{"external", "external_call", "synthetic", "is_stub", "reexport"} {
if b, ok := n.Meta[k].(bool); ok && b {
return true
}
}
return false
}
// bestTypeCandidate picks the canonical type/interface definition from a
// candidate slice for a type-position / reference / instantiate edge.
// Candidates are ranked by typeCandidateRank (real-def > non-test >
// top-level > exported); ties are broken by same-package proximity to
// the caller's directory, then by lexicographically-smallest id for a
// stable, deterministic result across runs and backends. Returns nil
// when no KindType / KindInterface candidate exists.
func bestTypeCandidate(candidates []*graph.Node, callerDir string) *graph.Node {
var best *graph.Node
bestRank := -1
bestSameDir := false
for _, c := range candidates {
if c.Kind != graph.KindType && c.Kind != graph.KindInterface {
continue
}
rank := typeCandidateRank(c)
sameDir := filepath.Dir(c.FilePath) == callerDir
if best == nil || candidateBeats(rank, sameDir, c.ID, bestRank, bestSameDir, best.ID) {
best, bestRank, bestSameDir = c, rank, sameDir
}
}
return best
}
// candidateBeats reports whether a candidate (rank/sameDir/id) should
// replace the current best, applying the tiebreak order: higher rank,
// then same-package proximity, then lexicographically-smallest id (for a
// stable, deterministic result independent of candidate-iteration order
// across in-memory and disk backends).
func candidateBeats(rank int, sameDir bool, id string, bestRank int, bestSameDir bool, bestID string) bool {
if rank != bestRank {
return rank > bestRank
}
if sameDir != bestSameDir {
return sameDir
}
return id < bestID
}
// memberMethodInfosByType returns the storage layer's per-type member
// method projection verbatim. Routed through MemberMethodsByType when
// the backend implements it; falls back to an EdgesByKind +
// per-edge GetNode walk that synthesises matching info rows.
func memberMethodInfosByType(g graph.Store) map[string][]graph.MemberMethodInfo {
if cap, ok := g.(graph.MemberMethodsByType); ok {
return cap.MemberMethodsByType()
}
out := map[string][]graph.MemberMethodInfo{}
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
method := g.GetNode(e.From)
if method == nil || method.Kind != graph.KindMethod {
continue
}
out[e.To] = append(out[e.To], graph.MemberMethodInfo{
MethodID: method.ID,
Name: method.Name,
FilePath: method.FilePath,
StartLine: method.StartLine,
RepoPrefix: method.RepoPrefix,
})
}
return out
}
// edgesByKinds yields every edge whose Kind is in the given set,
// using the EdgesByKindsScanner capability when the backend
// implements it (one query — an IN-list scan) and falling back to a
// chain of per-kind EdgesByKind iterators otherwise.
func edgesByKinds(g graph.Store, kinds []graph.EdgeKind) iter.Seq[*graph.Edge] {
if scan, ok := g.(graph.EdgesByKindsScanner); ok {
return scan.EdgesByKinds(kinds)
}
return func(yield func(*graph.Edge) bool) {
for _, k := range kinds {
for e := range g.EdgesByKind(k) {
if !yield(e) {
return
}
}
}
}
}
// nodesByKindsOrAll returns every node whose Kind is in the given
// set, using the NodesByKindsScanner capability when the backend
// implements it (a single kind-IN scan) and falling back to
// AllNodes + Go-side filter otherwise.
func nodesByKindsOrAll(g graph.Store, kinds ...graph.NodeKind) []*graph.Node {
if scan, ok := g.(graph.NodesByKindsScanner); ok {
return scan.NodesByKinds(kinds)
}
set := make(map[graph.NodeKind]struct{}, len(kinds))
for _, k := range kinds {
set[k] = struct{}{}
}
var out []*graph.Node
for _, n := range g.AllNodes() {
if n == nil {
continue
}
if _, ok := set[n.Kind]; ok {
out = append(out, n)
}
}
return out
}
// memberMethodsByType returns typeID → method-name-set for every
// EdgeMemberOf edge whose source is a KindMethod node. Routed through
// the storage layer's MemberMethodsByType capability when the backend
// implements it (one query — a join, server-side), falling back to the
// EdgesByKind + per-edge GetNode loop the resolver used before the
// capability landed. Used by InferImplements (and shaped to match its
// existing map[string]map[string]bool API).
func memberMethodsByType(g graph.Store) map[string]map[string]bool {
if cap, ok := g.(graph.MemberMethodsByType); ok {
raw := cap.MemberMethodsByType()
if len(raw) == 0 {
return nil
}
out := make(map[string]map[string]bool, len(raw))
for typeID, methods := range raw {
set := make(map[string]bool, len(methods))
for _, m := range methods {
set[m.Name] = true
}
out[typeID] = set
}
return out
}
out := map[string]map[string]bool{}
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
methodNode := g.GetNode(e.From)
if methodNode == nil || methodNode.Kind != graph.KindMethod {
continue
}
if out[e.To] == nil {
out[e.To] = make(map[string]bool)
}
out[e.To][methodNode.Name] = true
}
return out
}
// memberMethodNodesByType returns typeID → name → method-node for
// every EdgeMemberOf edge whose source is a KindMethod node. Routed
// through the storage layer's MemberMethodsByType capability when the
// backend implements it (the projection ships only the four columns
// the consumer reads — ID / Name / FilePath / StartLine — packed into
// a synthetic *Node that carries no Meta / QualName / Language); falls
// back to the EdgesByKind + per-edge GetNode loop otherwise. Used by
// InferOverrides which keys methods by name and reads ID/FilePath/
// StartLine off the node when it emits an EdgeOverrides edge.
func memberMethodNodesByType(g graph.Store) map[string]map[string]*graph.Node {
if cap, ok := g.(graph.MemberMethodsByType); ok {
raw := cap.MemberMethodsByType()
if len(raw) == 0 {
return nil
}
out := make(map[string]map[string]*graph.Node, len(raw))
for typeID, methods := range raw {
set := make(map[string]*graph.Node, len(methods))
for _, m := range methods {
set[m.Name] = &graph.Node{
ID: m.MethodID,
Kind: graph.KindMethod,
Name: m.Name,
FilePath: m.FilePath,
StartLine: m.StartLine,
RepoPrefix: m.RepoPrefix,
}
}
out[typeID] = set
}
return out
}
out := map[string]map[string]*graph.Node{}
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
method := g.GetNode(e.From)
if method == nil || method.Kind != graph.KindMethod {
continue
}
set := out[e.To]
if set == nil {
set = make(map[string]*graph.Node)
out[e.To] = set
}
set[method.Name] = method
}
return out
}
// structuralParentEdges returns every EdgeExtends / EdgeImplements /
// EdgeComposes edge whose endpoints are both KindType / KindInterface,
// projected as the (FromID, ToID, Origin) tuples InferOverrides
// consumes. Routed through the storage layer's StructuralParentEdges
// capability when the backend implements it (one query — a join with
// kind filters on both sides — no per-edge GetNode); falls back to
// the AllEdges + per-edge GetNode walk otherwise.
func structuralParentEdges(g graph.Store) []graph.StructuralParentEdgeRow {
if cap, ok := g.(graph.StructuralParentEdges); ok {
return cap.StructuralParentEdges()
}
parentKinds := map[graph.EdgeKind]bool{
graph.EdgeExtends: true,
graph.EdgeImplements: true,
graph.EdgeComposes: true,
}
var out []graph.StructuralParentEdgeRow
for _, e := range g.AllEdges() {
if e == nil || !parentKinds[e.Kind] {
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
if from.Kind != graph.KindType && from.Kind != graph.KindInterface {
continue
}
if to.Kind != graph.KindType && to.Kind != graph.KindInterface {
continue
}
out = append(out, graph.StructuralParentEdgeRow{
FromID: from.ID,
ToID: to.ID,
FromKind: from.Kind,
ToKind: to.Kind,
Origin: e.Origin,
})
}
return out
}
// InferImplements detects structural interface satisfaction by comparing
// method sets and adds EdgeImplements edges from types to interfaces.
// Returns the number of edges added.
func (r *Resolver) InferImplements() int { return r.inferImplements(nil, nil) }
// InferImplementsScoped re-derives EdgeImplements only for (type, interface)
// pairs where the type or the interface is in the affected set — used by
// incremental reindex to avoid the whole-graph type×interface cross product.
// It is add-parity with the full pass: an inferred edge is only ever dropped
// when one of its endpoints' file is evicted, so re-checking every pair with an
// affected endpoint re-lands exactly the dropped edges while the survivors are
// left untouched. Empty scope maps mean "nothing affected" → zero work.
func (r *Resolver) InferImplementsScoped(scopeTypes, scopeIfaces map[string]bool) int {
return r.inferImplements(scopeTypes, scopeIfaces)
}
func (r *Resolver) inferImplements(scopeTypes, scopeIfaces map[string]bool) int {
// Step 1: Collect all interfaces with their required method names.
type ifaceInfo struct {
id string
repo string
methods map[string]bool
}
var ifaces []ifaceInfo
for n := range r.graph.NodesByKind(graph.KindInterface) {
if n.Meta == nil {
continue
}
raw, ok := n.Meta["methods"]
if !ok {
continue
}
// Meta["methods"] may be []string or []any (after JSON round-trip).
methodSet := make(map[string]bool)
switch v := raw.(type) {
case []string:
for _, m := range v {
methodSet[m] = true
}
case []any:
for _, m := range v {
if s, ok := m.(string); ok {
methodSet[s] = true
}
}
}
if len(methodSet) == 0 {
continue
}
ifaces = append(ifaces, ifaceInfo{id: n.ID, repo: n.RepoPrefix, methods: methodSet})
}
if len(ifaces) == 0 {
return 0
}
// Step 2: Build map of type ID -> set of method names via EdgeMemberOf edges.
typeMethods := memberMethodsByType(r.graph)
// Step 3: For each type, check if its method set satisfies each interface.
//
// The (types × interfaces) cross product is embarrassingly parallel —
// each type's check is independent and the only write is an AddEdge
// at the end. We chunk types across NumCPU workers, collect pair
// results into per-worker slices, and apply them serially at the end
// (AddEdge contends on Graph mutation internally). On large repos
// like vscode this cuts InferImplements wall time roughly N×.
type pair struct {
typeID, ifaceID, filePath string
line int
}
typeList := make([]string, 0, len(typeMethods))
for tid := range typeMethods {
typeList = append(typeList, tid)
}
// Prefetch every type node referenced by EdgeMemberOf in one batch
// before the workers spin up — on disk backends a per-worker
// GetNode(typeID) was an N+1 over cgo that the workers' parallelism
// could not hide.
typeNodes := r.graph.GetNodesByIDs(typeList)
workers := runtime.NumCPU()
if workers < 1 {
workers = 1
}
if workers > len(typeList) {
workers = len(typeList)
}
if workers == 0 {
return 0
}
results := make([][]pair, workers)
var wg sync.WaitGroup
chunk := (len(typeList) + workers - 1) / workers
for w := 0; w < workers; w++ {
start := w * chunk
end := start + chunk
if end > len(typeList) {
end = len(typeList)
}
if start >= end {
continue
}
wg.Add(1)
go func(idx int, slice []string) {
defer wg.Done()
var out []pair
for _, typeID := range slice {
methods := typeMethods[typeID]
typeNode := typeNodes[typeID]
if typeNode == nil || (typeNode.Kind != graph.KindType && typeNode.Kind != graph.KindInterface) {
continue
}
for _, iface := range ifaces {
if iface.id == typeID {
continue
}
// Scoped incremental: only re-check pairs touching an
// affected type or a changed interface.
if scopeTypes != nil && !scopeTypes[typeID] && !scopeIfaces[iface.id] {
continue
}
// Repo gate: structural method-set matching across
// repos is almost always coincidental — every type
// with a `String()` method would "implement" every
// other repo's Stringer-shaped interface. Only infer
// implementation when the type and the interface
// live in the same repo. A genuine cross-repo
// implementation still surfaces structurally when
// the type explicitly embeds / names the interface.
if typeNode.RepoPrefix != iface.repo {
continue
}
satisfies := true
for m := range iface.methods {
if !methods[m] {
satisfies = false
break
}
}
if satisfies {
out = append(out, pair{
typeID: typeID,
ifaceID: iface.id,
filePath: typeNode.FilePath,
line: typeNode.StartLine,
})
}
}
}
results[idx] = out
}(w, typeList[start:end])
}
wg.Wait()
// A declared base list often resolves to the very (From, To, FilePath,
// Line) tuple the inference would mint — C# puts the base list on the
// declaration line — and AddEdge replaces in place on an identical key, so
// re-minting would CLOBBER the declared edge with an inference-marked
// copy. Never overwrite a declared fact with a guess: skip pairs that
// already exist without the marker.
declared := map[string]bool{}
for e := range r.graph.EdgesByKind(graph.EdgeImplements) {
if e == nil {
continue
}
if e.Meta != nil && e.Meta["via"] == MetaViaMethodSetInference {
continue
}
declared[e.From+"\x00"+e.To] = true
}
added := 0
for _, chunkResults := range results {
for _, p := range chunkResults {
if declared[p.typeID+"\x00"+p.ifaceID] {
continue
}
r.graph.AddEdge(&graph.Edge{
From: p.typeID,
To: p.ifaceID,
Kind: graph.EdgeImplements,
FilePath: p.filePath,
Line: p.line,
// Mark the edge as structurally inferred so hierarchy-walking
// consumers can tell a method-set guess from a source-declared
// base list. A one-method interface (Convert) makes every
// same-named type "implement" it — fine for discovery
// surfaces, poison for passes that union call sites across an
// implements-family.
Meta: map[string]any{"via": MetaViaMethodSetInference},
})
added++
}
}
return added
}
// InferOverrides materialises EdgeOverrides edges from method-name
// matches between a type and its supertype. Walks every type that has
// at least one EdgeExtends/EdgeImplements/EdgeComposes outgoing edge,
// then for every member of the type emits an EdgeOverrides edge to a
// matching member on the supertype (matched by name). Returns the
// number of new edges added.
//
// Origin tier is ast_resolved when the supertype edge itself was
// ast_resolved (extractor confirmed parent in the same compilation
// unit); ast_inferred when the supertype edge was inferred from name
// (e.g. InferImplements above); preserved when the parent edge was
// already lsp_resolved/lsp_dispatch (the LSP enrichment path stamps
// EdgeOverrides directly with that origin).
//
// This is the AST half of override inference — works without an LSP available.
func (r *Resolver) InferOverrides() int { return r.inferOverrides(nil) }
// InferOverridesScoped re-derives EdgeOverrides only for parent-edge rows whose
// child or parent type is in the affected set (add-parity with the full pass,
// same reasoning as InferImplementsScoped). Empty scope → zero work.
func (r *Resolver) InferOverridesScoped(scope map[string]bool) int { return r.inferOverrides(scope) }
func (r *Resolver) inferOverrides(scope map[string]bool) int {
r.mu.Lock()
defer r.mu.Unlock()
// Step 1: index methods by their owning type via EdgeMemberOf.
typeMembers := memberMethodNodesByType(r.graph) // typeID → name → method node
if len(typeMembers) == 0 {
return 0
}
// Step 2: for every (child → parent) extends/implements/composes
// edge, walk the child's methods and emit EdgeOverrides where the
// parent has a same-named method. Skip if the override edge
// already exists.
type overridePair struct {
from, to *graph.Node
origin string
}
var pending []overridePair
for _, row := range structuralParentEdges(r.graph) {
if row.FromID == row.ToID {
continue
}
// Scoped incremental: only re-check parent edges touching an affected type.
if scope != nil && !scope[row.FromID] && !scope[row.ToID] {
continue
}
childMethods := typeMembers[row.FromID]
parentMethods := typeMembers[row.ToID]
if len(childMethods) == 0 || len(parentMethods) == 0 {
continue
}
// Origin selection: track the parent edge's confidence into
// the override edge so blast-radius queries can filter by
// min_tier consistently.
origin := graph.OriginASTInferred
if row.Origin == graph.OriginASTResolved {
origin = graph.OriginASTResolved
} else if rank := graph.OriginRank(row.Origin); rank >= graph.OriginRank(graph.OriginLSPDispatch) {
origin = row.Origin
}
for name, cm := range childMethods {
pm, ok := parentMethods[name]
if !ok || pm.ID == cm.ID {
continue
}
pending = append(pending, overridePair{from: cm, to: pm, origin: origin})
}
}
added := 0
var provBatch []graph.EdgeProvenanceUpdate
for _, p := range pending {
// Skip when the edge already exists.
dup := false
for _, existing := range r.graph.GetOutEdges(p.from.ID) {
if existing.Kind == graph.EdgeOverrides && existing.To == p.to.ID {
dup = true
// Upgrade the provenance of the existing override edge
// through SetEdgeProvenanceBatch so the identity change
// is counted — a bare existing.Origin write would
// bypass the revision counter. Batched so a large
// hierarchy pass commits its provenance bumps in
// chunks on disk backends.
if graph.OriginRank(existing.Origin) < graph.OriginRank(p.origin) {
provBatch = append(provBatch, graph.EdgeProvenanceUpdate{Edge: existing, NewOrigin: p.origin})
}
break
}
}
if dup {
continue
}
r.graph.AddEdge(&graph.Edge{
From: p.from.ID,
To: p.to.ID,
Kind: graph.EdgeOverrides,
FilePath: p.from.FilePath,
Line: p.from.StartLine,
Confidence: 1.0,
ConfidenceLabel: "EXTRACTED",
Origin: p.origin,
})
added++
}
if len(provBatch) > 0 {
r.graph.SetEdgeProvenanceBatch(provBatch)
}
return added
}
func lastPathComponent(path string) string {
parts := strings.Split(path, "/")
if len(parts) == 0 {
return path
}
return parts[len(parts)-1]
}
// dirMatchesImport reports whether the (repo-relative) directory dir
// genuinely corresponds to importPath. Unlike a bare last-path-component
// match, dir must be a real *suffix* of the import path — so
// `tree-sitter-c/bindings/go` matches `github.com/x/tree-sitter-c/bindings/go`
// but `tree-sitter-dockerfile/bindings/go` does not. This is the
// precision gate that stops a loose `*/go` match from resolving every
// tree-sitter binding to whichever repo happens to sort first.
//
// Used only to authorise *cross-repo* candidates: a precise import-path
// match is real evidence the caller depends on that repo. Same-repo
// candidates don't need it — a same-repo match can't be the cross-repo
// false positive this guards against.
func dirMatchesImport(dir, importPath string) bool {
if dir == "" || importPath == "" {
return false
}
return dir == importPath || strings.HasSuffix(importPath, "/"+dir)
}
// callerRepoPrefix returns the RepoPrefix of the node that owns the edge's From field.
func (r *Resolver) callerRepoPrefix(e *graph.Edge) string {
// cachedGetNode: the pre-warm batch-loads every pending edge's From
// id, so this is a map hit during ResolveAll instead of one GetNode
// query per edge.
fromNode := r.cachedGetNode(e.From)
if fromNode != nil {
return fromNode.RepoPrefix
}
return ""
}