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:: 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 // (`::unresolved::Name`) and the general stub (`::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 // "::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:: 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::::`, 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:: 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::` // 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::` 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::` 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 `::` // 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 `::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::::`). 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::`). 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 (`