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
469 lines
18 KiB
Go
469 lines
18 KiB
Go
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
|
||
}
|