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
3977 lines
149 KiB
Go
3977 lines
149 KiB
Go
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 ""
|
||
}
|