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
411 lines
16 KiB
Go
411 lines
16 KiB
Go
package resolver
|
|
|
|
import "github.com/zzet/gortex/internal/graph"
|
|
|
|
// Function-as-value callback gate.
|
|
//
|
|
// A large class of real call relationships is wired by passing a function as a
|
|
// *value* — registering a handler (`router.Get("/x", handler)`), a callback
|
|
// (`list.forEach(process)`), an observer (`signal.connect(onChange)`) — rather
|
|
// than calling it directly. The per-language extractors capture each such
|
|
// value-position identifier as a placeholder reference edge
|
|
// (To = "unresolved::fnvalue::<name>", Meta via="callback_candidate",
|
|
// fn_value_name=<name>); see EmitFnValueCandidates in the languages package.
|
|
//
|
|
// Capture alone floods: every bare identifier in a value position is a
|
|
// candidate, and most are locals, parameters, or builtins, not functions. This
|
|
// gate is the other half of the pair — it binds each candidate to a real
|
|
// function/method in the SAME FILE and drops the rest, so an unbound identifier
|
|
// never becomes an edge.
|
|
//
|
|
// Beat: the landed edge rides a provenance TIER (OriginASTInferred — a
|
|
// scope-bound name resolution, strictly above text_matched) so callback edges
|
|
// are min_tier-filterable like every other Gortex edge, instead of carrying a
|
|
// single flat heuristic flag. The per-language value-position capture lands on
|
|
// top of this skeleton.
|
|
const (
|
|
// SynthFnValueCallback is the provenance tag for a bound callback edge.
|
|
SynthFnValueCallback = "fn-value-callback"
|
|
|
|
// fnValueCandidateVia marks an extractor-emitted placeholder awaiting the
|
|
// gate; fnValueRegistrationVia marks the bound edge the gate lands.
|
|
fnValueCandidateVia = "callback_candidate"
|
|
fnValueRegistrationVia = "callback_registration"
|
|
|
|
// metaFnValueName carries the captured bare identifier on both the
|
|
// placeholder and the bound edge.
|
|
metaFnValueName = "fn_value_name"
|
|
)
|
|
|
|
// ResolveFnValueCallbacks binds each captured function-as-value placeholder to a
|
|
// same-file function/method and lands a tiered callback-registration reference
|
|
// edge, dropping any candidate that does not resolve to a real function. It is a
|
|
// full-recompute, idempotent synthesizer: graph.AddEdge dedupes and
|
|
// graph.EvictFile drops the edges on reindex. Returns the number of edges
|
|
// landed.
|
|
func ResolveFnValueCallbacks(g graph.Store) int { return resolveFnValueCallbacks(g, nil) }
|
|
|
|
// ResolveFnValueCallbacksScoped is the incremental counterpart of
|
|
// ResolveFnValueCallbacks: it gates only the callback candidates that originate
|
|
// in the given changed repos, leaving an unchanged repo's already-bound
|
|
// registrations on disk (they were never dropped). A nil scope gates the whole
|
|
// graph, so ResolveFnValueCallbacks and the whole-index path stay identical.
|
|
//
|
|
// Only the CANDIDATE scan is scoped. A candidate placeholder lives in (is
|
|
// emitted from) the repo that declared the registration, so a changed repo owns
|
|
// exactly the candidates whose binding its reindex dropped. RESOLUTION stays
|
|
// whole-graph — the resolve helpers below scan the entire graph by name — so a
|
|
// changed-repo callback still binds to a handler that lives in an unchanged repo.
|
|
func ResolveFnValueCallbacksScoped(g graph.Store, scope map[string]bool) int {
|
|
return resolveFnValueCallbacks(g, scope)
|
|
}
|
|
|
|
func resolveFnValueCallbacks(g graph.Store, scope map[string]bool) int {
|
|
if g == nil {
|
|
return 0
|
|
}
|
|
var landed []*graph.Edge
|
|
// Candidates sharing a file each want the same GetFileNodes(filePath)
|
|
// result. Fetching it fresh per candidate is a per-candidate SQL
|
|
// round-trip regardless of how few nodes the file has — a generated file
|
|
// with a large candidate count (a tree-sitter parser.c, an ORM-generated
|
|
// Go file) turns into hundreds of thousands of redundant queries against
|
|
// a handful of nodes. Cache per file for the life of this pass.
|
|
fileNodes := map[string][]*graph.Node{}
|
|
getFileNodes := func(filePath string) []*graph.Node {
|
|
if ns, ok := fileNodes[filePath]; ok {
|
|
return ns
|
|
}
|
|
ns := g.GetFileNodes(filePath)
|
|
fileNodes[filePath] = ns
|
|
return ns
|
|
}
|
|
// nameMemo caches g.FindNodesByName(name) for the life of the pass. The
|
|
// resolve helpers hit it repeatedly for the same registration name (every
|
|
// router.Get("/x", handler) that names the same handler, every recurring
|
|
// Class::method string), and each hit was an unmemoized FindNodesByName —
|
|
// on a large graph the single largest cost of the gate. No node is added or
|
|
// removed until the AddEdge tail below, so a name's node set is stable
|
|
// across the pass and the memo returns identical results.
|
|
nameMemo := map[string][]*graph.Node{}
|
|
process := func(e *graph.Edge) {
|
|
if e == nil || e.Meta == nil {
|
|
return
|
|
}
|
|
if via, _ := e.Meta["via"].(string); via != fnValueCandidateVia {
|
|
return
|
|
}
|
|
name, _ := e.Meta[metaFnValueName].(string)
|
|
if name == "" || isFnValueNonTarget(name) {
|
|
return
|
|
}
|
|
// Resolution scope depends on the captured form. A special form's
|
|
// receiver hint (`<self>` / a concrete type) binds the member against
|
|
// that type's methods (compiler-precise); a qualified-path candidate
|
|
// marked `fn_value_ungated` may bind cross-module at a lower tier; a
|
|
// plain candidate binds same-file.
|
|
recvHint, _ := e.Meta["fn_ref_recv_hint"].(string)
|
|
ungated, _ := e.Meta["fn_value_ungated"].(bool)
|
|
skipGate, _ := e.Meta["skip_gate"].(bool)
|
|
target := ""
|
|
conf := 0.6
|
|
origin := graph.OriginASTInferred
|
|
switch {
|
|
case skipGate:
|
|
// Curated-HOF string callable: bypass same-file scope and bind by a
|
|
// repo-wide unique-or-drop rule (a `Class::method` string scopes to
|
|
// the type).
|
|
if recvHint != "" {
|
|
target = resolveMemberByTypeMemo(g, recvHint, name, nameMemo)
|
|
}
|
|
if target == "" {
|
|
target = resolveUniqueFnValueMemo(g, name, nameMemo)
|
|
}
|
|
conf = 0.5
|
|
case recvHint == "<self>":
|
|
if target = resolveFnValueSelfMemberMemo(g, e.From, name, nameMemo); target != "" {
|
|
conf, origin = 0.85, graph.OriginASTResolved
|
|
} else {
|
|
target = resolveFnValueName(getFileNodes(e.FilePath), name)
|
|
}
|
|
case recvHint != "":
|
|
if target = resolveMemberByTypeMemo(g, recvHint, name, nameMemo); target != "" {
|
|
conf, origin = 0.85, graph.OriginASTResolved
|
|
} else if ungated {
|
|
target = resolveFnValueCrossModuleMemo(g, name, nameMemo)
|
|
conf = 0.45
|
|
}
|
|
default:
|
|
target = resolveFnValueName(getFileNodes(e.FilePath), name)
|
|
if target == "" && ungated {
|
|
target = resolveFnValueCrossModuleMemo(g, name, nameMemo)
|
|
conf = 0.45
|
|
}
|
|
}
|
|
if target == "" || target == e.From {
|
|
// Unbound (a local / param / undefined name) or a self-reference
|
|
// (a function's own declaration token): reject rather than
|
|
// fabricate an edge.
|
|
return
|
|
}
|
|
meta := map[string]any{
|
|
"via": fnValueRegistrationVia,
|
|
metaFnValueName: name,
|
|
MetaSynthesizedBy: SynthFnValueCallback,
|
|
MetaProvenance: ProvenanceHeuristic,
|
|
}
|
|
if form, _ := e.Meta["fn_ref_form"].(string); form != "" {
|
|
meta["fn_ref_form"] = form
|
|
}
|
|
landed = append(landed, &graph.Edge{
|
|
From: e.From,
|
|
To: target,
|
|
Kind: graph.EdgeReferences,
|
|
FilePath: e.FilePath,
|
|
Line: e.Line,
|
|
Confidence: conf,
|
|
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeReferences, conf),
|
|
Origin: origin,
|
|
Meta: meta,
|
|
})
|
|
}
|
|
|
|
if scope == nil {
|
|
// The gate needs only the placeholders parked in the fn-value namespace,
|
|
// not every reference edge. When the backend can range-scan that namespace
|
|
// (FnValuePlaceholderScanner) use it: the generic EdgesByKind(references)
|
|
// path materialises the whole placeholders-plus-real-references set on every
|
|
// whole-graph synthesizer pass — several times the size of the placeholder
|
|
// slice on a large multi-repo graph. Both iterators are iter.Seq[*Edge], so
|
|
// the loop body is identical; the Meta["via"] == callback_candidate filter
|
|
// in process STAYS on both paths — a non-candidate edge can be parked in the
|
|
// namespace (e.g. an already-bound registration) and must never be gated.
|
|
edges := g.EdgesByKind(graph.EdgeReferences)
|
|
if fp, ok := g.(graph.FnValuePlaceholderScanner); ok {
|
|
edges = fp.FnValuePlaceholderEdges()
|
|
}
|
|
for e := range edges {
|
|
process(e)
|
|
}
|
|
} else {
|
|
// Scoped: walk only the changed repos' out-edges (GetRepoEdges is one
|
|
// backend query per repo). The via filter in process still applies, so a
|
|
// non-candidate reference edge in the changed repo is ignored.
|
|
for prefix := range scope {
|
|
if prefix == "" {
|
|
continue
|
|
}
|
|
for _, e := range g.GetRepoEdges(prefix) {
|
|
if e == nil || e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
process(e)
|
|
}
|
|
}
|
|
}
|
|
for _, e := range landed {
|
|
g.AddEdge(e)
|
|
}
|
|
return len(landed)
|
|
}
|
|
|
|
// resolveFnValueName returns the ID of a function or method named name among
|
|
// fileNodes (the caller's already-fetched same-file node list), or "" when
|
|
// none exists. Same-file scope is the conservative default; per-language
|
|
// capture extends the gate with imported-symbol and C-family file-scope
|
|
// rules on top of this skeleton.
|
|
func resolveFnValueName(fileNodes []*graph.Node, name string) string {
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
for _, n := range fileNodes {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
if n.Name != name {
|
|
continue
|
|
}
|
|
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
|
|
return n.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveUniqueFnValue returns the ID of the sole function/method named name in
|
|
// the repo, or "" when none or more than one exists (unique-or-drop). The
|
|
// shared repo-wide resolution rule for qualified-path and gate-skipping
|
|
// (curated-HOF string) function values. Prototype declarations of the name
|
|
// never make it ambiguous — see uniqueFnValueMatchMemo.
|
|
func resolveUniqueFnValue(g graph.Store, name string) string {
|
|
return resolveUniqueFnValueMemo(g, name, nil)
|
|
}
|
|
|
|
// resolveUniqueFnValueMemo is resolveUniqueFnValue with a shared per-pass
|
|
// FindNodesByName memo (nil disables memoization).
|
|
func resolveUniqueFnValueMemo(g graph.Store, name string, memo map[string][]*graph.Node) string {
|
|
return uniqueFnValueMatchMemo(g, name, nil, memo)
|
|
}
|
|
|
|
// resolveFnValueCrossModuleMemo binds a function value to a uniquely-named
|
|
// function/method anywhere in the repo, skipping any candidate with file-local
|
|
// linkage (a C/C++ `static` function, stamped scope_static): such a definition
|
|
// is invisible outside its translation unit, so a cross-module reference can
|
|
// never target it, and a same-named static in an unrelated file must not make
|
|
// the name look ambiguous. The same-file path is preferred by the caller; this
|
|
// is the cross-module fallback. A shared per-pass FindNodesByName memo collapses
|
|
// repeated lookups of the same name (nil disables memoization).
|
|
func resolveFnValueCrossModuleMemo(g graph.Store, name string, memo map[string][]*graph.Node) string {
|
|
return uniqueFnValueMatchMemo(g, name, isFileLocalLinkage, memo)
|
|
}
|
|
|
|
// findNodesByNameMemo wraps g.FindNodesByName with an optional per-pass cache.
|
|
// The gate calls it for the same registration names many times; caching the
|
|
// result collapses those to one backend lookup per distinct name. Safe only
|
|
// within a pass that does not add or remove nodes between lookups. A nil memo
|
|
// forwards straight through, so non-pass callers see identical behaviour.
|
|
func findNodesByNameMemo(g graph.Store, name string, memo map[string][]*graph.Node) []*graph.Node {
|
|
if memo == nil {
|
|
return g.FindNodesByName(name)
|
|
}
|
|
if ns, ok := memo[name]; ok {
|
|
return ns
|
|
}
|
|
ns := g.FindNodesByName(name)
|
|
memo[name] = ns
|
|
return ns
|
|
}
|
|
|
|
// uniqueFnValueMatchMemo is the shared unique-or-drop scan over every
|
|
// function/method named name, with an optional per-node exclusion and a shared
|
|
// per-pass FindNodesByName memo (nil disables memoization).
|
|
//
|
|
// A C-family forward declaration (`void strlenCommand(client *c);` in a
|
|
// header, stamped Meta["prototype"]) names the SAME extern symbol as its
|
|
// definition, not a competitor — C has one flat namespace per linked program.
|
|
// Counting it as a distinct candidate made every prototyped function
|
|
// permanently ambiguous (definition + header declaration = two nodes), which
|
|
// silently dropped the entire generated-command-table reference surface: a
|
|
// codebase that declares its handlers in a shared header is exactly the
|
|
// codebase that wires them through a table. Definitions therefore win:
|
|
// prototypes are consulted only when no definition matches at all (the
|
|
// definition's translation unit isn't indexed), and then under the same
|
|
// unique-or-drop rule.
|
|
func uniqueFnValueMatchMemo(g graph.Store, name string, exclude func(*graph.Node) bool, memo map[string][]*graph.Node) string {
|
|
def, proto := "", ""
|
|
for _, n := range findNodesByNameMemo(g, name, memo) {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
if exclude != nil && exclude(n) {
|
|
continue
|
|
}
|
|
if isPrototypeDecl(n) {
|
|
if proto != "" && proto != n.ID {
|
|
proto = ambiguousFnValue
|
|
} else {
|
|
proto = n.ID
|
|
}
|
|
continue
|
|
}
|
|
if def != "" && def != n.ID {
|
|
return "" // two real definitions — genuinely ambiguous
|
|
}
|
|
def = n.ID
|
|
}
|
|
if def != "" {
|
|
return def
|
|
}
|
|
if proto == ambiguousFnValue {
|
|
return ""
|
|
}
|
|
return proto
|
|
}
|
|
|
|
// ambiguousFnValue is a sentinel marking a name matched by more than one
|
|
// prototype declaration; it can never collide with a real node ID because the
|
|
// ID convention is "<file>::<name>".
|
|
const ambiguousFnValue = "\x00ambiguous"
|
|
|
|
// isPrototypeDecl reports whether a node is a C-family forward declaration
|
|
// (stamped Meta["prototype"] by the extractor) rather than a definition.
|
|
func isPrototypeDecl(n *graph.Node) bool {
|
|
if n.Meta == nil {
|
|
return false
|
|
}
|
|
v, _ := n.Meta["prototype"].(bool)
|
|
return v
|
|
}
|
|
|
|
// isFileLocalLinkage reports whether a node was stamped with translation-unit
|
|
// (C/C++ static) linkage, so it cannot be the target of a cross-module value
|
|
// reference.
|
|
func isFileLocalLinkage(n *graph.Node) bool {
|
|
if n.Meta == nil {
|
|
return false
|
|
}
|
|
v, _ := n.Meta["scope_static"].(bool)
|
|
return v
|
|
}
|
|
|
|
// resolveMemberByType binds member to a uniquely-named method of typeName
|
|
// (matched via Meta["receiver"]), or "" when none or more than one matches.
|
|
// Shared scope rule for `Foo::bar`-style references and self-member resolution.
|
|
func resolveMemberByType(g graph.Store, typeName, member string) string {
|
|
return resolveMemberByTypeMemo(g, typeName, member, nil)
|
|
}
|
|
|
|
// resolveMemberByTypeMemo is resolveMemberByType with a shared per-pass
|
|
// FindNodesByName memo (nil disables memoization).
|
|
func resolveMemberByTypeMemo(g graph.Store, typeName, member string, memo map[string][]*graph.Node) string {
|
|
if typeName == "" || member == "" {
|
|
return ""
|
|
}
|
|
match := ""
|
|
for _, n := range findNodesByNameMemo(g, member, memo) {
|
|
if n == nil || n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
if recv, _ := n.Meta["receiver"].(string); recv != typeName {
|
|
continue
|
|
}
|
|
if match != "" && match != n.ID {
|
|
return "" // ambiguous within the type — drop
|
|
}
|
|
match = n.ID
|
|
}
|
|
return match
|
|
}
|
|
|
|
// resolveFnValueSelfMemberMemo binds a `this.m` / `self.m` member reference
|
|
// against the methods of the registration site's enclosing type, so it can
|
|
// never bind a coincidentally-named top-level function. A shared per-pass
|
|
// FindNodesByName memo collapses repeated lookups (nil disables memoization).
|
|
func resolveFnValueSelfMemberMemo(g graph.Store, fromID, member string, memo map[string][]*graph.Node) string {
|
|
from := g.GetNode(fromID)
|
|
if from == nil || from.Meta == nil {
|
|
return ""
|
|
}
|
|
recv, _ := from.Meta["receiver"].(string)
|
|
if recv == "" {
|
|
return ""
|
|
}
|
|
return resolveMemberByTypeMemo(g, recv, member, memo)
|
|
}
|
|
|
|
// isFnValueNonTarget reports whether name is a literal/keyword/builtin that
|
|
// can never be a captured function value, so the gate skips it before the
|
|
// same-file lookup. The set is deliberately small and language-agnostic; the
|
|
// per-language capture passes refine it with isGoBuiltinOrKeyword-style checks.
|
|
func isFnValueNonTarget(name string) bool {
|
|
switch name {
|
|
case "true", "false", "nil", "null", "none", "None", "undefined",
|
|
"this", "self", "super", "new", "delete", "typeof", "void":
|
|
return true
|
|
}
|
|
return false
|
|
}
|