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

469 lines
18 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 (
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// Cross-package name-match guard.
//
// The heuristic cascade in resolveFunctionCall / resolveMethodCall ends,
// for calls it can't pin precisely, in a name-only fallback: "the first
// function/method named X in the caller's repo". When the only candidate
// of that name lives in a package the caller never imports, that
// fallback manufactures a false `calls` edge — a JS/TS factory result
// `h.handle()` binding to an unrelated `handle`, or a `ns.foo()`
// namespace call binding to a free `foo` in some other module.
//
// This guard runs once after the main resolution pass. For every edge
// the pass resolved at one of the two weakest confidence tiers
// (text_matched / ast_inferred) it asks a single question: is the
// resolved target import-reachable from the call site? Reachable means
// the target sits in the caller's own directory (same package) or in a
// directory the caller's file imports. When it is not, the edge is
// reverted to its pre-resolution `unresolved::` target so a
// higher-evidence resolver (CrossRepoResolver, or a later LSP-backed
// pass) can have a clean attempt instead of inheriting a wrong binding.
//
// Genuine same-package and imported-target edges are never touched: the
// reachability set always contains the caller's own directory, and an
// imported package contributes its directory to the set. Edges resolved
// at ast_resolved or above are out of scope — those carry structural or
// compiler-grade evidence the name-only fallback never had.
// guardCrossPackageCallEdges inspects the edges mutated by the just-
// completed resolution pass and reverts any weak-tier call/reference
// edge whose resolved target is not import-reachable from the caller.
// jobs are the reindexJob records produced by ResolveAll's worker
// phase; each carries the edge's pre-resolution target in oldTo, so a
// reverted edge is restored exactly. closure is the import-reachability
// map from buildImportClosure. Returns the number of edges reverted.
func (r *Resolver) guardCrossPackageCallEdges(jobs []reindexJob, closure map[string]map[string]struct{}) int {
if len(jobs) == 0 {
return 0
}
// Collect both mutation lists across the whole pass and apply them
// via the batched Store methods at the end. Per-edge
// SetEdgeProvenance + ReindexEdge in the body would otherwise pay
// two ACID round-trips per reverted edge against disk backends —
// catastrophic on a 30k-job pass.
var provBatch []graph.EdgeProvenanceUpdate
var reindexBatch []graph.EdgeReindex
for i := range jobs {
j := &jobs[i]
// A concurrent edit during a chunked ResolveAll yield may have evicted
// this edge since it resolved; reverting + reindexing it would
// half-resurrect it. Skip — it is no longer in the graph.
if r.validateLiveness && !edgeStillLive(r.graph, j.edge) {
continue
}
// The deferred LSP batch may have re-bound (or confirmed) this edge
// after the heuristic job was recorded, stamping it OriginLSPResolved —
// compiler-grade evidence the name-only fallback this guard polices
// never had. j.origin still holds the stale heuristic tier, so trust
// the live edge: never revert an LSP-owned binding. (The batch now
// overrides confident heuristic binds, so a recorded job's target can
// be LSP-owned; before, the batch only touched heuristic-unresolved
// edges, disjoint from these jobs, and this never fired.)
if j.edge.Origin == graph.OriginLSPResolved {
continue
}
if !isCallLikeEdge(j.kind) {
continue
}
// Only the two weakest tiers — a name-only guess — are in scope.
// DefaultOriginFor backfills the tier for edges whose Origin the
// resolver left unset (the heuristic fallbacks never stamp it).
origin := j.origin
if origin == "" {
origin = graph.DefaultOriginFor(j.kind, j.confidence, "")
}
if origin != graph.OriginTextMatched && origin != graph.OriginASTInferred {
continue
}
// The pre-resolution target must be a bare-name placeholder —
// `unresolved::Foo` (function call) or `unresolved::*.foo`
// (member call). Anything else carries evidence the name-only
// fallback never had and is out of scope: `extern::` pins an
// import path, `grpc::` / `pyrel::` / `import::` are owned by
// dedicated passes, and a non-`unresolved::` target was never a
// guess to begin with.
if !isBareNameCallTarget(j.oldTo) {
continue
}
callerFile := r.edgeCallerFile(j.edge)
callerNode := r.cachedGetNode(j.edge.From)
target := r.cachedGetNode(j.newTo)
if callerFile == "" || target == nil {
continue
}
if r.targetImportReachable(callerFile, callerNode, target, closure) {
continue
}
// A member call whose only in-repo definition of the name is this
// target is not a cross-package mis-guess — there is nowhere else the
// call could bind. A method call carries its receiver, so it needs no
// import of the method's package, and inherited / indirectly-typed
// receivers (owner.foo() → BaseType.foo two packages up) never name the
// declaring package, so the import closure structurally misses them.
// Keep the resolution.
if r.loneMemberDefnKeep(target, j.edge, j.oldTo) {
continue
}
// Not reachable — revert to the unresolved placeholder and
// re-index against the resolved target we are abandoning.
// SetEdgeProvenance("") drops the resolution provenance so
// the reverted edge's identity change is counted; the target
// revert + re-bucket follows. Both go in their respective
// batches so the whole pass commits in two chunks instead of
// 2×N per-edge transactions.
oldResolved := j.edge.To
provBatch = append(provBatch, graph.EdgeProvenanceUpdate{Edge: j.edge, NewOrigin: ""})
j.edge.To = j.oldTo
j.edge.Confidence = 0
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: j.edge, OldTo: oldResolved})
}
if len(provBatch) > 0 {
r.graph.SetEdgeProvenanceBatch(provBatch)
}
if len(reindexBatch) > 0 {
r.graph.ReindexEdges(reindexBatch)
}
return len(reindexBatch)
}
// isBareNameCallTarget reports whether an unresolved edge target is a
// bare-name call placeholder — `unresolved::Foo` for a free-function
// call or `unresolved::*.foo` for a member call. These are the only
// shapes the name-only resolution fallback acts on. Targets that embed
// further structure (`unresolved::extern::path::sym`, `grpc::`,
// `pyrel::`, `import::`) carry evidence the fallback never had and are
// resolved by other code paths, so the guard leaves them alone.
func isBareNameCallTarget(target string) bool {
rest, ok := strings.CutPrefix(target, unresolvedPrefix)
if !ok || rest == "" {
return false
}
rest = strings.TrimPrefix(rest, "*.")
if rest == "" {
return false
}
// A remaining `::` means the placeholder is one of the structured
// forms (extern::, grpc::, pyrel::, import::), not a bare name.
return !strings.Contains(rest, "::")
}
// isCallLikeEdge reports whether an edge kind is one the guard polices.
// EdgeCalls is the obvious case; EdgeReferences is included because the
// resolver promotes a call-shaped EdgeReads to EdgeReferences once it
// learns the target is a function/method, and that promotion runs
// through the very same name-only fallback.
func isCallLikeEdge(k graph.EdgeKind) bool {
return k == graph.EdgeCalls || k == graph.EdgeReferences
}
// edgeCallerFile returns the file path of the node that owns the edge's
// From end. Empty when the caller node is unknown.
//
// Hot path: called once per cross-package-guarded edge. The pre-warmed
// per-pass cache populated in ResolveAll holds every From ID across the
// pending slice, so this call is a map lookup during a ResolveAll pass
// and a direct store call elsewhere.
func (r *Resolver) edgeCallerFile(e *graph.Edge) string {
if n := r.cachedGetNode(e.From); n != nil && n.FilePath != "" {
return n.FilePath
}
return e.FilePath
}
// targetImportReachable reports whether target sits in a package the
// caller's file can see: the caller's own directory (same package), or
// a directory present in the caller's import closure.
func (r *Resolver) targetImportReachable(callerFile string, callerNode, target *graph.Node, closure map[string]map[string]struct{}) bool {
if target.FilePath == "" {
// A target with no file (synthetic / external stub) can't be
// shown unreachable — leave the edge alone.
return true
}
callerDir := filepath.Dir(callerFile)
targetDir := filepath.Dir(target.FilePath)
if targetDir == callerDir {
return true
}
// Same source package across different directories is reachable without
// an import edge. Maven splits one package across src/main/java and
// src/test/java, and JVM same-package callers import nothing — so a
// directory-only closure reports a false "unreachable" for every
// test→production same-package call. scope_pkg is stamped only on JVM
// member nodes, so this never fires for directory-scoped ecosystems.
if sameScopePackage(callerNode, target) {
return true
}
dirs, ok := closure[callerFile]
if !ok {
// No closure entry for the caller (its file node or imports were
// not indexed). Be conservative: without evidence of isolation
// we keep the edge rather than risk dropping a real one.
return true
}
_, reachable := dirs[targetDir]
return reachable
}
// scopePkgOf returns a node's stamped source package (scope_pkg Meta),
// empty when absent. Only JVM extractors (Java / Kotlin) stamp it.
func scopePkgOf(n *graph.Node) string {
if n == nil || n.Meta == nil {
return ""
}
if p, ok := n.Meta["scope_pkg"].(string); ok {
return p
}
return ""
}
// sameScopePackage reports whether two nodes belong to the same source
// package of the same language. Empty package on either side is never a
// match, so directory-scoped ecosystems (no scope_pkg) never qualify.
func sameScopePackage(a, b *graph.Node) bool {
if a == nil || b == nil {
return false
}
pa := scopePkgOf(a)
if pa == "" {
return false
}
return pa == scopePkgOf(b) && a.Language == b.Language
}
// loneMemberDefnKeep reports whether a to-be-reverted member-call edge should
// survive the cross-package guard because its target is the sole in-repo
// definition of the method name. A name with exactly one candidate cannot be a
// cross-package mis-guess: a method call carries its receiver, so it needs no
// import of the method's package, and the import closure structurally misses
// inherited / indirectly-typed receivers (owner.foo() where owner came from a
// return value the caller's file never imports the type of). Restricted to the
// statically-typed languages (java, go) where a lone method name is unambiguous
// — TS / Python duck typing makes a same-name coincidence likelier, so the
// guard's revert stays load-bearing there — and gated on the receiver, when
// known, naming an in-repo type so an external-typed receiver (a logging
// facade's `logger.info`) still reverts rather than latching onto an unrelated
// same-named local method.
func (r *Resolver) loneMemberDefnKeep(target *graph.Node, e *graph.Edge, oldTo string) bool {
if target == nil || !loneMemberLang(target.Language) {
return false
}
bareName := graph.UnresolvedName(oldTo)
memberCall := strings.HasPrefix(bareName, "*.")
// Go has free functions that DO need their package imported, so only a
// member call (`x.foo()` — the receiver carries the type, no import of the
// method's package needed) is kept; a bare free-function call to an
// un-imported package must still revert. Java has no free functions, so its
// bare calls are static-member dispatch and keep too.
if target.Language != "java" && !memberCall {
return false
}
name := strings.TrimPrefix(bareName, "*.")
if name == "" {
return false
}
repo := r.callerRepoPrefix(e)
if rt := edgeReceiverType(e); rt != "" && !r.hasInRepoType(rt, repo) {
return false
}
n := 0
for _, c := range r.cachedFindNodesByNameInRepo(name, repo) {
if c.Language != target.Language {
continue
}
// A member call can only bind to a method; count functions too only
// for Java's static-member model (its bare calls).
if c.Kind == graph.KindMethod || (target.Language == "java" && c.Kind == graph.KindFunction) {
if n++; n > 1 {
return false
}
}
}
return n == 1
}
// loneMemberLang reports whether a lone in-repo method definition is safe to
// keep against the cross-package guard for the given language. Limited to the
// statically-typed languages where exactly one same-named member is
// structurally unambiguous; TS / Python / JS duck typing makes a same-name
// coincidence likelier, so their guard revert stays.
func loneMemberLang(lang string) bool {
switch lang {
case "java", "go", "rust", "csharp", "kotlin", "scala":
return true
}
return false
}
// hasInRepoType reports whether the repo defines a type/interface named
// typeName — the gate that keeps javaLoneMemberDefnKeep from latching a
// call on an external-typed receiver onto an unrelated in-repo method.
func (r *Resolver) hasInRepoType(typeName, repo string) bool {
for _, c := range r.cachedFindNodesByNameInRepo(typeName, repo) {
if c.Kind == graph.KindType || c.Kind == graph.KindInterface {
return true
}
}
return false
}
// buildImportClosure maps each caller file path to the set of directories
// it can reach by import. The set is seeded with the file's own directory
// and extended with the directory of every node its resolved EdgeImports
// edges point at. It is built from the post-resolution graph — by the
// time the guard runs, import edges have been resolved to real file /
// package nodes, so this closure captures JS/TS relative-file imports
// that the pre-resolution reachability index (keyed on directory-shaped
// import paths) structurally misses.
func (r *Resolver) buildImportClosure() map[string]map[string]struct{} {
return r.buildImportClosureFiltered(nil)
}
// buildImportClosureFiltered is buildImportClosure restricted to a set of repo
// prefixes: it seeds the closure only for files owned by those repos and only
// walks import edges whose caller sits in one of them. Each import edge
// contributes solely to its own caller's closure entry, so a caller in the set
// gets the same reachable-dir set it would in the whole-graph build — the guard
// queries the closure only for those callers, so its verdicts are unchanged.
// Re-export edges stay unfiltered: a caller in the set may import a barrel that
// re-exports from a repo outside it, and the transitive barrel walk must still
// reach it. A nil repos set builds the whole-graph closure.
func (r *Resolver) buildImportClosureFiltered(repos map[string]struct{}) map[string]map[string]struct{} {
inScope := func(id string) bool {
if repos == nil {
return true
}
_, ok := repos[graph.RepoPrefixOfID(id)]
return ok
}
closure := make(map[string]map[string]struct{})
add := func(file, dir string) {
if file == "" || dir == "" {
return
}
set := closure[file]
if set == nil {
set = make(map[string]struct{})
closure[file] = set
}
set[dir] = struct{}{}
}
for n := range r.graph.NodesByKind(graph.KindFile) {
if n.FilePath != "" && inScope(n.ID) {
add(n.FilePath, filepath.Dir(n.FilePath))
}
}
// Materialise the resolved import edges and batch-load their endpoints
// (caller file + target) in one GetNodesByIDs — a per-edge GetNode here
// is a query round-trip per import on a disk backend. Inlines
// edgeCallerFile's cached-node logic against the batch map.
//
// Re-export edges ride the same batch: an import that lands on a
// barrel (`import { persist } from 'zustand/middleware'` resolving to
// src/middleware.ts, which `export { persist } from
// './middleware/persist.ts'`) must make the re-exported module's
// directory reachable too — the consumer names the barrel, but the
// symbol it calls lives behind the re-export hop. Without this, the
// guard reverts every legitimate barrel-mediated call as
// "not import-reachable".
skipTarget := func(to string) bool {
return strings.HasPrefix(to, unresolvedPrefix) ||
strings.HasPrefix(to, "external::") ||
graph.IsStdlibStub(to) ||
strings.HasPrefix(to, "dep::")
}
var imports, reexports []*graph.Edge
ids := make(map[string]struct{})
collect := func(e *graph.Edge) {
if e.From != "" {
ids[e.From] = struct{}{}
}
if e.To != "" {
ids[e.To] = struct{}{}
}
}
for e := range r.graph.EdgesByKind(graph.EdgeImports) {
// Skip imports still pointing at an unresolved placeholder or an
// out-of-repo stub — neither names an in-repo directory that a
// name-only call candidate could legitimately live in.
if skipTarget(e.To) {
continue
}
// An import edge only extends its own caller's closure entry, so on a
// scoped build we need just the edges whose caller is in scope.
if !inScope(e.From) {
continue
}
imports = append(imports, e)
collect(e)
}
for e := range r.graph.EdgesByKind(graph.EdgeReExports) {
if skipTarget(e.To) {
continue
}
reexports = append(reexports, e)
collect(e)
}
if len(imports) == 0 {
return closure
}
idList := make([]string, 0, len(ids))
for id := range ids {
idList = append(idList, id)
}
nodes := r.graph.GetNodesByIDs(idList)
// Direct barrel-file → re-export-target-file map, then a memoised
// transitive walk so chained barrels (src/index.ts → src/middleware.ts
// → src/middleware/persist.ts) contribute every hop's directory.
reexpTargets := make(map[string][]string)
for _, e := range reexports {
barrel := e.FilePath
if n := nodes[e.From]; n != nil && n.FilePath != "" {
barrel = n.FilePath
}
if t := nodes[e.To]; t != nil && t.FilePath != "" && barrel != "" {
reexpTargets[barrel] = append(reexpTargets[barrel], t.FilePath)
}
}
barrelDirCache := make(map[string][]string)
var barrelDirs func(file string, seen map[string]bool) []string
barrelDirs = func(file string, seen map[string]bool) []string {
if dirs, ok := barrelDirCache[file]; ok {
return dirs
}
if seen[file] {
return nil
}
seen[file] = true
var dirs []string
for _, tf := range reexpTargets[file] {
dirs = append(dirs, filepath.Dir(tf))
dirs = append(dirs, barrelDirs(tf, seen)...)
}
barrelDirCache[file] = dirs
return dirs
}
for _, e := range imports {
callerFile := e.FilePath
if n := nodes[e.From]; n != nil && n.FilePath != "" {
callerFile = n.FilePath
}
if target := nodes[e.To]; target != nil && target.FilePath != "" {
add(callerFile, filepath.Dir(target.FilePath))
for _, d := range barrelDirs(target.FilePath, map[string]bool{}) {
add(callerFile, d)
}
}
}
return closure
}