package resolver import ( "os" "path/filepath" "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "go.uber.org/zap" "github.com/zzet/gortex/internal/graph" ) // CrossRepoStats holds counts from a cross-repo resolution pass. type CrossRepoStats struct { Resolved int `json:"resolved"` Unresolved int `json:"unresolved"` CrossRepoEdges int `json:"cross_repo_edges"` ByRepo map[string]int `json:"by_repo"` } // CrossWorkspaceDepRule names one allowed dependency from a source // workspace into another. Mirrors config.CrossWorkspaceDep but lives // here so the resolver doesn't import internal/config (avoids a cycle // once future steps wire workspace plumbing through manager.go). type CrossWorkspaceDepRule struct { // Workspace is the *target* workspace slug — the workspace whose // nodes are eligible to be referenced from the source workspace. Workspace string // Modules is the list of import-path prefixes that the source // workspace is allowed to follow into the target. Iteration 1 // only supports prefix-style matches (longest prefix wins). Modules []string } // CrossWorkspaceDepLookup returns the list of declared cross-workspace // dependencies for a *source* workspace. Empty / nil result means the // source workspace has no declared cross-workspace deps and so the // resolver must keep cross-workspace candidates ineligible. type CrossWorkspaceDepLookup func(sourceWorkspaceID string) []CrossWorkspaceDepRule // CrossRepoResolver resolves unresolved edges across repository boundaries. // // dirIndex / lastDirIndex are scratch maps populated for the duration // of a single Resolve* pass — they let resolveImport look up candidate // file nodes by directory in O(1) instead of scanning the whole graph // (which is O(N) per import edge, O(N×M) total). Maps are nil between // passes so we don't pay the memory cost while idle. // // mu is the graph-wide resolver lock shared with every Resolver built // from the same Graph. Private to CrossRepoResolver wasn't enough: // MultiWatcher.forwardEvents calls ResolveForRepo while the per-repo // Watcher's debounce timer concurrently calls Resolver.ResolveFile, // and both paths iterate graph.AllEdges() / AllNodes() and mutate // Edge.To in place. Sharing g.ResolveMutex() serialises both resolver // types against the same graph. // // crossWorkspaceLookup is the workspace-boundary check. Empty (nil) // means the resolver is in legacy mode: cross-repo / cross-workspace // candidates resolve as if no boundary existed — for callers that // haven't plumbed config through yet. When set, candidates whose // WorkspaceID differs from // the caller's are accepted only when the source workspace declared // the target workspace via `cross_workspace_deps` AND, for import // edges, the import path has a declared-module prefix. type CrossRepoResolver struct { graph graph.Store // nodeByID / nodesByName: per-pass batched lookup cache, the // cross-repo mirror of the fields on Resolver (resolver.go). // Populated by warmLookupCache before the per-edge fan-out and // cleared on return; cachedGetNode / cachedFindNodesByName consult // them first. Without it the cross-repo pass fires one // GetNode/FindNodesByName query per pending edge — across 200k+ // unresolved edges that is a warmup hang on disk backends. logger *zap.Logger nodeByID map[string]*graph.Node nodesByName map[string][]*graph.Node nodesByNameRepo map[string]map[string][]*graph.Node nodesByQualName map[string]*graph.Node dirIndex map[string][]*graph.Node lastDirIndex map[string][]*graph.Node // reachableReposByFile maps a caller file's ID to the set of repo // prefixes that file imports (derived from resolved EdgeImports // edges). It is the import-reachability evidence gate: a name-only // cross-repo function/method/type candidate is eligible only when // the caller's file actually imports the candidate's repo. Without // it, `FindNodesByName` spanning a multi-repo graph resolves short // common names (`len`, `string`, `Language`, `set`) to whichever // repo sorts first — the name-collision false positives the analyzer // surfaced. Built once per Resolve* pass, torn down after. reachableReposByFile map[string]map[string]struct{} // depModuleIndex bridges Go imports to dep:: contract // nodes from the caller's go.mod. Same shape and rationale as // the field of the same name on Resolver — see resolver.go for // the full doc. Cross-repo always scopes by callerRepo, so a // dep declared by repo A's go.mod never satisfies an import in // repo B even if the module path matches. depModuleIndex map[string][]depModuleEntry mu *sync.Mutex // validateLiveness turns on the concurrent-edit guards in resolveEdge. // Set only on the chunked resolve path (ResolveAll with chunking), where // the pass releases mu between chunks so an interactive single-file edit // can interleave and evict nodes/edges the once-built per-pass indexes // still reference. With it on, resolveEdge skips an edge that is no // longer live (reindexing an evicted edge half-resurrects it and can // panic) and refuses a resolution whose target node was evicted (a // dangling edge). Off (the default, and the whole-pass-locked path) it is // a no-op: nothing can mutate the graph mid-pass, so every edge and // candidate is live by construction. validateLiveness bool crossWorkspaceLookup CrossWorkspaceDepLookup // npmAlias rewrites a JS/TS import specifier that matches an // npm-alias dependency key in the importing file's nearest- // ancestor package.json. Same contract as the field of the // same name on Resolver — see npm_alias.go. npmAlias NpmAliasResolver // pathAlias expands a JS/TS tsconfig/jsconfig path-alias / baseUrl // import specifier to the repo-prefixed file stem it targets. Same // contract as the field of the same name on Resolver — see // jsts_imports.go. pathAlias PathAliasResolver // workspaceMembers maps a file path to the package-manager // workspace it belongs to, used to prefer a same-workspace // candidate on a same-named import collision. Same contract as // the field of the same name on Resolver — see // workspace_membership.go. workspaceMembers WorkspaceMembership // Cross-daemon proxy-edge minting (off by default). When edgesEnabled // and prober != nil, a function call that local resolution leaves // unresolved is, as a last resort, stitched to a proxy node standing // in for a symbol a remote daemon owns — but only on positive remote // evidence (a find_declaration hit AND a non-empty import hint). edgesEnabled bool prober RemoteDeclarationProber proxyBudget int } // NewCrossRepo creates a CrossRepoResolver for the given graph. func NewCrossRepo(g graph.Store) *CrossRepoResolver { return &CrossRepoResolver{graph: g, mu: g.ResolveMutex(), logger: zap.NewNop()} } // SetLogger attaches a logger so ResolveAll emits pass progress (the // cross-repo mirror of Resolver.SetLogger). A nil logger becomes a no-op. func (cr *CrossRepoResolver) SetLogger(l *zap.Logger) { if l == nil { l = zap.NewNop() } cr.logger = l } // SetCrossWorkspaceDepLookup wires the boundary rule. After this // call, the resolver will refuse cross-workspace candidates that // aren't covered by an explicit declaration in the source workspace's // `cross_workspace_deps`. Legacy graphs (no WorkspaceID on either // side) keep working — when both From and To carry empty workspace // slugs the boundary check trivially passes. func (cr *CrossRepoResolver) SetCrossWorkspaceDepLookup(lookup CrossWorkspaceDepLookup) { cr.crossWorkspaceLookup = lookup } // callerWorkspaceID returns the workspace slug for the From-side of // an edge. Falls back to RepoPrefix to match Contract.Effective- // Workspace's "missing → repo-name" rule. func (cr *CrossRepoResolver) callerWorkspaceID(e *graph.Edge) string { from := cr.cachedGetNode(e.From) if from == nil { return "" } if from.WorkspaceID != "" { return from.WorkspaceID } return from.RepoPrefix } // candidateWorkspaceID extracts the same slug from a candidate node. func candidateWorkspaceID(n *graph.Node) string { if n == nil { return "" } if n.WorkspaceID != "" { return n.WorkspaceID } return n.RepoPrefix } // crossWorkspaceEligible reports whether sourceWS is permitted to // reach a candidate in targetWS, optionally constrained by the // candidate's import path. importPath == "" means "any module" // (function/method calls — they don't carry an import path so the // only check is workspace-pair declaration). func (cr *CrossRepoResolver) crossWorkspaceEligible(sourceWS, targetWS, importPath string) bool { if sourceWS == targetWS { return true } if cr.crossWorkspaceLookup == nil { // Legacy / unwired callers: no boundary enforcement. return true } rules := cr.crossWorkspaceLookup(sourceWS) for _, rule := range rules { if rule.Workspace != targetWS { continue } if importPath == "" { // Function/method call into a declared cross-workspace // dep is allowed once the workspace pair is declared — // iteration 1 doesn't try to require an import-path // match for non-import edges. return true } for _, m := range rule.Modules { if m == importPath || strings.HasPrefix(importPath, m+"/") { return true } } } return false } // pickImportCandidate chooses the best cross-repo file candidate for an // import: a candidate in the importer's own workspace wins outright; // otherwise the first candidate the cross-workspace policy permits is // used. Returns nil when no candidate clears the boundary, so the caller // falls through to its dep-module / external handling. // // This replaces the old "first dir match, then a single boundary check // that bailed to external" rule, which mis-resolved when two same-named // modules live in different workspaces — the worktree-instance case, // where the importer's workspace has its own copy of the imported module // but the canonical copy (in another workspace) sorted first. func (cr *CrossRepoResolver) pickImportCandidate(callerWS, importPath string, candidates []*graph.Node) *graph.Node { for _, c := range candidates { if candidateWorkspaceID(c) == callerWS { return c } } for _, c := range candidates { if cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), importPath) { return c } } return nil } // pickQualNameCandidate enumerates every node that shares qualName and // returns the one in the caller's own workspace, else the first the // cross-workspace policy permits, else nil. The graph's qual-name index // is single-valued, so when the same module is checked out twice (a // canonical checkout plus a worktree instance under its own prefix) only // one node is reachable by qual name; the two share a Name, so the // multi-valued by-name index recovers the full candidate set. `single` // is the node the single-valued lookup already returned — used to learn // the shared Name without a second qual-name probe. func (cr *CrossRepoResolver) pickQualNameCandidate(callerWS, qualName string, single *graph.Node) *graph.Node { if single == nil || single.Name == "" { return nil } all := cr.graph.FindNodesByNames([]string{single.Name})[single.Name] var cands []*graph.Node for _, c := range all { if c != nil && c.QualName == qualName { cands = append(cands, c) } } for _, c := range cands { if candidateWorkspaceID(c) == callerWS { return c } } for _, c := range cands { if cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), qualName) { return c } } return nil } // ResolveAll resolves all unresolved edges in the graph, trying same-repo // matches first, then cross-repo search. Sets Edge.CrossRepo = true for // cross-repo matches. func (cr *CrossRepoResolver) ResolveAll() *CrossRepoStats { cr.mu.Lock() defer cr.mu.Unlock() cr.buildDirIndexes() defer cr.clearDirIndexes() cr.buildDepModuleIndex() defer cr.clearDepModuleIndex() cr.buildReachableReposIndex() defer cr.clearReachableReposIndex() stats := &CrossRepoStats{ByRepo: make(map[string]int)} // Predicate-shaped read: disk backends only enumerate the // "unresolved::*" slice (the only one this pass mutates). Batch // mutations to commit in chunks at the end. // Materialise the pending slice once so warmLookupCache can batch // the per-edge GetNode / FindNodesByName the cascade would otherwise // fire serially (the cross-repo warmup storm on disk backends). var pending []*graph.Edge for e := range cr.graph.EdgesWithUnresolvedTarget() { pending = append(pending, e) } cr.warmLookupCache(pending) defer cr.clearLookupCache() passStart := time.Now() cr.logger.Info("cross-repo resolve: pass start", zap.Int("pending", len(pending))) 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: cr.logger.Info("cross-repo resolve: compute progress", zap.Int64("processed", processed.Load()), zap.Int("pending", len(pending)), zap.Duration("elapsed", time.Since(passStart))) } } }() // Resolve concurrently across NumCPU workers, mirroring the master // Resolver's pool. Each edge is touched by exactly one worker (disjoint // chunks); the per-pass caches/indexes are read-only here; each worker // accumulates into its OWN batch + stats — so no shared mutable state is // written concurrently. Batches are concatenated and applied once after // the barrier (cr never reindexes per-edge mid-loop, so unlike the // master pool no edge clone is needed); stats are summed. // Chunked resolve: process pending in super-chunks, releasing the resolve // mutex between chunks so an interactive single-file edit can interleave // instead of waiting out the whole pass. Each super-chunk's compute+apply // runs entirely under the lock (atomic, fresh reads); only the inter-chunk // gap is unlocked, where the pass holds no partial graph state. resolveEdge's // liveness guards (cr.validateLiveness) skip any edge/candidate a yielded // edit evicted. GORTEX_RESOLVE_CHUNK=0 restores one chunk == the prior // whole-pass-locked behaviour. cr.validateLiveness = resolveChunkEnabled() superChunk := len(pending) if cr.validateLiveness { if sz := resolveChunkSize(); sz < superChunk { superChunk = sz } } if superChunk < 1 { superChunk = 1 } reindexTotal := 0 for base := 0; base < len(pending); base += superChunk { hi := base + superChunk if hi > len(pending) { hi = len(pending) } sc := pending[base:hi] workers := runtime.NumCPU() if workers > len(sc) { workers = len(sc) } if workers < 1 { workers = 1 } perWorkerBatch := make([][]graph.EdgeReindex, workers) perWorkerStats := make([]*CrossRepoStats, workers) var wg sync.WaitGroup chunk := (len(sc) + workers - 1) / workers for w := 0; w < workers; w++ { start := w * chunk end := start + chunk if end > len(sc) { end = len(sc) } if start >= end { continue } wg.Add(1) go func(idx int, slice []*graph.Edge) { defer wg.Done() ws := &CrossRepoStats{ByRepo: make(map[string]int)} var batch []graph.EdgeReindex for _, e := range slice { cr.resolveEdge(e, ws, &batch) processed.Add(1) } perWorkerStats[idx] = ws perWorkerBatch[idx] = batch }(w, sc[start:end]) } wg.Wait() var scBatch []graph.EdgeReindex for i := range perWorkerBatch { scBatch = append(scBatch, perWorkerBatch[i]...) } for _, ws := range perWorkerStats { if ws == nil { continue } stats.Resolved += ws.Resolved stats.Unresolved += ws.Unresolved stats.CrossRepoEdges += ws.CrossRepoEdges for repo, n := range ws.ByRepo { stats.ByRepo[repo] += n } } if len(scBatch) > 0 { if cr.validateLiveness { scBatch = cr.filterLiveReindex(scBatch) } cr.graph.ReindexEdges(scBatch) reindexTotal += len(scBatch) } // Hand the resolve mutex to any waiting interactive edit before the // next chunk. Held continuously within a chunk; released only here, // where the pass holds no partial graph state. if cr.validateLiveness && hi < len(pending) { cr.mu.Unlock() runtime.Gosched() cr.mu.Lock() } } close(progressDone) cr.logger.Info("cross-repo resolve: compute done", zap.Int("pending", len(pending)), zap.Int("reindex_batch", reindexTotal), zap.Int("super_chunk", superChunk), zap.Duration("elapsed", time.Since(passStart))) // Materialise the cross_repo_* edge layer over the freshly lifted // calls / implements / extends edges. DetectCrossRepoEdges(cr.graph) return stats } // ResolveForRepo resolves only unresolved edges originating from nodes // in the specified repository. func (cr *CrossRepoResolver) ResolveForRepo(repoPrefix string) *CrossRepoStats { cr.mu.Lock() defer cr.mu.Unlock() // One backend query for every out-edge from this repo's nodes, // instead of GetRepoNodes followed by GetOutEdges per node. On // disk backends (SQLite, DuckDB) the per-node loop // was O(repo_nodes) round-trips per pass — single-digit minutes // of warmup on a multi-repo workspace where this method runs // once per tracked repo. return cr.resolveScopedLocked(cr.graph.GetRepoEdges(repoPrefix)) } // ResolveForFile is the watcher fast path: it re-resolves only the // out-edges of the changed file, not the whole repo. The watcher fires // after every single-file save, and the old ResolveForRepo path // materialised the repo's ENTIRE edge set (hundreds of thousands of // edges, each with its meta blob) on every keystroke-save — the // dominant per-edit allocation flood and the cause of the // "buffer pool is full" crash on a small resident pool. Scoping to the // changed file's edges turns that into a GetFileNodes lookup plus one // batched GetOutEdgesByNodeIDs, bounded by the file's size. // // relPath must be the repo-relative graph key — callers convert an // absolute watcher path via Indexer.RelKey first. A path matching no // nodes is a no-op. // // Scope note: this resolves edges the changed file OWNS. A new // definition in this file that would resolve some OTHER file's pending // unresolved edge (inbound resolution) is not re-checked here — that // case is rare, self-heals when the referencing file is next touched, // and is swept up by the periodic full ResolveAll. ResolveForRepo // remains for warmup / global recompute. func (cr *CrossRepoResolver) ResolveForFile(repoPrefix, relPath string) *CrossRepoStats { cr.mu.Lock() defer cr.mu.Unlock() nodes := cr.graph.GetFileNodes(relPath) if len(nodes) == 0 { return &CrossRepoStats{ByRepo: make(map[string]int)} } ids := make([]string, 0, len(nodes)) for _, n := range nodes { if n != nil { ids = append(ids, n.ID) } } var edges []*graph.Edge for _, es := range cr.graph.GetOutEdgesByNodeIDs(ids) { edges = append(edges, es...) } return cr.resolveScopedLocked(edges) } // resolveScopedLocked lifts every unresolved target among edges to its // real cross-repo node, then materialises the cross_repo_* parallel-edge // layer. Shared by ResolveForRepo (whole-repo edge set) and // ResolveForFile (one changed file's out-edges). Caller holds cr.mu. func (cr *CrossRepoResolver) resolveScopedLocked(edges []*graph.Edge) *CrossRepoStats { cr.buildDirIndexes() defer cr.clearDirIndexes() cr.buildDepModuleIndex() defer cr.clearDepModuleIndex() cr.buildReachableReposIndex() defer cr.clearReachableReposIndex() stats := &CrossRepoStats{ByRepo: make(map[string]int)} var reindexBatch []graph.EdgeReindex for _, e := range edges { if e == nil || !strings.HasPrefix(e.To, unresolvedPrefix) { continue } cr.resolveEdge(e, stats, &reindexBatch) } if len(reindexBatch) > 0 { cr.graph.ReindexEdges(reindexBatch) } // Materialise the cross_repo_* edge layer. The pass is graph-wide // (cheap relative to a resolve pass) so an edge into repoPrefix // from another repo — lifted when that other repo was resolved — // also picks up its parallel edge once repoPrefix's nodes exist. DetectCrossRepoEdges(cr.graph) return stats } // buildDirIndexes walks the graph once and populates two lookup maps // used by resolveImport — the only resolution path that previously // scanned every node per edge. // // - dirIndex keys on filepath.Dir(file.FilePath) for exact matches // (importPath equal to the file's directory). // - lastDirIndex keys on the last path component of that directory, // covering the common case where an import path is a single name // like "logger" and we want any file under .../logger/. // // These maps are torn down via clearDirIndexes when the pass completes // so we don't keep ~N pointers alive between resolves. func (cr *CrossRepoResolver) buildDirIndexes() { cr.dirIndex = make(map[string][]*graph.Node, 128) cr.lastDirIndex = make(map[string][]*graph.Node, 128) for n := range cr.graph.NodesByKind(graph.KindFile) { dir := filepath.Dir(n.FilePath) cr.dirIndex[dir] = append(cr.dirIndex[dir], n) last := lastPathComponent(dir) if last != "" && last != dir { cr.lastDirIndex[last] = append(cr.lastDirIndex[last], n) } } } // buildDepModuleIndex mirrors Resolver.buildDepModuleIndex — see that // method for the full rationale. Cross-repo always scopes the lookup // by callerRepo, so the same dep node reachable here is the one in the // importing file's own go.mod. func (cr *CrossRepoResolver) buildDepModuleIndex() { by := make(map[string][]depModuleEntry) for n := range cr.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) }) } cr.depModuleIndex = by } func (cr *CrossRepoResolver) clearDepModuleIndex() { cr.depModuleIndex = nil } // lookupDepModule returns the dep:: contract node whose // module path is a prefix of importPath, scoped to callerRepo. func (cr *CrossRepoResolver) lookupDepModule(callerRepo, importPath string) *graph.Node { for _, entry := range cr.depModuleIndex[callerRepo] { if importPath == entry.modulePath || strings.HasPrefix(importPath, entry.modulePath+"/") { return entry.node } } return nil } func (cr *CrossRepoResolver) clearDirIndexes() { cr.dirIndex = nil cr.lastDirIndex = nil } // buildReachableReposIndex walks every resolved EdgeImports edge and // records, per caller file, the set of repo prefixes that file imports. // This is the positive evidence the cross-repo name-only fallbacks // consult: a candidate in repo R is eligible for caller file F only // when F imports R. Per-repo resolution (resolver.go) runs first and // resolves imports — including cross-repo imports, with a precise // import-path match — so by the time this index is built the import // graph is settled enough to be trustworthy evidence. func (cr *CrossRepoResolver) buildReachableReposIndex() { idx := make(map[string]map[string]struct{}) // Materialise the import edges and batch-load their targets in one // GetNodesByIDs — a per-edge GetNode(e.To) here is a query round-trip // per import on a disk backend, which under the cross-repo pass's // import population was a multi-minute cold-warmup stall (it runs // before the pass even logs "pass start"). var imports []*graph.Edge ids := make(map[string]struct{}) for e := range cr.graph.EdgesByKind(graph.EdgeImports) { imports = append(imports, e) if e.To != "" { ids[e.To] = struct{}{} } } if len(imports) == 0 { cr.reachableReposByFile = idx return } idList := make([]string, 0, len(ids)) for id := range ids { idList = append(idList, id) } nodes := cr.graph.GetNodesByIDs(idList) for _, e := range imports { // Only resolved imports carry evidence — an unresolved import // target tells us nothing about which repo the caller reaches. to := nodes[e.To] if to == nil || to.RepoPrefix == "" { continue } set := idx[e.From] if set == nil { set = make(map[string]struct{}) idx[e.From] = set } set[to.RepoPrefix] = struct{}{} } cr.reachableReposByFile = idx } func (cr *CrossRepoResolver) clearReachableReposIndex() { cr.reachableReposByFile = nil } // reachabilityChecker returns a per-edge closure that reports whether the // caller of e may reach a candidate in targetRepo. It captures the caller's // repo + import-reachability set ONCE; the per-call repoReachable re-derived // both via cachedGetNode on every candidate, so a common cross-repo name // with thousands of candidates paid O(candidates) redundant cache lookups // per edge — the bulk of cr's compute wall time. Same semantics as // repoReachable; only the per-candidate cost differs. func (cr *CrossRepoResolver) reachabilityChecker(e *graph.Edge) func(targetRepo string) bool { callerRepo := cr.callerRepoPrefix(e) reachableRepos := cr.reachableReposByFile[cr.callerFileID(e)] return func(targetRepo string) bool { if targetRepo == "" || targetRepo == callerRepo { return true } if reachableRepos == nil { return false } _, ok := reachableRepos[targetRepo] return ok } } // callerFileID returns the graph ID of the file that owns the edge's // From symbol. File node IDs equal their path, and EdgeImports edges // are keyed From=fileID, so this is the lookup key for // reachableReposByFile. Falls back to the edge's own FilePath when the // From node can't be resolved. func (cr *CrossRepoResolver) callerFileID(e *graph.Edge) string { if from := cr.cachedGetNode(e.From); from != nil { if from.Kind == graph.KindFile { return from.ID } if from.FilePath != "" { return from.FilePath } } return e.FilePath } // resolveEdge dispatches one unresolved edge through the cross-repo // resolution paths and, when the resolution lifted the To target, // appends a re-bind job to batch instead of committing a per-edge // ReindexEdge transaction. The caller flushes the accumulated batch // after the whole pass via ReindexEdges so disk backends amortise // the commit cost. // warmLookupCache batches the per-edge GetNode / FindNodesByName the // cross-repo worker loop would otherwise fire serially — the mirror of // Resolver.warmLookupCache (resolver.go). It includes the authoritative // negative: a queried name with no node records an empty result, so the // 200k+ external-call stubs return from the cache instead of each // scanning the unindexed name column (the warmup hang). func (cr *CrossRepoResolver) warmLookupCache(pending []*graph.Edge) { if len(pending) == 0 { return } idSet := make(map[string]struct{}, len(pending)) 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{}{} } bare := graph.UnresolvedName(e.To) if name := identifierFromTarget(bare); name != "" { nameSet[name] = struct{}{} } // Seed the RAW unresolved name too. This is pure scan-avoidance and // changes no resolution outcome: the legit cross-repo matches use the // bare identifier (seeded above) and resolve fine. The problem is the // EXTERNAL / unresolvable residual that dominates this pass (stdlib + // out-of-tree "calls" that never match a node): resolveFunctionCall // looks them up by their full target (e.g. "extern::pkg::Foo"), which // the stripped pre-warm key ("Foo") didn't cover, so they missed the // cache and fell through to a per-edge FindNodesByName scan — the // parallel cross-repo storm. Seeding the raw form lets them hit the // authoritative negative instead of scanning. if bare != "" { nameSet[bare] = struct{}{} } // Import targets: mirror resolveEdge's dispatch (TrimPrefix of the // bare unresolved:: form) so the seeded qual-name matches what // resolveImport looks up via GetNodeByQualName. if t := strings.TrimPrefix(e.To, unresolvedPrefix); 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) } cr.nodeByID = cr.graph.GetNodesByIDs(ids) cr.nodesByName = cr.graph.FindNodesByNames(names) // Authoritative negatives: record an empty result for every queried // name that has no node, so the cached lookup returns empty instead // of falling through to a per-edge FindNodesByName scan. if cr.nodesByName == nil { cr.nodesByName = make(map[string][]*graph.Node, len(nameSet)) } for n := range nameSet { if _, ok := cr.nodesByName[n]; !ok { cr.nodesByName[n] = nil } } // Fold every candidate node into the id cache too, so a downstream // GetNode on a chosen target hits instead of going to the store. if cr.nodeByID == nil && len(cr.nodesByName) > 0 { cr.nodeByID = make(map[string]*graph.Node, len(cr.nodesByName)) } for _, hits := range cr.nodesByName { for _, n := range hits { if n == nil || n.ID == "" { continue } if _, ok := cr.nodeByID[n.ID]; !ok { cr.nodeByID[n.ID] = n } } } // Index the name hits by repo so resolveFunctionCall / resolveMethodCall // collect ONLY the caller's reachable-repo, same-language candidates // instead of fetching every same-named node across all repos + languages // and discarding the unreachable majority per edge (the cross-repo // candidate-iteration cost). Every pre-warmed name gets an entry (empty // for an authoritative negative) so scopedCandidates can distinguish // "pre-warmed, no node" (return empty) from "not pre-warmed" (fall // through to the flat cache). cr.nodesByNameRepo = make(map[string]map[string][]*graph.Node, len(cr.nodesByName)) for name, hits := range cr.nodesByName { byRepo := make(map[string][]*graph.Node) for _, n := range hits { if n == nil { continue } byRepo[n.RepoPrefix] = append(byRepo[n.RepoPrefix], n) } cr.nodesByNameRepo[name] = byRepo } // Pre-warm the import qual-name cache + authoritative negatives, so // resolveImport's GetNodeByQualName hits instead of scanning the // unindexed qual_name column per cross-repo import edge. if len(qualNameSet) > 0 { qns := make([]string, 0, len(qualNameSet)) for q := range qualNameSet { qns = append(qns, q) } cr.nodesByQualName = cr.graph.GetNodesByQualNames(qns) if cr.nodesByQualName == nil { cr.nodesByQualName = make(map[string]*graph.Node, len(qualNameSet)) } for q := range qualNameSet { if _, ok := cr.nodesByQualName[q]; !ok { cr.nodesByQualName[q] = nil } } } } func (cr *CrossRepoResolver) clearLookupCache() { cr.nodeByID = nil cr.nodesByName = nil cr.nodesByNameRepo = nil cr.nodesByQualName = nil } // scopedCandidates returns the candidates named `name` the caller of e could // plausibly resolve to: nodes in the caller's own repo, a repo its file // imports (reachableReposByFile), or no repo (synthetic) — AND of the // caller's language (a Go call can't bind a same-named TypeScript symbol). // This applies the import + language prune at the SOURCE: cachedFindNodesByName // returns every same-named node across all repos and languages (thousands for // a common name), which the per-edge loops then iterate and discard; the // per-pass name→repo index collects only the relevant few. Names absent from // the index (not pre-warmed) fall through to the flat cache, preserving the // negative-cache + correctness contract. func (cr *CrossRepoResolver) scopedCandidates(e *graph.Edge, name string) []*graph.Node { byRepo, ok := cr.nodesByNameRepo[name] if !ok { return cr.cachedFindNodesByName(name) } if len(byRepo) == 0 { return nil // pre-warmed, no node (authoritative negative) } caller := cr.cachedGetNode(e.From) callerRepo, callerLang, callerFile := "", "", e.FilePath if caller != nil { callerRepo = caller.RepoPrefix callerLang = caller.Language if caller.Kind == graph.KindFile { callerFile = caller.ID } else if caller.FilePath != "" { callerFile = caller.FilePath } } reachableRepos := cr.reachableReposByFile[callerFile] var out []*graph.Node keep := func(repo string) { for _, n := range byRepo[repo] { if callerLang == "" || n.Language == "" || n.Language == callerLang { out = append(out, n) } } } keep(callerRepo) if callerRepo != "" { keep("") // synthetic / no-repo nodes are always reachable } for r := range reachableRepos { if r != callerRepo && r != "" { keep(r) } } return out } // cachedGetNode consults the per-pass id cache first, falling through to // the store on a miss (positive-only: absence means "not pre-warmed"). func (cr *CrossRepoResolver) cachedGetNode(id string) *graph.Node { if id == "" { return nil } if cr.nodeByID != nil { if n, ok := cr.nodeByID[id]; ok { return n } } return cr.graph.GetNode(id) } // cachedFindNodesByName consults the per-pass name cache first. A // pre-warmed name with no node returns empty (authoritative negative); // a name absent from the cache falls through to the store. func (cr *CrossRepoResolver) cachedFindNodesByName(name string) []*graph.Node { if name == "" { return nil } if cr.nodesByName != nil { if hits, ok := cr.nodesByName[name]; ok { return hits } } return cr.graph.FindNodesByName(name) } // cachedGetNodeByQualName serves resolveImport's qual-name lookup from the // per-pass cache (authoritative negative for queried-but-absent import // paths), mirroring Resolver.cachedGetNodeByQualName. func (cr *CrossRepoResolver) cachedGetNodeByQualName(qualName string) *graph.Node { if qualName == "" { return nil } if cr.nodesByQualName != nil { if n, ok := cr.nodesByQualName[qualName]; ok { return n } } return cr.graph.GetNodeByQualName(qualName) } func (cr *CrossRepoResolver) resolveEdge(e *graph.Edge, stats *CrossRepoStats, batch *[]graph.EdgeReindex) { oldTo := e.To // UnresolvedName handles BOTH the bare `unresolved::X` and the // multi-repo `::unresolved::X` forms; a plain TrimPrefix only // strips the bare form, leaving prefixed stubs (which fix-1's widened // EdgesWithUnresolvedTarget now feeds this pass) with target=full-id — // so the lookup key matched no node and missed the per-pass name cache, // turning every prefixed stub into a futile per-edge FindNodesByName // scan. Mirrors the master Resolver.resolveEdge. target := graph.UnresolvedName(e.To) if target == "" { target = strings.TrimPrefix(e.To, unresolvedPrefix) } switch { case strings.HasPrefix(target, "import::"): cr.resolveImport(e, strings.TrimPrefix(target, "import::"), stats) case strings.HasPrefix(target, "*."): cr.resolveMethodCall(e, strings.TrimPrefix(target, "*."), stats) case e.Kind == graph.EdgeExtends || e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeComposes: // Type-hierarchy edges never resolve to a function/method. // CrossRepoResolver has no type-only resolution path, and a // cross-repo supertype requires the child's file to import the // parent's repo — which would have let per-repo resolution // (or a precise import) land it already. Leave it unresolved // rather than let resolveFunctionCall match a coincidental // cross-repo function of the same name. stats.Unresolved++ default: cr.resolveFunctionCall(e, target, stats) // Last-resort cross-daemon proxy-edge stitch: only when local // resolution left the edge unresolved, proxy-edge minting is on, // and a prober is wired. The evidence gate inside tryRemoteStitch // refuses to probe on a bare name. if e.To == oldTo && cr.edgesEnabled && cr.prober != nil { cr.tryRemoteStitch(e, target, stats) } } if e.To != oldTo { *batch = append(*batch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) } } // filterLiveReindex validates a resolved batch before applying it on the // chunked path: a concurrent edit during an inter-chunk yield may have evicted // an edge (reindexing it half-resurrects it and can panic) or its resolved // target node (a dangling edge). Drop evicted edges; revert a resolution whose // target is gone. O(batch) — only the edges that actually resolved, NOT the // whole pending set (an O(pending*out-degree) per-edge check stalled the pass). func (cr *CrossRepoResolver) filterLiveReindex(batch []graph.EdgeReindex) []graph.EdgeReindex { out := batch[:0] for _, r := range batch { e := r.Edge if e == nil || !edgeStillLive(cr.graph, e) { continue } if !isSyntheticResolveTarget(e.To) && cr.graph.GetNode(e.To) == nil { e.To = r.OldTo continue } out = append(out, r) } return out } // edgeStillLive reports whether e is currently present as an out-edge of its // source node — i.e. it was not evicted by a concurrent single-file edit // since the resolve pass snapshotted its pending set. The in-memory store // returns the live stored *Edge pointers, so identity comparison is exact // there; disk-backed stores materialise a fresh copy per read, so a value // identity on the fields that pin a unique edge row backs the pointer test // up. Without the value fallback, the chunked ResolveAll apply-loop dropped // EVERY computed resolution on a sqlite-backed daemon (`reindex_batch: 0` on // every pass) — the whole master resolve silently no-opped. func edgeStillLive(g graph.Store, e *graph.Edge) bool { if e == nil { return false } // Fast path: the value fallback below only ever compares the fields that // pin a unique edge row (From/To/Kind/Line/FilePath) -- the pointer test // never fires on a sqlite-backed daemon (GetOutEdges returns freshly // decoded pointers). A backend that can answer edge existence as a single // indexed point lookup (no row decode, no Meta gob, no per-edge slice // allocation) does exactly that comparison far more cheaply. On the // cold/full pass this guard runs once per applied edge -- hundreds of // thousands of times -- so the scan-all-out-edges form is a dominant share // of resolve cost and GC churn. if ex, ok := g.(edgeExister); ok { return ex.EdgeExists(e.From, e.To, e.Kind, e.FilePath, e.Line) } for _, oe := range g.GetOutEdges(e.From) { if oe == e { return true } if oe != nil && oe.Kind == e.Kind && oe.To == e.To && oe.Line == e.Line && oe.FilePath == e.FilePath { return true } } return false } // edgeExister is the optional fast path for edgeStillLive: a store backend that // can answer "does this exact edge row exist" as an indexed point lookup rather // than materialising all of From's out-edges. The sqlite store implements it; // the in-memory graph falls through to the GetOutEdges scan (already cheap, no // disk, no gob). type edgeExister interface { EdgeExists(from, to string, kind graph.EdgeKind, filePath string, line int) bool } // isSyntheticResolveTarget reports whether a resolved target is an intentional // placeholder rather than a concrete graph node (so the chunked path's // target-liveness guard must not treat its absence as an evicted candidate). func isSyntheticResolveTarget(to string) bool { return strings.HasPrefix(to, "external::") || strings.HasPrefix(to, "extern::") } // resolveChunkEnabled reports whether the global resolve passes process their // pending set in super-chunks, releasing the resolve mutex between chunks so // interactive single-file edits can interleave instead of waiting out the // whole pass. Default ON; GORTEX_RESOLVE_CHUNK=0 restores the prior // whole-pass-locked behaviour. func resolveChunkEnabled() bool { if v := os.Getenv("GORTEX_RESOLVE_CHUNK"); v != "" { return v != "0" && !strings.EqualFold(v, "false") } return true } // resolveChunkSize is the number of pending edges resolved + applied per chunk // before the resolve mutex is yielded. GORTEX_RESOLVE_CHUNK_SIZE overrides; // default 2048 — large enough to amortise the per-chunk worker barrier, small // enough that a waiting edit is delayed by at most one chunk's compute. func resolveChunkSize() int { if v := os.Getenv("GORTEX_RESOLVE_CHUNK_SIZE"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return 2048 } // callerRepoPrefix returns the RepoPrefix of the node that owns the edge's From field. func (cr *CrossRepoResolver) callerRepoPrefix(e *graph.Edge) string { fromNode := cr.cachedGetNode(e.From) if fromNode != nil { return fromNode.RepoPrefix } return "" } func (cr *CrossRepoResolver) resolveFunctionCall(e *graph.Edge, funcName string, stats *CrossRepoStats) { candidates := cr.scopedCandidates(e, funcName) if len(candidates) == 0 { stats.Unresolved++ return } callerRepo := cr.callerRepoPrefix(e) callerWS := cr.callerWorkspaceID(e) reachable := cr.reachabilityChecker(e) // 1. Prefer same-repo match. for _, c := range candidates { if (c.Kind == graph.KindFunction || c.Kind == graph.KindMethod) && c.RepoPrefix == callerRepo { e.To = c.ID stats.Resolved++ return } } // 2. Cross-repo fallback: first function/method match that clears // BOTH evidence gates — // (a) import-reachability: the caller's file must actually import // the candidate's repo. Without this, a bare name like `len` // or `String` resolves to whichever repo sorts first. // (b) workspace boundary: same-workspace cross-repo is allowed; // cross-workspace requires a declared cross_workspace_deps // entry covering the workspace pair. for _, c := range candidates { if c.Kind != graph.KindFunction && c.Kind != graph.KindMethod { continue } if !reachable(c.RepoPrefix) { continue } if !cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), "") { continue } e.To = c.ID e.CrossRepo = true stats.Resolved++ stats.CrossRepoEdges++ stats.ByRepo[c.RepoPrefix]++ return } stats.Unresolved++ } func (cr *CrossRepoResolver) resolveImport(e *graph.Edge, importPath string, stats *CrossRepoStats) { callerRepo := cr.callerRepoPrefix(e) callerWS := cr.callerWorkspaceID(e) // npm-alias rewrite: see Resolver.resolveImport. Applied here too // so a JS/TS import of an alias key resolves cross-repo to a // locally-vendored real package when the per-repo pass left it // unresolved. importPath, npmAliased := rewriteNpmAliasImport(cr.npmAlias, e.FilePath, importPath) // JS/TS relative + tsconfig-path-alias / baseUrl import: resolve the // specifier onto the in-repo file (or exported symbol) it names, the // same as Resolver.resolveImport. Aliases are repo-local, so this is // mostly a no-op once the per-repo pass has run; kept for parity so a // JS/TS import edge reaching the cross-repo pass still unresolved gets // the same treatment (issue #136). if to := resolveJSTSImportTarget(cr.cachedGetNode, cr.pathAlias, jsTSImportCallerFile(e), importPath); to != "" { e.To = to if picked := cr.cachedGetNode(to); picked != nil && picked.RepoPrefix != callerRepo { e.CrossRepo = true stats.CrossRepoEdges++ stats.ByRepo[picked.RepoPrefix]++ } stats.Resolved++ return } // Look for a package node with matching qualified name. if node := cr.cachedGetNodeByQualName(importPath); node != nil { picked := node if !cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(picked), importPath) { // The qual-name index is single-valued, so a same-module // instance in the caller's own workspace (a worktree of the // imported module, tracked under its own prefix) can be // shadowed by a copy in another workspace. Enumerate every // node sharing this qual name and prefer the caller's // workspace before giving up. picked = cr.pickQualNameCandidate(callerWS, importPath, node) } if picked != nil { e.To = picked.ID if picked.RepoPrefix != callerRepo { e.CrossRepo = true stats.CrossRepoEdges++ stats.ByRepo[picked.RepoPrefix]++ } stats.Resolved++ return } // A qual-name hit with no workspace-eligible instance falls // through to the directory-match scan below rather than bailing // straight to external. } // Look for file nodes whose directory matches the import path. Two // inverted indexes (built once per Resolve* pass) replace what used // to be an O(N) scan of the entire graph per import edge. // // 1. Exact dir match — `dirIndex[importPath]` covers the case where // the import literally equals a known directory. // 2. Last-component match — `lastDirIndex[lastPathComponent(...)]` // covers the common case where an import path is just a name // (e.g. "logger") and any file under .../logger/ is a candidate. // // Falls back to a full graph scan if the indexes are unset (defensive // — only happens when resolveImport is called outside a Resolve* pass). // When a package-manager workspace lookup is installed every // same-repo candidate is collected so a same-named collision // across two workspace members can be resolved to the importer's // own workspace; otherwise the first same-repo hit short-circuits // the scan as before. collectAll := cr.workspaceMembers != nil var sameRepo *graph.Node var sameRepoAll, crossRepoAll []*graph.Node consider := func(n *graph.Node) { if n.Kind != graph.KindFile { return } if 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. lastDirIndex / the full-scan fallback key on the // last path component only, so without this gate an import of // `.../tree-sitter-c/bindings/go` resolves to whichever // `*/bindings/go` directory sorts first. Collect every match so // the workspace-aware pick below can prefer the importer's own // workspace instead of the first one encountered. if dirMatchesImport(filepath.Dir(n.FilePath), importPath) { crossRepoAll = append(crossRepoAll, n) } } stop := func() bool { return sameRepo != nil && !collectAll } if cr.dirIndex != nil { for _, n := range cr.dirIndex[importPath] { consider(n) if stop() { break } } if sameRepo == nil || collectAll { for _, n := range cr.lastDirIndex[lastPathComponent(importPath)] { consider(n) if stop() { break } } } } else { for n := range cr.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: prefer the same-repo file in the // importing file's own package-manager workspace. if ws := cr.preferSameWorkspaceFile(e.FilePath, sameRepoAll); ws != nil { sameRepo = ws } e.To = sameRepo.ID stats.Resolved++ return } // Cross-repo directory match: prefer a candidate in the caller's own // workspace, then any the cross-workspace policy permits. Never bail // on the first ineligible candidate — a same-workspace instance (a // worktree of the imported module tracked under its own prefix) may // appear later in the list. if picked := cr.pickImportCandidate(callerWS, importPath, crossRepoAll); picked != nil { e.To = picked.ID e.CrossRepo = true stats.Resolved++ stats.CrossRepoEdges++ stats.ByRepo[picked.RepoPrefix]++ return } // No file node matched. Try the dep:: contract from the // caller's go.mod before giving up. The dep node lives in the // caller's own repo, so this is a same-repo edge. if depNode := cr.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 — fall back to the // package node itself. See Resolver.resolveImport. if npmAliased { if pkg := npmPackagePrefix(importPath); pkg != "" { if node := cr.cachedGetNodeByQualName(pkg); node != nil && cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(node), pkg) { e.To = node.ID if node.RepoPrefix != callerRepo { e.CrossRepo = true stats.CrossRepoEdges++ stats.ByRepo[node.RepoPrefix]++ } stats.Resolved++ return } } } // External/unresolvable import. e.To = "external::" + importPath stats.Unresolved++ } func (cr *CrossRepoResolver) resolveMethodCall(e *graph.Edge, methodName string, stats *CrossRepoStats) { candidates := cr.scopedCandidates(e, methodName) if len(candidates) == 0 { stats.Unresolved++ return } callerRepo := cr.callerRepoPrefix(e) callerWS := cr.callerWorkspaceID(e) receiverType := edgeReceiverType(e) reachable := cr.reachabilityChecker(e) // If we have a type hint, try exact type match first. if receiverType != "" { // Same-repo + exact type. for _, c := range candidates { if c.Kind == graph.KindMethod && c.RepoPrefix == callerRepo && nodeReceiverType(c) == receiverType { e.To = c.ID e.Confidence = 0.95 stats.Resolved++ return } } // Cross-repo + exact type — bounded by the import-reachability // and workspace evidence gates. for _, c := range candidates { if c.Kind != graph.KindMethod || nodeReceiverType(c) != receiverType { continue } if !reachable(c.RepoPrefix) { continue } if !cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), "") { continue } e.To = c.ID e.CrossRepo = true e.Confidence = 0.85 stats.Resolved++ stats.CrossRepoEdges++ stats.ByRepo[c.RepoPrefix]++ return } } // Fallback: name-only matching (methods first, then functions for pkg.Func() calls). for _, c := range candidates { if c.Kind == graph.KindMethod && c.RepoPrefix == callerRepo { e.To = c.ID stats.Resolved++ return } } for _, c := range candidates { if c.Kind != graph.KindMethod { continue } if !reachable(c.RepoPrefix) { continue } if !cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), "") { continue } e.To = c.ID e.CrossRepo = true stats.Resolved++ stats.CrossRepoEdges++ stats.ByRepo[c.RepoPrefix]++ return } for _, c := range candidates { if c.Kind == graph.KindFunction && c.RepoPrefix == callerRepo { e.To = c.ID stats.Resolved++ return } } for _, c := range candidates { if c.Kind != graph.KindFunction { continue } if !reachable(c.RepoPrefix) { continue } if !cr.crossWorkspaceEligible(callerWS, candidateWorkspaceID(c), "") { continue } e.To = c.ID e.CrossRepo = true stats.Resolved++ stats.CrossRepoEdges++ stats.ByRepo[c.RepoPrefix]++ return } stats.Unresolved++ }