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

257 lines
10 KiB
Go

package resolver
import (
"strings"
"github.com/zzet/gortex/internal/graph"
)
// Import-evidence disambiguation for bare JS/TS calls.
//
// resolveFunctionCall's generic cascade is locality-driven: same-directory
// candidates win, then the first same-repo match, then (for JS/TS value
// callees) a unique repo-wide match — and any remaining ambiguity refuses.
// That cascade encodes Go package semantics, where a bare name IS visible
// from every file in the directory. The ES module system has no ambient
// directory scope: a bare call's callee is either defined in the caller's
// own file or explicitly imported. Two consequences the cascade gets wrong
// for JS/TS:
//
// - A same-directory neighbour that defines the name (a test helper
// shadowing a library export) captures the call even though the caller
// never imports it — and explicitly imports the real target.
// - Cross-directory ambiguity (the library export vs. several test-local
// helpers of the same name) refuses, dropping every call edge to a
// widely-used export (zustand's `createStore`: 20 refused call edges,
// ~120 line-level false negatives).
//
// pickImportEvidenceCallee closes both holes with structural evidence the
// cascade never consulted: the caller file's import closure. When the
// caller imports exactly one candidate's file — directly, or transitively
// through re-export (barrel) hops — that import statement is AST-grade
// proof of which module the name comes from, so the pick is stamped
// OriginASTResolved (the same tier resolveRendersChild's import-binding
// path uses). When the caller imports none of the candidates' files, or
// several, no evidence exists and the cascade proceeds unchanged.
//
// Precedence (documented here because it is the load-bearing design):
//
// 1. preferScopeCandidate — per-language static scope rules stay first;
// they carry stronger, language-specific evidence.
// 2. Module-local definition — a same-file function/method candidate is
// bound directly by the file-local tier in resolveFunctionCall, and
// any same-file candidate (any kind) blocks the import pick. A
// top-level local definition cannot legally coexist with an imported
// binding of the same name (redeclaration), and a function-scoped
// shadow may be the true callee; in both cases the local symbol wins
// or the cascade safely refuses. Blocking only ever preserves the
// pre-import-evidence behaviour — it can never mint a new edge.
// 3. Import evidence (this pass) — a unique imported candidate wins,
// BEFORE the same-directory loop, so an explicit import beats a
// same-dir-different-file shadow.
// 4. The existing cascade — same-dir, first-same-repo, JS/TS top-level
// value callee — untouched.
//
// Language gate: the pick only runs for JS/TS caller files. This is a
// correctness requirement, not caution. In Go a bare call can never name a
// symbol from another package (that call is selector-shaped and resolved
// elsewhere), so letting an imported package's file outrank the caller's
// own directory would manufacture impossible edges. In Python a plain
// `import x` puts x's file in the closure without bringing `foo` into
// scope, so file-granularity closure membership is not scope evidence
// there. For the ES module system it is: every import form that binds a
// bare name (named, default, aliased) makes the target module's file the
// only place the name can come from. Go and Python resolution paths are
// bit-for-bit unchanged.
//
// Granularity: the closure is FILE-level, not the directory-level closure
// guardCrossPackageCallEdges uses. The guard's map is seeded with each
// file's own directory (so same-package calls survive the guard), which
// would make every same-dir shadow "import-reachable" and defeat point 3;
// and dir granularity cannot separate two same-named candidates living in
// one imported directory. The expansion machinery is shared, not
// duplicated: raw specifiers go through the exact resolveJSTSImportTarget
// helper resolveImport uses (relative join, tsconfig paths/baseUrl, npm
// alias, extension + index probing), so the closure is identical whether
// an import edge has already been resolved by this pass or not — the pick
// is order-independent inside the parallel worker phase.
// pickImportEvidenceCallee returns the single candidate whose defining
// file the caller file imports (directly or through re-export hops), or
// nil when the evidence is absent or ambiguous. Only consulted for JS/TS
// callers with ≥2 candidates — the single-candidate paths keep today's
// behaviour, and the cross-package guard still polices them.
func (r *Resolver) pickImportEvidenceCallee(callerFile, funcName string, candidates []*graph.Node) *graph.Node {
if callerFile == "" || len(candidates) < 2 || !isJSTSPath(callerFile) {
return nil
}
// A candidate defined in the caller's own file is (or may be) module-
// local — the import pick must stand down and let the locality/scope
// cascade bind it. Any kind blocks: a local class, const, or nested
// helper of the same name all shadow an import at some scope, and
// falling through only preserves today's behaviour.
for _, c := range candidates {
if c.FilePath == callerFile {
return nil
}
}
imported := r.importedFilesFor(callerFile)
if len(imported) == 0 {
return nil
}
var pick *graph.Node
for _, c := range candidates {
if !isImportableCallee(c, funcName) {
continue
}
if _, ok := imported[c.FilePath]; !ok {
continue
}
if pick != nil {
// Two imported files (or one file with two same-named
// top-level symbols) both define the name — the import
// statement alone cannot arbitrate. Refuse, exactly like
// the no-import case.
return nil
}
pick = c
}
return pick
}
// isImportableCallee reports whether a candidate is a symbol an ES import
// of `funcName` can actually bind a bare call to: a TOP-LEVEL function,
// variable, or constant (ID == <file>::<name>, the module's own namespace).
// Methods and nested/local bindings are never import targets — importing a
// file does not bring a class method or a function-scoped helper into the
// caller's scope, so accepting them would mint impossible edges. The
// variable/constant kinds are load-bearing, not a nicety: an identifier
// alias-cast export (zustand's `export const persist = persistImpl as
// unknown as Persist`) lands as a KindVariable/KindConstant node, the
// exact value-callee shape whose repo-wide ambiguity refuses call edges
// today (see pickTopLevelValueCallee).
func isImportableCallee(c *graph.Node, funcName string) bool {
switch c.Kind {
case graph.KindFunction, graph.KindVariable, graph.KindConstant:
return c.FilePath != "" && c.ID == c.FilePath+"::"+funcName
}
return false
}
// importedFilesFor returns the set of file paths the caller file imports —
// directly, plus every file reachable from an imported barrel through
// transitive EdgeReExports hops. Memoised per caller file for the duration
// of a resolve pass (r.importFilesMu guards the map because the resolver's
// worker phase is parallel); cleared with the per-pass lookup caches.
//
// The set is built from the import edges' CURRENT state, whichever that
// is: a resolved edge contributes its target node's file, an unresolved
// one has its raw `import::` specifier expanded exactly the way
// resolveImport will expand it. Both states yield the same file, so a call
// edge resolved before its file's import edge sees the same closure as one
// resolved after.
func (r *Resolver) importedFilesFor(callerFile string) map[string]struct{} {
r.importFilesMu.RLock()
files, ok := r.importFilesByCaller[callerFile]
r.importFilesMu.RUnlock()
if ok {
return files
}
files = make(map[string]struct{})
seen := map[string]bool{callerFile: true}
var pendingBarrels []string
addTarget := func(f string) {
if f == "" || f == callerFile {
return
}
if _, dup := files[f]; dup {
return
}
files[f] = struct{}{}
pendingBarrels = append(pendingBarrels, f)
}
for _, e := range r.graph.GetOutEdges(callerFile) {
if e.Kind != graph.EdgeImports {
continue
}
addTarget(r.importedFileOf(e))
}
// Barrel hops: an import that lands on a re-exporting module makes the
// re-exported modules visible too (`import { createStore } from
// 'zustand'` → src/index.ts → src/vanilla.ts). Same transitive walk
// buildImportClosure performs for the guard, at file granularity.
for len(pendingBarrels) > 0 {
f := pendingBarrels[len(pendingBarrels)-1]
pendingBarrels = pendingBarrels[:len(pendingBarrels)-1]
if seen[f] {
continue
}
seen[f] = true
for _, e := range r.graph.GetOutEdges(f) {
if e.Kind != graph.EdgeReExports {
continue
}
addTarget(r.importedFileOf(e))
}
}
r.importFilesMu.Lock()
if r.importFilesByCaller == nil {
r.importFilesByCaller = make(map[string]map[string]struct{})
}
r.importFilesByCaller[callerFile] = files
r.importFilesMu.Unlock()
return files
}
// importedFileOf maps an EdgeImports / EdgeReExports edge to the file path
// of the module it names, or "" when the edge targets nothing indexable
// (third-party package, stdlib stub, dep contract, unexpandable specifier).
func (r *Resolver) importedFileOf(e *graph.Edge) string {
to := e.To
if to == "" {
return ""
}
if graph.IsUnresolvedTarget(to) {
payload, ok := strings.CutPrefix(graph.UnresolvedName(to), "import::")
if !ok || payload == "" {
return ""
}
// Same expansion, same precedence as resolveImport: tsconfig
// paths / relative join first, npm-alias rewrite second.
target := resolveJSTSImportTarget(r.cachedGetNode, r.pathAlias, jsTSImportCallerFile(e), payload)
if target == "" {
if rewritten, aliased := rewriteNpmAliasImport(r.npmAlias, e.FilePath, payload); aliased {
target = resolveJSTSImportTarget(r.cachedGetNode, r.pathAlias, jsTSImportCallerFile(e), rewritten)
}
}
return r.nodeFilePath(target)
}
if strings.HasPrefix(to, "external::") || strings.HasPrefix(to, "dep::") || graph.IsStdlibStub(to) {
return ""
}
return r.nodeFilePath(to)
}
// nodeFilePath returns the file path of the node id names — the node's own
// path for a file node, its defining file for a symbol node (a per-binding
// import edge resolves to the exported symbol when it exists).
func (r *Resolver) nodeFilePath(id string) string {
if id == "" {
return ""
}
n := r.cachedGetNode(id)
if n == nil {
return ""
}
if n.FilePath != "" {
return n.FilePath
}
if n.Kind == graph.KindFile {
return n.ID
}
return ""
}