Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1408 lines
50 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package resolver
import (
"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::<module> 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::<module> 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 `<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::<module> 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++
}