项目文件夹

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

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