项目文件夹

文件
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 行
149 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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 ""
}