chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# `internal/resolver`
|
||||
|
||||
Resolves unresolved edges in the graph — symbol references and
|
||||
imports recorded as `unresolved::...` placeholders by the per-language
|
||||
extractors get rewritten to point at real graph nodes once those
|
||||
nodes exist.
|
||||
|
||||
## Cross-repo + cross-workspace boundary
|
||||
|
||||
`CrossRepoResolver` searches across repository boundaries when no
|
||||
same-repo candidate exists. With a `CrossWorkspaceDepLookup` wired
|
||||
via `SetCrossWorkspaceDepLookup`, candidates from a *different*
|
||||
workspace are accepted only when:
|
||||
|
||||
1. The source workspace declares the target workspace in its
|
||||
`cross_workspace_deps`, AND
|
||||
2. For import edges, the import path matches a declared module
|
||||
prefix (longest match wins).
|
||||
|
||||
For function/method-call edges (no import path available) the
|
||||
workspace-pair declaration alone is sufficient. The whole point of
|
||||
this layer is that an unresolved call into another workspace
|
||||
silently fails to resolve unless the user opted in.
|
||||
|
||||
When the lookup is unset (legacy callers), the resolver falls back
|
||||
to permissive cross-repo lookup: any cross-repo candidate is fair
|
||||
game. This keeps existing tests and single-workspace setups working
|
||||
without code changes.
|
||||
|
||||
## Wiring
|
||||
|
||||
The `internal/indexer.MultiIndexer` builds the lookup from each
|
||||
tracked repo's `.gortex.yaml::cross_workspace_deps` and sets it on
|
||||
the resolver in two places:
|
||||
|
||||
- `MultiWatcher.NewMultiWatcher` for live-edit re-resolution.
|
||||
- `MultiIndexer.IndexAll` after the per-repo indexing loop completes.
|
||||
Also re-run by `MultiIndexer.RunGlobalResolve` after warmup.
|
||||
|
||||
If neither path runs (e.g. a single-repo `Indexer.ResolveAll`), the
|
||||
boundary is permissive.
|
||||
@@ -0,0 +1,23 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// backendResolverEnabled reports whether the resolver should consult
|
||||
// graph.BackendResolver before running its Go-side worker pool.
|
||||
// Default on for the disk-backed daemon: the backend resolver runs
|
||||
// one query per rule rather than one round-trip per unresolved edge.
|
||||
// With the multi-repo encoding exposing 100k+ `unresolved::*` edges
|
||||
// at warmup, the per-edge Go path is the difference between a sub-
|
||||
// 10-minute warmup and a hang / OOM. Set GORTEX_BACKEND_RESOLVER=0
|
||||
// to opt back out for the edge case where a small in-memory corpus
|
||||
// can be heuristically resolved faster in RAM.
|
||||
func backendResolverEnabled() bool {
|
||||
v := os.Getenv("GORTEX_BACKEND_RESOLVER")
|
||||
if v == "0" || strings.EqualFold(v, "false") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// scopeNode is the per-binding payload of the owner-keyed scope
|
||||
// index built by bindBareNameScopeRefs. Kept as a named struct so
|
||||
// the bind helpers can share the same signature.
|
||||
type scopeNode struct {
|
||||
id string
|
||||
name string
|
||||
startLine int
|
||||
kind graph.NodeKind
|
||||
}
|
||||
|
||||
// bindBareNameScopeRefs rewrites `unresolved::<bareName>` edges whose
|
||||
// source is inside a function scope (or IS a function) onto the
|
||||
// matching KindLocal / KindParam node that the enclosing function
|
||||
// declares. Pre-#77 there was nothing to bind to — locals were
|
||||
// edge-endpoint-only — so the resolver always fell through to
|
||||
// `unresolved::*`. With #77's KindLocal materialisation the scope is
|
||||
// now first-class and we can do the bind.
|
||||
//
|
||||
// Two precedence rules govern the choice when more than one candidate
|
||||
// matches the name:
|
||||
//
|
||||
// 1. KindLocal beats KindParam — Go shadowing semantics, a local
|
||||
// declared with the same name as a parameter takes over from its
|
||||
// declaration line onwards.
|
||||
// 2. Among KindLocal candidates the most recently declared one before
|
||||
// the reference line wins (the standard "last shadow in scope"
|
||||
// rule). The edge's Line field is the reference site; we filter
|
||||
// candidates to StartLine <= reference line and pick the maximum
|
||||
// StartLine.
|
||||
//
|
||||
// Ambiguous cases that don't resolve to one winner (e.g. two locals
|
||||
// with the same Name on the same StartLine, or no candidate before
|
||||
// the reference line) are left untouched so the downstream `unresolved`
|
||||
// audit can still surface them.
|
||||
//
|
||||
// Scope today is Go-only — TypeScript / Python don't materialise
|
||||
// locals yet, so their unresolved bare-name edges have no candidate
|
||||
// to bind to. The pass naturally degenerates to a no-op for those
|
||||
// languages because the candidate index will be empty for their
|
||||
// owners.
|
||||
func (r *Resolver) bindBareNameScopeRefs() {
|
||||
// Index every KindLocal / KindParam by enclosing-function ID. Done
|
||||
// once up front so the per-edge bind is an O(matching-name) walk
|
||||
// rather than a graph-wide FindNodesByName.
|
||||
owned := map[string][]scopeNode{}
|
||||
for n := range r.graph.NodesByKind(graph.KindLocal) {
|
||||
owner := enclosingFunctionForBinding(n.ID)
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
owned[owner] = append(owned[owner], scopeNode{
|
||||
id: n.ID, name: n.Name, startLine: n.StartLine, kind: graph.KindLocal,
|
||||
})
|
||||
}
|
||||
for n := range r.graph.NodesByKind(graph.KindParam) {
|
||||
owner := enclosingFunctionForBinding(n.ID)
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
owned[owner] = append(owned[owner], scopeNode{
|
||||
id: n.ID, name: n.Name, startLine: n.StartLine, kind: graph.KindParam,
|
||||
})
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var batch []graph.EdgeReindex
|
||||
for e := range r.graph.EdgesByKind(graph.EdgeReads) {
|
||||
if rewrote := r.tryBindBareName(e, owned); rewrote != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: rewrote})
|
||||
}
|
||||
}
|
||||
for e := range r.graph.EdgesByKind(graph.EdgeReferences) {
|
||||
if rewrote := r.tryBindBareName(e, owned); rewrote != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: rewrote})
|
||||
}
|
||||
}
|
||||
// EdgeArgOf and EdgeValueFlow carry the same shape — `unresolved::<name>`
|
||||
// is the dataflow source/target the parser couldn't bind.
|
||||
for e := range r.graph.EdgesByKind(graph.EdgeArgOf) {
|
||||
if rewrote := r.tryBindBareName(e, owned); rewrote != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: rewrote})
|
||||
}
|
||||
}
|
||||
for e := range r.graph.EdgesByKind(graph.EdgeValueFlow) {
|
||||
if rewrote := r.tryBindBareName(e, owned); rewrote != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: rewrote})
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// bindBareNameScopeRefsForFile is the single-file scope of
|
||||
// bindBareNameScopeRefs. A bare-name reference binds to a KindLocal /
|
||||
// KindParam declared by its OWN enclosing function, and that function —
|
||||
// with all of its locals and params — lives entirely in the edited file.
|
||||
// So the scope index is built from the file's own KindLocal / KindParam
|
||||
// nodes and only the file's outgoing Read / Reference / ArgOf / ValueFlow
|
||||
// edges are considered. This produces the same binds as the whole-graph
|
||||
// sweep for a per-save resolve without scanning every KindLocal in the
|
||||
// graph (the single largest node kind).
|
||||
func (r *Resolver) bindBareNameScopeRefsForFile(filePath string) {
|
||||
owned := map[string][]scopeNode{}
|
||||
for _, n := range r.graph.GetFileNodes(filePath) {
|
||||
if n.Kind != graph.KindLocal && n.Kind != graph.KindParam {
|
||||
continue
|
||||
}
|
||||
owner := enclosingFunctionForBinding(n.ID)
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
owned[owner] = append(owned[owner], scopeNode{
|
||||
id: n.ID, name: n.Name, startLine: n.StartLine, kind: n.Kind,
|
||||
})
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var batch []graph.EdgeReindex
|
||||
for _, e := range r.fileOutEdges(filePath) {
|
||||
switch e.Kind {
|
||||
case graph.EdgeReads, graph.EdgeReferences, graph.EdgeArgOf, graph.EdgeValueFlow:
|
||||
if rewrote := r.tryBindBareName(e, owned); rewrote != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: rewrote})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// tryBindBareName tries to rewrite e.To from `unresolved::<name>` to a
|
||||
// matching in-scope KindLocal/KindParam ID. Returns the original To
|
||||
// value when a rewrite happened (caller batches it for ReindexEdges)
|
||||
// or "" when the edge was left alone.
|
||||
func (r *Resolver) tryBindBareName(e *graph.Edge, owned map[string][]scopeNode) string {
|
||||
if e == nil || !graph.IsUnresolvedTarget(e.To) {
|
||||
return ""
|
||||
}
|
||||
name := graph.UnresolvedName(e.To)
|
||||
if name == "" || strings.ContainsAny(name, ".*:#") {
|
||||
// Not a bare identifier — leave to other passes (qualified
|
||||
// names, *.method, etc.).
|
||||
return ""
|
||||
}
|
||||
ownerID := enclosingFunctionForBinding(e.From)
|
||||
if ownerID == "" {
|
||||
return ""
|
||||
}
|
||||
candidates := owned[ownerID]
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
chosen := pickInScopeBinding(candidates, name, e.Line)
|
||||
if chosen == "" || chosen == e.To {
|
||||
return ""
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = chosen
|
||||
return oldTo
|
||||
}
|
||||
|
||||
// pickInScopeBinding implements the precedence rules:
|
||||
// - prefer KindLocal over KindParam (Go shadowing),
|
||||
// - among KindLocal, pick the latest StartLine that's still <= refLine,
|
||||
// - if multiple candidates match the same maximum StartLine, return ""
|
||||
// (ambiguous — leave the edge unresolved so the audit surfaces it).
|
||||
//
|
||||
// owned is the per-owner scope-node slice; name is the bare identifier
|
||||
// from the edge target; refLine is the edge's line (the reference
|
||||
// site). Returns the chosen ID, or "" when no unambiguous winner.
|
||||
func pickInScopeBinding(owned []scopeNode, name string, refLine int) string {
|
||||
var bestLocal struct {
|
||||
id string
|
||||
line int
|
||||
dups int
|
||||
}
|
||||
var paramID string
|
||||
for _, c := range owned {
|
||||
if c.name != name {
|
||||
continue
|
||||
}
|
||||
if c.kind == graph.KindLocal {
|
||||
if refLine > 0 && c.startLine > refLine {
|
||||
// Declared after the reference — can't be bound here.
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c.startLine > bestLocal.line:
|
||||
bestLocal.id = c.id
|
||||
bestLocal.line = c.startLine
|
||||
bestLocal.dups = 0
|
||||
case c.startLine == bestLocal.line && c.id != bestLocal.id:
|
||||
bestLocal.dups++
|
||||
}
|
||||
} else if c.kind == graph.KindParam {
|
||||
if paramID != "" && paramID != c.id {
|
||||
// Two params with the same name in the same function
|
||||
// shouldn't happen but defensive — abstain.
|
||||
paramID = ""
|
||||
} else {
|
||||
paramID = c.id
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestLocal.id != "" && bestLocal.dups == 0 {
|
||||
return bestLocal.id
|
||||
}
|
||||
return paramID
|
||||
}
|
||||
|
||||
// enclosingFunctionForBinding strips the per-binding suffix added by
|
||||
// the Go extractor (`#local:`, `#param:`, `#closure`, `#tparam:`) to
|
||||
// recover the owner function/method ID. If `id` has no suffix it's
|
||||
// returned unchanged — the caller is already a function/method node
|
||||
// directly (the per-edge From is the function itself for things like
|
||||
// the `external::foo` import edge inside `func Foo()`).
|
||||
func enclosingFunctionForBinding(id string) string {
|
||||
if i := strings.Index(id, "#"); i > 0 {
|
||||
return id[:i]
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestBindBareNameScopeRefs_LocalWins covers the headline case: a
|
||||
// function declares a KindLocal `key1`; an EdgeReads to
|
||||
// `unresolved::key1` originating from that function's body should be
|
||||
// rewritten to point at the KindLocal node.
|
||||
func TestBindBareNameScopeRefs_LocalWins(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
localID := owner + "#local:key1@+3"
|
||||
g.AddNode(&graph.Node{
|
||||
ID: localID, Kind: graph.KindLocal, Name: "key1",
|
||||
FilePath: "pkg/foo.go", StartLine: 3, EndLine: 3, Language: "go",
|
||||
})
|
||||
g.AddEdge(&graph.Edge{From: localID, To: owner, Kind: graph.EdgeMemberOf, FilePath: "pkg/foo.go", Line: 3})
|
||||
|
||||
edge := &graph.Edge{
|
||||
From: owner, To: "unresolved::key1",
|
||||
Kind: graph.EdgeReads, FilePath: "pkg/foo.go", Line: 5,
|
||||
}
|
||||
g.AddEdge(edge)
|
||||
|
||||
r := New(g)
|
||||
r.bindBareNameScopeRefs()
|
||||
|
||||
assert.Equal(t, localID, edge.To, "EdgeReads must be rewritten to the in-scope KindLocal")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_FromBindingResolvesToOwner — the From of
|
||||
// the edge is itself a per-binding ID (`<func>#local:x@+N`); the
|
||||
// pass should strip the suffix to recover the enclosing function and
|
||||
// still bind correctly.
|
||||
func TestBindBareNameScopeRefs_FromBindingResolvesToOwner(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
keyID := owner + "#local:key@+2"
|
||||
g.AddNode(&graph.Node{ID: keyID, Kind: graph.KindLocal, Name: "key", FilePath: "pkg/foo.go", StartLine: 2, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: keyID, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
from := owner + "#local:out@+5"
|
||||
g.AddNode(&graph.Node{ID: from, Kind: graph.KindLocal, Name: "out", FilePath: "pkg/foo.go", StartLine: 5, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: from, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
edge := &graph.Edge{From: from, To: "unresolved::key", Kind: graph.EdgeValueFlow, Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, keyID, edge.To, "From with #local: suffix must still resolve via enclosing function")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_ParamFallback covers the Go-shadowing
|
||||
// fallback: when no local matches, the parameter with the same name
|
||||
// wins.
|
||||
func TestBindBareNameScopeRefs_ParamFallback(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
paramID := owner + "#param:req"
|
||||
g.AddNode(&graph.Node{ID: paramID, Kind: graph.KindParam, Name: "req", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: paramID, To: owner, Kind: graph.EdgeParamOf})
|
||||
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::req", Kind: graph.EdgeReads, Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, paramID, edge.To, "no matching local — param with same name must take over")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_LocalShadowsParam — both a param and a
|
||||
// local share the same name; the local wins (Go shadowing).
|
||||
func TestBindBareNameScopeRefs_LocalShadowsParam(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
paramID := owner + "#param:x"
|
||||
g.AddNode(&graph.Node{ID: paramID, Kind: graph.KindParam, Name: "x", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: paramID, To: owner, Kind: graph.EdgeParamOf})
|
||||
|
||||
localID := owner + "#local:x@+4"
|
||||
g.AddNode(&graph.Node{ID: localID, Kind: graph.KindLocal, Name: "x", FilePath: "pkg/foo.go", StartLine: 4, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: localID, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::x", Kind: graph.EdgeReads, Line: 6}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, localID, edge.To, "KindLocal must shadow KindParam with the same name")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_RefBeforeDeclLeftAlone — a reference
|
||||
// whose line is BEFORE the local's StartLine can't possibly bind to
|
||||
// that local. The pass must leave the edge unresolved rather than
|
||||
// reach backwards.
|
||||
func TestBindBareNameScopeRefs_RefBeforeDeclLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
localID := owner + "#local:tmp@+10"
|
||||
g.AddNode(&graph.Node{ID: localID, Kind: graph.KindLocal, Name: "tmp", FilePath: "pkg/foo.go", StartLine: 10, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: localID, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::tmp", Kind: graph.EdgeReads, Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, "unresolved::tmp", edge.To, "reference before declaration must not bind")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_LatestShadowWins covers the standard "last
|
||||
// shadow in scope" rule when two locals share a name across scopes:
|
||||
// the binding declared on the higher line (closer to the reference)
|
||||
// wins.
|
||||
func TestBindBareNameScopeRefs_LatestShadowWins(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
earlier := owner + "#local:err@+2"
|
||||
g.AddNode(&graph.Node{ID: earlier, Kind: graph.KindLocal, Name: "err", FilePath: "pkg/foo.go", StartLine: 2, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: earlier, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
later := owner + "#local:err@+8"
|
||||
g.AddNode(&graph.Node{ID: later, Kind: graph.KindLocal, Name: "err", FilePath: "pkg/foo.go", StartLine: 8, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: later, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::err", Kind: graph.EdgeReads, Line: 12}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, later, edge.To, "the most recent shadow before the reference line must win")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_AmbiguousLeftAlone — two locals with the
|
||||
// same name declared on the same line (shouldn't happen in valid Go
|
||||
// but defensive): the pass must leave the edge unresolved rather
|
||||
// than pick an arbitrary winner.
|
||||
func TestBindBareNameScopeRefs_AmbiguousLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
a := owner + "#local:err@+5"
|
||||
b := owner + "#local:err@+5#1"
|
||||
g.AddNode(&graph.Node{ID: a, Kind: graph.KindLocal, Name: "err", FilePath: "pkg/foo.go", StartLine: 5, Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: b, Kind: graph.KindLocal, Name: "err", FilePath: "pkg/foo.go", StartLine: 5, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: a, To: owner, Kind: graph.EdgeMemberOf})
|
||||
g.AddEdge(&graph.Edge{From: b, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::err", Kind: graph.EdgeReads, Line: 7}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
assert.Equal(t, "unresolved::err", edge.To, "ambiguous candidates on same line must leave the edge unresolved")
|
||||
}
|
||||
|
||||
// TestBindBareNameScopeRefs_QualifiedNotTouched ensures the pass only
|
||||
// fires on bare names — qualified shapes (`*.Method`, `pkg.Name`,
|
||||
// `unresolved::pyrel::...`) are left to other passes.
|
||||
func TestBindBareNameScopeRefs_QualifiedNotTouched(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
// Even if a local matches the unqualified part, the qualified
|
||||
// shapes must be left alone.
|
||||
g.AddNode(&graph.Node{ID: owner + "#local:Foo@+2", Kind: graph.KindLocal, Name: "Foo", FilePath: "pkg/foo.go", StartLine: 2, Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: owner + "#local:Foo@+2", To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
keep := []*graph.Edge{
|
||||
{From: owner, To: "unresolved::*.Foo", Kind: graph.EdgeReads, Line: 5},
|
||||
{From: owner, To: "unresolved::pkg.Foo", Kind: graph.EdgeReads, Line: 6},
|
||||
{From: owner, To: "unresolved::pyrel::./foo", Kind: graph.EdgeReads, Line: 7},
|
||||
}
|
||||
for _, e := range keep {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
|
||||
New(g).bindBareNameScopeRefs()
|
||||
for _, e := range keep {
|
||||
assert.True(t,
|
||||
e.To == "unresolved::*.Foo" || e.To == "unresolved::pkg.Foo" || e.To == "unresolved::pyrel::./foo",
|
||||
"qualified shape %q must stay untouched", e.To,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// buildResolverGraph creates a graph with unresolved edges for benchmarking.
|
||||
func buildResolverGraph(files, symsPerFile int) (graph.Store, *Resolver) {
|
||||
g := graph.New()
|
||||
|
||||
// Create file nodes with functions, types, and methods.
|
||||
for f := range files {
|
||||
filePath := fmt.Sprintf("pkg%d/file%d.go", f/10, f)
|
||||
pkg := fmt.Sprintf("pkg%d", f/10)
|
||||
|
||||
// Add a type per file.
|
||||
typeName := fmt.Sprintf("Type%d", f)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: fmt.Sprintf("%s::%s", filePath, typeName),
|
||||
Kind: graph.KindType,
|
||||
Name: typeName,
|
||||
QualName: fmt.Sprintf("%s.%s", pkg, typeName),
|
||||
FilePath: filePath,
|
||||
Language: "go",
|
||||
Meta: map[string]any{"receiver_type": typeName},
|
||||
})
|
||||
|
||||
for s := range symsPerFile {
|
||||
funcName := fmt.Sprintf("Func%d_%d", f, s)
|
||||
nodeID := fmt.Sprintf("%s::%s", filePath, funcName)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: nodeID,
|
||||
Kind: graph.KindFunction,
|
||||
Name: funcName,
|
||||
QualName: fmt.Sprintf("%s.%s", pkg, funcName),
|
||||
FilePath: filePath,
|
||||
Language: "go",
|
||||
})
|
||||
|
||||
// Add unresolved call edges to functions in other files.
|
||||
targetFile := (f + 1) % files
|
||||
targetFunc := fmt.Sprintf("Func%d_%d", targetFile, s%symsPerFile)
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: nodeID,
|
||||
To: "unresolved::" + targetFunc,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return g, New(g)
|
||||
}
|
||||
|
||||
func BenchmarkResolver_ResolveAll(b *testing.B) {
|
||||
sizes := []struct {
|
||||
name string
|
||||
files int
|
||||
symsPerFile int
|
||||
}{
|
||||
{"Small_50files", 50, 5},
|
||||
{"Medium_200files", 200, 10},
|
||||
{"Large_500files", 500, 10},
|
||||
}
|
||||
|
||||
for _, sz := range sizes {
|
||||
b.Run(sz.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
b.StopTimer()
|
||||
_, r := buildResolverGraph(sz.files, sz.symsPerFile)
|
||||
b.StartTimer()
|
||||
r.ResolveAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolver_ResolveFile(b *testing.B) {
|
||||
_, r := buildResolverGraph(200, 10)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := range b.N {
|
||||
r.ResolveFile(fmt.Sprintf("pkg%d/file%d.go", (i%200)/10, i%200))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolver_InferImplements(b *testing.B) {
|
||||
g := graph.New()
|
||||
|
||||
// Create interfaces and implementing types.
|
||||
for i := range 20 {
|
||||
ifaceName := fmt.Sprintf("Interface%d", i)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: fmt.Sprintf("pkg/iface.go::%s", ifaceName),
|
||||
Kind: graph.KindInterface,
|
||||
Name: ifaceName,
|
||||
FilePath: "pkg/iface.go",
|
||||
Language: "go",
|
||||
Meta: map[string]any{
|
||||
"methods": []string{fmt.Sprintf("Method%d", i), fmt.Sprintf("Other%d", i)},
|
||||
},
|
||||
})
|
||||
|
||||
// 5 types implement each interface.
|
||||
for j := range 5 {
|
||||
typeName := fmt.Sprintf("Impl%d_%d", i, j)
|
||||
filePath := fmt.Sprintf("pkg/impl%d.go", j)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: fmt.Sprintf("%s::%s", filePath, typeName),
|
||||
Kind: graph.KindType,
|
||||
Name: typeName,
|
||||
FilePath: filePath,
|
||||
Language: "go",
|
||||
Meta: map[string]any{"receiver_type": typeName},
|
||||
})
|
||||
// Add methods matching the interface.
|
||||
for _, mName := range []string{fmt.Sprintf("Method%d", i), fmt.Sprintf("Other%d", i)} {
|
||||
methodID := fmt.Sprintf("%s::%s.%s", filePath, typeName, mName)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: methodID,
|
||||
Kind: graph.KindMethod,
|
||||
Name: mName,
|
||||
FilePath: filePath,
|
||||
Language: "go",
|
||||
Meta: map[string]any{"receiver_type": typeName},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
r.InferImplements()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package resolver
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// Built-in classifier for unresolved method calls.
|
||||
//
|
||||
// When resolveMethodCall can't attribute `x.Method()` to any indexed
|
||||
// definition, we consult these maps so the flow trace UI can label
|
||||
// `arr.push(x)` as `builtin::js::array::push` instead of the useless
|
||||
// "unresolved::*.push". Coverage focuses on methods that actually
|
||||
// show up in captured flows (String / Array / DOM / Promise / Map /
|
||||
// Set for JS-family; str / list / dict / set for Python). Go stdlib
|
||||
// is *not* in these maps — it's attributed via import aliases in
|
||||
// the parser (see `extractCalls` in internal/parser/languages/golang.go).
|
||||
//
|
||||
// Value is a category string used purely for UI grouping. `/` means
|
||||
// the method name is shared across multiple built-in types and we
|
||||
// can't disambiguate without receiver-type inference. The UI treats
|
||||
// anything with a `/` as "multi — take your pick".
|
||||
|
||||
var jsBuiltins = map[string]string{
|
||||
// Array
|
||||
"push": "array", "pop": "array", "shift": "array", "unshift": "array",
|
||||
"splice": "array", "reverse": "array", "sort": "array",
|
||||
"map": "array", "filter": "array", "reduce": "array", "reduceRight": "array",
|
||||
"forEach": "array", "findIndex": "array", "some": "array", "every": "array",
|
||||
"flat": "array", "flatMap": "array", "fill": "array", "copyWithin": "array",
|
||||
|
||||
// Shared (array / string)
|
||||
"concat": "array/string", "slice": "array/string",
|
||||
"includes": "array/string", "indexOf": "array/string", "lastIndexOf": "array/string",
|
||||
"find": "array/string",
|
||||
|
||||
// Array static
|
||||
"from": "array.static", "isArray": "array.static", "of": "array.static",
|
||||
|
||||
// String
|
||||
"charAt": "string", "charCodeAt": "string", "codePointAt": "string",
|
||||
"startsWith": "string", "endsWith": "string",
|
||||
"match": "string", "matchAll": "string", "normalize": "string",
|
||||
"padStart": "string", "padEnd": "string", "repeat": "string",
|
||||
"replace": "string", "replaceAll": "string", "search": "string",
|
||||
"split": "string", "substring": "string", "substr": "string",
|
||||
"toLowerCase": "string", "toUpperCase": "string",
|
||||
"trim": "string", "trimStart": "string", "trimEnd": "string",
|
||||
"localeCompare": "string", "at": "string",
|
||||
"fromCharCode": "string.static", "fromCodePoint": "string.static",
|
||||
|
||||
// Number
|
||||
"toFixed": "number", "toPrecision": "number", "toExponential": "number",
|
||||
"parseFloat": "number.static", "parseInt": "number.static",
|
||||
"isNaN": "number.static", "isFinite": "number.static", "isInteger": "number.static",
|
||||
|
||||
// JSON (static on the JSON object)
|
||||
"parse": "json", "stringify": "json",
|
||||
|
||||
// Object (static on Object)
|
||||
"assign": "object", "freeze": "object", "create": "object",
|
||||
"getPrototypeOf": "object", "setPrototypeOf": "object",
|
||||
"defineProperty": "object", "defineProperties": "object",
|
||||
|
||||
// Map / Set / WeakMap / WeakSet
|
||||
"set": "map", "get": "map",
|
||||
"has": "map/set", "delete": "map/set", "clear": "map/set", "size": "map/set",
|
||||
"add": "set",
|
||||
|
||||
// Shared (array / map / object / set): keys/values/entries
|
||||
"keys": "array/map/object", "values": "array/map/object", "entries": "array/map/object",
|
||||
|
||||
// Promise
|
||||
"then": "promise", "catch": "promise", "finally": "promise",
|
||||
"resolve": "promise.static", "reject": "promise.static",
|
||||
"all": "promise.static", "allSettled": "promise.static", "race": "promise.static", "any": "promise.static",
|
||||
|
||||
// DOM — Node / Element
|
||||
"querySelector": "dom", "querySelectorAll": "dom",
|
||||
"getElementById": "dom", "getElementsByClassName": "dom", "getElementsByTagName": "dom",
|
||||
"getAttribute": "dom", "setAttribute": "dom", "removeAttribute": "dom", "hasAttribute": "dom",
|
||||
"appendChild": "dom", "removeChild": "dom", "insertBefore": "dom",
|
||||
"replaceChild": "dom", "cloneNode": "dom",
|
||||
"addEventListener": "dom", "removeEventListener": "dom", "dispatchEvent": "dom",
|
||||
"click": "dom", "focus": "dom", "blur": "dom",
|
||||
"createElement": "dom", "createTextNode": "dom", "createDocumentFragment": "dom",
|
||||
"parseFromString": "dom.DOMParser",
|
||||
"contains": "dom/array/string",
|
||||
|
||||
// console
|
||||
"log": "console", "warn": "console", "error": "console", "debug": "console", "info": "console",
|
||||
}
|
||||
|
||||
var pyBuiltins = map[string]string{
|
||||
// str
|
||||
"startswith": "str", "endswith": "str", "rfind": "str",
|
||||
"upper": "str", "lower": "str", "capitalize": "str", "title": "str",
|
||||
"strip": "str", "lstrip": "str", "rstrip": "str",
|
||||
"format": "str",
|
||||
|
||||
// list
|
||||
"append": "list", "extend": "list", "insert": "list",
|
||||
|
||||
// dict
|
||||
"setdefault": "dict", "update": "dict",
|
||||
|
||||
// set
|
||||
"discard": "set", "union": "set", "intersection": "set",
|
||||
"difference": "set", "symmetric_difference": "set",
|
||||
|
||||
// Shared across str/list/dict/set
|
||||
"split": "str", "rsplit": "str", "join": "str",
|
||||
"replace": "str",
|
||||
"remove": "list/set", "pop": "list/dict/set",
|
||||
"index": "list/str", "count": "list/str", "sort": "list", "reverse": "list",
|
||||
"get": "dict", "keys": "dict", "values": "dict", "items": "dict",
|
||||
"add": "set", "has": "set",
|
||||
"find": "str",
|
||||
}
|
||||
|
||||
// classifyBuiltin returns the category for a built-in method name in a
|
||||
// given language family, if any. Languages are normalised upstream by
|
||||
// langFromFilePath — "ts" and "js" both query jsBuiltins.
|
||||
func classifyBuiltin(method, lang string) (string, bool) {
|
||||
switch lang {
|
||||
case "js", "ts":
|
||||
c, ok := jsBuiltins[method]
|
||||
return c, ok
|
||||
case "py":
|
||||
c, ok := pyBuiltins[method]
|
||||
return c, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// langFromFilePath infers a language family from a file extension.
|
||||
// Returns "" when the extension isn't one we classify — callers should
|
||||
// fall back to the normal Unresolved path in that case.
|
||||
func langFromFilePath(p string) string {
|
||||
switch filepath.Ext(p) {
|
||||
case ".js", ".jsx", ".mjs", ".cjs":
|
||||
return "js"
|
||||
case ".ts", ".tsx", ".mts", ".cts":
|
||||
return "ts"
|
||||
case ".py":
|
||||
return "py"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// loadCSources extracts each C-family fixture with the C extractor and loads
|
||||
// its nodes/edges into a fresh graph — the faithful extract→resolve harness for
|
||||
// generated-table reference recovery.
|
||||
func loadCSources(t *testing.T, files map[string]string) graph.Store {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
c := languages.NewCExtractor()
|
||||
for path, src := range files {
|
||||
r, err := c.Extract(path, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("extract %s: %v", path, err)
|
||||
}
|
||||
for _, n := range r.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, e := range r.Edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func refEdgeAt(g graph.Store, from string, line int) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.Kind == graph.EdgeReferences && e.Line == line && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == fnValueRegistrationVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCCommandTableReferences pins the redis command-table shape: a generated
|
||||
// `.def` fragment holding `MAKE_CMD(..., fooCommand, ...)` rows produces a usage
|
||||
// edge from the fragment to the command function defined in another translation
|
||||
// unit. Without it, find_usages(fooCommand) returns zero and mislabels the
|
||||
// function as dead.
|
||||
func TestCCommandTableReferences(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"commands.def": "" +
|
||||
"struct redisCommand redisCommandTable[] = {\n" + // line 1
|
||||
"{MAKE_CMD(\"get\", getCommand, 2)},\n" + // line 2
|
||||
"{MAKE_CMD(\"strlen\", strlenCommand, 2)},\n" + // line 3
|
||||
"};\n",
|
||||
"t_string.c": "" +
|
||||
"robj *getCommand(client *c) { return lookupKey(c); }\n" +
|
||||
"void strlenCommand(client *c) { addReplyLongLong(c, 0); }\n",
|
||||
})
|
||||
|
||||
require.Equal(t, "t_string.c::getCommand", resolveUniqueFnValue(g, "getCommand"),
|
||||
"the pointer-return command function must be a real node")
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
get := refEdgeAt(g, "commands.def", 2)
|
||||
require.NotNil(t, get, "MAKE_CMD row must reference getCommand")
|
||||
assert.Equal(t, "t_string.c::getCommand", get.To)
|
||||
assert.Equal(t, "getCommand", get.Meta["fn_value_name"])
|
||||
|
||||
strlen := refEdgeAt(g, "commands.def", 3)
|
||||
require.NotNil(t, strlen, "MAKE_CMD row must reference strlenCommand")
|
||||
assert.Equal(t, "t_string.c::strlenCommand", strlen.To)
|
||||
}
|
||||
|
||||
// TestCCommandTableDesignatedInitializer covers the designated-initializer table
|
||||
// form `{ .proc = fooCommand }`, not just the macro-call form.
|
||||
func TestCCommandTableDesignatedInitializer(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"table.c": "" +
|
||||
"struct cmd table[] = {\n" +
|
||||
"{ .name = \"ping\", .proc = pingCommand },\n" + // line 2
|
||||
"};\n",
|
||||
"server.c": "void pingCommand(client *c) { addReply(c); }\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
e := refEdgeAt(g, "table.c", 2)
|
||||
require.NotNil(t, e, "designated .proc initializer must reference pingCommand")
|
||||
assert.Equal(t, "server.c::pingCommand", e.To)
|
||||
}
|
||||
|
||||
// tableRefTo reports whether targetID has an incoming bound command-table
|
||||
// reference edge originating in fromFile.
|
||||
func tableRefTo(g graph.Store, targetID, fromFile string) *graph.Edge {
|
||||
for _, e := range g.GetInEdges(targetID) {
|
||||
if e.From != fromFile || e.Kind != graph.EdgeReferences || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v == fnValueRegistrationVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCDispatchTableInitializerListPositional covers the positional
|
||||
// initializer-list dispatch table `{ "name", fnPtr, arity }` — the second-slot
|
||||
// handler resolves to its cross-file definition, exactly like the macro form.
|
||||
func TestCDispatchTableInitializerListPositional(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"table.c": "" +
|
||||
"struct cmd table[] = {\n" + // line 1
|
||||
"{ \"ping\", pingCommand, 2 },\n" + // line 2
|
||||
"{ \"echo\", echoCommand, 2 },\n" + // line 3
|
||||
"};\n",
|
||||
"server.c": "" +
|
||||
"void pingCommand(client *c) { addReply(c); }\n" +
|
||||
"void echoCommand(client *c) { addReply(c); }\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
ping := refEdgeAt(g, "table.c", 2)
|
||||
require.NotNil(t, ping, "positional dispatch-table row must reference pingCommand")
|
||||
assert.Equal(t, "server.c::pingCommand", ping.To)
|
||||
|
||||
echo := refEdgeAt(g, "table.c", 3)
|
||||
require.NotNil(t, echo, "positional dispatch-table row must reference echoCommand")
|
||||
assert.Equal(t, "server.c::echoCommand", echo.To)
|
||||
}
|
||||
|
||||
// TestCCommandTableNoiseProducesNoEdge is the strong precision pin: even when a
|
||||
// repo genuinely defines functions whose names are ALL_CAPS or shorter than four
|
||||
// characters, a command-table row naming them must NOT mint a reference. The
|
||||
// capture guard drops them before the gate, so the coincidental function
|
||||
// definitions can never become false usages — only the real mixed-case handler
|
||||
// binds.
|
||||
func TestCCommandTableNoiseProducesNoEdge(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"commands.def": "" +
|
||||
"struct redisCommand t[] = {\n" + // line 1
|
||||
"{MAKE_CMD(\"get\", CMD_READONLY, run, getCommand)},\n" + // line 2
|
||||
"};\n",
|
||||
"t_string.c": "" +
|
||||
"robj *getCommand(client *c) { return 0; }\n" +
|
||||
// Decoys: an ALL_CAPS function name and a sub-4-char function name.
|
||||
// Both are real, uniquely-named functions the gate WOULD bind if a
|
||||
// candidate reached it — proving the guard, not the gate, is what
|
||||
// suppresses them.
|
||||
"void CMD_READONLY(void) {}\n" +
|
||||
"void run(void) {}\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
require.NotNil(t, tableRefTo(g, "t_string.c::getCommand", "commands.def"),
|
||||
"the real mixed-case handler must be referenced by the table row")
|
||||
assert.Nil(t, tableRefTo(g, "t_string.c::CMD_READONLY", "commands.def"),
|
||||
"an ALL_CAPS function name must not be referenced from a table row")
|
||||
assert.Nil(t, tableRefTo(g, "t_string.c::run", "commands.def"),
|
||||
"a sub-4-char function name must not be referenced from a table row")
|
||||
}
|
||||
|
||||
// TestCCommandTableEndToEndIncomingNotStub is the end-to-end pin: index a
|
||||
// two-file fixture (the handler defined in defs.c, the row in a generated
|
||||
// table.def), resolve, and assert the handler's incoming edges include the table
|
||||
// row carrying the correct file:line and pointing at the real, non-stub function
|
||||
// node — the exact shape find_usages(handler) walks.
|
||||
func TestCCommandTableEndToEndIncomingNotStub(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"defs.c": "void handleGet(client *c) { addReply(c); }\n",
|
||||
"table.def": "" +
|
||||
"struct redisCommand redisCommandTable[] = {\n" + // line 1
|
||||
"{MAKE_CMD(\"get\", \"Get the value\", 2, CMD_READONLY, handleGet, 1, 1)},\n" + // line 2
|
||||
"};\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
const target = "defs.c::handleGet"
|
||||
node := g.GetNode(target)
|
||||
require.NotNil(t, node, "the handler must be a real node")
|
||||
assert.Equal(t, graph.KindFunction, node.Kind, "the handler is a function")
|
||||
assert.False(t, node.Stub, "the handler is real source, not a federation proxy")
|
||||
assert.False(t, graph.IsStub(target), "the handler id is not a stub id")
|
||||
|
||||
ref := tableRefTo(g, target, "table.def")
|
||||
require.NotNil(t, ref, "the handler's incoming edges must include the .def table row")
|
||||
assert.Equal(t, "table.def", ref.FilePath, "the reference carries the .def file")
|
||||
assert.Equal(t, 2, ref.Line, "the reference carries the exact table-row line")
|
||||
assert.Equal(t, target, ref.To)
|
||||
assert.False(t, graph.IsUnresolvedTarget(ref.To), "the bound edge no longer points at an unresolved placeholder")
|
||||
}
|
||||
|
||||
// TestCCommandTablePrototypeNotAmbiguous pins the shape that silenced every
|
||||
// real-world command table: a C codebase declares each handler in a shared
|
||||
// header (`void strlenCommand(client *c);`) AND defines it in its own
|
||||
// translation unit, so the handler's name matches two KindFunction nodes. A
|
||||
// forward declaration names the same extern symbol as the definition — it must
|
||||
// not make the name ambiguous, and the reference must bind to the definition,
|
||||
// not the header line.
|
||||
func TestCCommandTablePrototypeNotAmbiguous(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"commands.def": "" +
|
||||
"struct redisCommand t[] = {\n" + // line 1
|
||||
"{MAKE_CMD(\"strlen\", CMD_READONLY, strlenCommand)},\n" + // line 2
|
||||
"};\n",
|
||||
"server.h": "void strlenCommand(client *c);\n",
|
||||
"t_string.c": "void strlenCommand(client *c) { addReplyLongLong(c, 0); }\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
e := refEdgeAt(g, "commands.def", 2)
|
||||
require.NotNil(t, e, "a header prototype must not make the handler ambiguous")
|
||||
assert.Equal(t, "t_string.c::strlenCommand", e.To, "the definition wins over the prototype")
|
||||
assert.Nil(t, tableRefTo(g, "server.h::strlenCommand", "commands.def"),
|
||||
"the header declaration line is not the reference target")
|
||||
}
|
||||
|
||||
// TestCCommandTablePrototypeOnlyBinds covers a handler whose definition is not
|
||||
// indexed (another repo / excluded path) but whose header declaration is: the
|
||||
// unique prototype is still a legitimate binding target.
|
||||
func TestCCommandTablePrototypeOnlyBinds(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"commands.def": "" +
|
||||
"struct redisCommand t[] = {\n" +
|
||||
"{MAKE_CMD(\"exec\", CMD_NOSCRIPT, execCommand)},\n" + // line 2
|
||||
"};\n",
|
||||
"server.h": "void execCommand(client *c);\n",
|
||||
})
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
e := refEdgeAt(g, "commands.def", 2)
|
||||
require.NotNil(t, e, "a unique prototype binds when no definition is indexed")
|
||||
assert.Equal(t, "server.h::execCommand", e.To)
|
||||
}
|
||||
|
||||
// TestCCommandTableTwoPrototypesStillAmbiguous keeps the conservative floor:
|
||||
// with no definition and two same-named declarations in different headers,
|
||||
// the candidate is dropped rather than guessed.
|
||||
func TestCCommandTableTwoPrototypesStillAmbiguous(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"commands.def": "" +
|
||||
"struct redisCommand t[] = {\n" +
|
||||
"{MAKE_CMD(\"exec\", CMD_NOSCRIPT, execCommand)},\n" +
|
||||
"};\n",
|
||||
"server.h": "void execCommand(client *c);\n",
|
||||
"cluster.h": "void execCommand(client *c);\n",
|
||||
})
|
||||
|
||||
assert.Equal(t, 0, ResolveFnValueCallbacks(g),
|
||||
"two prototypes with no definition stay ambiguous — dropped")
|
||||
}
|
||||
|
||||
// TestCCommandTableRealFileSlice is the regression pin against the real
|
||||
// generated-table shape: the fixture under testdata/redis_cmdtable is a
|
||||
// verbatim slice of redis's generated src/commands.def (the file prelude —
|
||||
// including a #ifdef inside an initializer list and #define/keySpec blocks —
|
||||
// plus the contiguous string/transactions rows around the strlen entry and the
|
||||
// table terminator), a verbatim run of the server.h handler declarations, and
|
||||
// the verbatim strlenCommand definition from t_string.c. Hand-written
|
||||
// idealizations of this file previously passed while the real shape produced
|
||||
// zero edges (the header prototype made every handler name ambiguous), so this
|
||||
// test reads the real bytes.
|
||||
func TestCCommandTableRealFileSlice(t *testing.T) {
|
||||
load := func(name string) []byte {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "redis_cmdtable", name))
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
defSrc := load("commands.def")
|
||||
g := loadCSources(t, map[string]string{
|
||||
"src/commands.def": string(defSrc),
|
||||
"src/server.h": string(load("server.h")),
|
||||
"src/t_string.c": string(load("t_string.c")),
|
||||
})
|
||||
|
||||
// Locate the strlen row in the fixture by content, so the assertion tracks
|
||||
// the verbatim slice rather than a hardcoded offset.
|
||||
rowLine := 0
|
||||
for i, line := range strings.Split(string(defSrc), "\n") {
|
||||
if strings.HasPrefix(line, "{MAKE_CMD(\"strlen\"") {
|
||||
rowLine = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotZero(t, rowLine, "fixture must contain the verbatim strlen row")
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
|
||||
const target = "src/t_string.c::strlenCommand"
|
||||
node := g.GetNode(target)
|
||||
require.NotNil(t, node, "the real definition slice must produce the handler node")
|
||||
assert.False(t, node.Stub)
|
||||
|
||||
ref := tableRefTo(g, target, "src/commands.def")
|
||||
require.NotNil(t, ref, "the real table row must reference the handler definition")
|
||||
assert.Equal(t, rowLine, ref.Line, "the reference carries the verbatim row's line")
|
||||
assert.Equal(t, "src/commands.def", ref.FilePath)
|
||||
|
||||
// The prototype-only handler in the same rows (execCommand is declared in
|
||||
// the server.h slice but its multi.c definition is not part of the
|
||||
// fixture) binds to the unique declaration instead of being dropped.
|
||||
assert.NotNil(t, tableRefTo(g, "src/server.h::execCommand", "src/commands.def"),
|
||||
"a prototype-only handler still gets its table-row reference")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCFnAddressCrossFileComparison pins the classic function-pointer identity
|
||||
// check: `c->cmd->proc != execCommand` in one translation unit references a
|
||||
// function defined in another. C's flat extern namespace makes these bind
|
||||
// repo-globally.
|
||||
func TestCFnAddressCrossFileComparison(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"cmd.h": "typedef void (*cmdProc)(void);\n",
|
||||
"exec.c": "void execCommand(void) {}\n",
|
||||
"server.c": "" +
|
||||
"int isExec(cmdProc p) {\n" + // line 1
|
||||
" return p != execCommand;\n" + // line 2
|
||||
"}\n",
|
||||
})
|
||||
ResolveFnValueCallbacks(g)
|
||||
e := refEdgeAt(g, "server.c::isExec", 2)
|
||||
require.NotNil(t, e, "cross-file function-address comparison must bind")
|
||||
assert.Equal(t, "exec.c::execCommand", e.To)
|
||||
}
|
||||
|
||||
// TestCFnAddressAssignment covers a function-pointer assignment right-hand side.
|
||||
func TestCFnAddressAssignment(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"exec.c": "void execCommand(void) {}\n",
|
||||
"wire.c": "" +
|
||||
"void wire(cmdProc *slot) {\n" + // line 1
|
||||
" *slot = execCommand;\n" + // line 2
|
||||
"}\n",
|
||||
})
|
||||
ResolveFnValueCallbacks(g)
|
||||
e := refEdgeAt(g, "wire.c::wire", 2)
|
||||
require.NotNil(t, e, "function-pointer assignment must bind")
|
||||
assert.Equal(t, "exec.c::execCommand", e.To)
|
||||
}
|
||||
|
||||
// TestCFnAddressAmpersand covers the `&fn` address-of form and its tag.
|
||||
func TestCFnAddressAmpersand(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"h.c": "void handler(void) {}\n",
|
||||
"reg.c": "" +
|
||||
"void setup(void) {\n" + // line 1
|
||||
" install(&handler);\n" + // line 2
|
||||
"}\n",
|
||||
})
|
||||
ResolveFnValueCallbacks(g)
|
||||
e := refEdgeAt(g, "reg.c::setup", 2)
|
||||
require.NotNil(t, e, "&handler must bind")
|
||||
assert.Equal(t, "h.c::handler", e.To)
|
||||
assert.Equal(t, "address_of", e.Meta["fn_ref_form"])
|
||||
}
|
||||
|
||||
// TestCFnAddressStaticNotCrossFile pins the scope_static guard: a file-local
|
||||
// static function is invisible to another translation unit, so a same-named
|
||||
// cross-file reference must not bind it.
|
||||
func TestCFnAddressStaticNotCrossFile(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"a.c": "static void helper(void) {}\n",
|
||||
"b.c": "" +
|
||||
"void use(cmdProc *slot) {\n" + // line 1
|
||||
" *slot = helper;\n" + // line 2
|
||||
"}\n",
|
||||
})
|
||||
ResolveFnValueCallbacks(g)
|
||||
assert.Nil(t, refEdgeAt(g, "b.c::use", 2),
|
||||
"a cross-file reference must not bind a file-local static function")
|
||||
}
|
||||
|
||||
// TestCFnAddressSameFileStaticWins pins same-file precedence: when a static in
|
||||
// the referencing file and an extern elsewhere share a name, the same-file
|
||||
// definition is chosen.
|
||||
func TestCFnAddressSameFileStaticWins(t *testing.T) {
|
||||
g := loadCSources(t, map[string]string{
|
||||
"a.c": "" +
|
||||
"static void dispatch(void) {}\n" + // line 1
|
||||
"void user(void) {\n" + // line 2
|
||||
" register_cb(dispatch);\n" + // line 3
|
||||
"}\n",
|
||||
"c.c": "void dispatch(void) {}\n", // extern, different file
|
||||
})
|
||||
ResolveFnValueCallbacks(g)
|
||||
e := refEdgeAt(g, "a.c::user", 3)
|
||||
require.NotNil(t, e, "same-file dispatch must bind")
|
||||
assert.Equal(t, "a.c::dispatch", e.To, "same-file static wins over cross-file extern")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// These tests pin the canonical-definition contract for type-use /
|
||||
// reference / instantiate edges: when several nodes share a name, the
|
||||
// resolver must land the edge on the *canonical* in-repo definition —
|
||||
// the one search_symbols returns — rather than a same-named rival
|
||||
// (external stub, test/mock definition, private or nested member type).
|
||||
// Each test seeds the rival shape that, before the fix, stole the edge
|
||||
// and left the canonical definition with zero incoming usage (the
|
||||
// "likely_unused" hard-0 for widely-imported and builder-pattern types).
|
||||
|
||||
// usageIncoming counts the incoming edge kinds find_usages treats as
|
||||
// real usage (mirrors graph.ClassifyZeroEdge's usageEdgeKinds).
|
||||
func usageIncoming(g *graph.Graph, id string) int {
|
||||
usage := map[graph.EdgeKind]bool{
|
||||
graph.EdgeCalls: true, graph.EdgeReferences: true, graph.EdgeInstantiates: true,
|
||||
graph.EdgeImplements: true, graph.EdgeExtends: true, graph.EdgeReads: true,
|
||||
graph.EdgeWrites: true, graph.EdgeTests: true,
|
||||
}
|
||||
n := 0
|
||||
for _, e := range g.GetInEdges(id) {
|
||||
if usage[e.Kind] {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A type-use edge must land on the real in-repo definition, never on a
|
||||
// same-named external / synthetic stub node.
|
||||
func TestCanonicalPick_PrefersRealDefOverExternalStub(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Canonical definition in a normal source file.
|
||||
canon := &graph.Node{
|
||||
ID: "repoA/client/OkHttpClient.kt::OkHttpClient", Kind: graph.KindType, Name: "OkHttpClient",
|
||||
FilePath: "repoA/client/OkHttpClient.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
}
|
||||
g.AddNode(canon)
|
||||
// A same-named synthetic external placeholder (re-export / external_call
|
||||
// terminal). Marked external so the ranker demotes it hard.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "repoA/external::OkHttpClient", Kind: graph.KindType, Name: "OkHttpClient",
|
||||
FilePath: "external::OkHttpClient", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"external": true, "synthetic": true},
|
||||
})
|
||||
// The caller node fixes the edge's repo (callerRepoPrefix reads it).
|
||||
g.AddNode(&graph.Node{ID: "repoA/app/Main.kt::run", Kind: graph.KindFunction, Name: "run", FilePath: "repoA/app/Main.kt", Language: "kotlin", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoA/app/Main.kt::field", Kind: graph.KindVariable, Name: "field", FilePath: "repoA/app/Main.kt", Language: "kotlin", RepoPrefix: "repoA"})
|
||||
|
||||
// instantiate + typed_as + references edges from another file.
|
||||
inst := &graph.Edge{From: "repoA/app/Main.kt::run", To: "unresolved::OkHttpClient", Kind: graph.EdgeInstantiates, FilePath: "repoA/app/Main.kt", Line: 5}
|
||||
typed := &graph.Edge{From: "repoA/app/Main.kt::field", To: "unresolved::OkHttpClient", Kind: graph.EdgeTypedAs, FilePath: "repoA/app/Main.kt", Line: 6}
|
||||
g.AddEdge(inst)
|
||||
g.AddEdge(typed)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, canon.ID, inst.To, "instantiate must land on the real definition, not the external stub")
|
||||
assert.Equal(t, canon.ID, typed.To, "typed_as must land on the real definition, not the external stub")
|
||||
assert.GreaterOrEqual(t, usageIncoming(g, canon.ID), 1, "canonical def must have incoming usage")
|
||||
}
|
||||
|
||||
// A type-use edge must land on the non-test definition when a same-named
|
||||
// type also exists in a test source file.
|
||||
func TestCanonicalPick_PrefersNonTestOverTestDef(t *testing.T) {
|
||||
g := graph.New()
|
||||
canon := &graph.Node{
|
||||
ID: "repoA/src/main/Response.kt::Response", Kind: graph.KindType, Name: "Response",
|
||||
FilePath: "repoA/src/main/Response.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
}
|
||||
g.AddNode(canon)
|
||||
// Same-named class in a test source file — must not catch the edge.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "repoA/src/jvmTest/SomeTest.kt::Response", Kind: graph.KindType, Name: "Response",
|
||||
FilePath: "repoA/src/jvmTest/SomeTest.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
})
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/src/jvmTest/SomeTest.kt::testIt", Kind: graph.KindFunction, Name: "testIt", FilePath: "repoA/src/jvmTest/SomeTest.kt", Language: "kotlin", RepoPrefix: "repoA"})
|
||||
|
||||
// The reference edge originates *from* the test file's directory, so a
|
||||
// naive same-directory preference would have stolen it for the test def.
|
||||
ref := &graph.Edge{From: "repoA/src/jvmTest/SomeTest.kt::testIt", To: "unresolved::Response", Kind: graph.EdgeReferences, FilePath: "repoA/src/jvmTest/SomeTest.kt", Line: 9}
|
||||
g.AddEdge(ref)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, canon.ID, ref.To, "reference must land on the non-test definition even when the caller is in a test file")
|
||||
assert.GreaterOrEqual(t, usageIncoming(g, canon.ID), 1)
|
||||
}
|
||||
|
||||
// A type-use edge must prefer an exported/public definition over a
|
||||
// same-named private one.
|
||||
func TestCanonicalPick_PrefersExportedOverPrivate(t *testing.T) {
|
||||
g := graph.New()
|
||||
pub := &graph.Node{
|
||||
ID: "repoA/api/AppState.ts::AppState", Kind: graph.KindInterface, Name: "AppState",
|
||||
FilePath: "repoA/api/AppState.ts", Language: "typescript", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
}
|
||||
g.AddNode(pub)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "repoA/internal/local.ts::AppState", Kind: graph.KindInterface, Name: "AppState",
|
||||
FilePath: "repoA/internal/local.ts", Language: "typescript", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "private"},
|
||||
})
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/app/use.ts::handler", Kind: graph.KindFunction, Name: "handler", FilePath: "repoA/app/use.ts", Language: "typescript", RepoPrefix: "repoA"})
|
||||
typed := &graph.Edge{From: "repoA/app/use.ts::handler", To: "unresolved::AppState", Kind: graph.EdgeTypedAs, FilePath: "repoA/app/use.ts", Line: 3}
|
||||
g.AddEdge(typed)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, pub.ID, typed.To, "typed_as must prefer the exported definition over a private same-named one")
|
||||
}
|
||||
|
||||
// The nested-builder shape: a reference / instantiate of `Foo` must not
|
||||
// be stolen by a nested `Foo.Builder` member type, and an instantiate of
|
||||
// the nested `Foo.Builder` must not be stolen by the top-level `Foo`.
|
||||
func TestCanonicalPick_NestedBuilderDoesNotStealParentEdges(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Top-level Foo.
|
||||
foo := &graph.Node{
|
||||
ID: "repoA/Foo.kt::Foo", Kind: graph.KindType, Name: "Foo",
|
||||
FilePath: "repoA/Foo.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
}
|
||||
g.AddNode(foo)
|
||||
// Nested Foo.Builder, expressed as a dotted name (the qualified form a
|
||||
// language that keeps the enclosing-type prefix emits).
|
||||
builder := &graph.Node{
|
||||
ID: "repoA/Foo.kt::Foo.Builder", Kind: graph.KindType, Name: "Foo.Builder",
|
||||
FilePath: "repoA/Foo.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
Meta: map[string]any{"visibility": "public"},
|
||||
}
|
||||
g.AddNode(builder)
|
||||
g.AddNode(&graph.Node{ID: "repoA/app/Main.kt::run", Kind: graph.KindFunction, Name: "run", FilePath: "repoA/app/Main.kt", Language: "kotlin", RepoPrefix: "repoA"})
|
||||
|
||||
// A reference to `Foo` must land on the top-level Foo, not Foo.Builder.
|
||||
refFoo := &graph.Edge{From: "repoA/app/Main.kt::run", To: "unresolved::Foo", Kind: graph.EdgeReferences, FilePath: "repoA/app/Main.kt", Line: 4}
|
||||
g.AddEdge(refFoo)
|
||||
// An instantiate of the nested `Foo.Builder` must land on the builder,
|
||||
// not on the top-level Foo.
|
||||
instBuilder := &graph.Edge{From: "repoA/app/Main.kt::run", To: "unresolved::Foo.Builder", Kind: graph.EdgeInstantiates, FilePath: "repoA/app/Main.kt", Line: 5}
|
||||
g.AddEdge(instBuilder)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, foo.ID, refFoo.To, "reference to Foo must not be stolen by the nested Foo.Builder")
|
||||
assert.Equal(t, builder.ID, instBuilder.To, "instantiate of Foo.Builder must land on the nested builder, not on Foo")
|
||||
assert.GreaterOrEqual(t, usageIncoming(g, foo.ID), 1, "top-level Foo must keep its own incoming usage")
|
||||
assert.GreaterOrEqual(t, usageIncoming(g, builder.ID), 1, "nested Foo.Builder must keep its own incoming usage")
|
||||
}
|
||||
|
||||
// Guard: with no rival, the single canonical type still resolves (the
|
||||
// fix must not narrow the common one-candidate case).
|
||||
func TestCanonicalPick_SingleCandidateStillResolves(t *testing.T) {
|
||||
g := graph.New()
|
||||
canon := &graph.Node{
|
||||
ID: "repoA/Widget.kt::Widget", Kind: graph.KindType, Name: "Widget",
|
||||
FilePath: "repoA/Widget.kt", Language: "kotlin", RepoPrefix: "repoA",
|
||||
}
|
||||
g.AddNode(canon)
|
||||
g.AddNode(&graph.Node{ID: "repoA/app/Main.kt::run", Kind: graph.KindFunction, Name: "run", FilePath: "repoA/app/Main.kt", Language: "kotlin", RepoPrefix: "repoA"})
|
||||
inst := &graph.Edge{From: "repoA/app/Main.kt::run", To: "unresolved::Widget", Kind: graph.EdgeInstantiates, FilePath: "repoA/app/Main.kt", Line: 2}
|
||||
g.AddEdge(inst)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, canon.ID, inst.To)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// celeryVia is the Meta["via"] tag the Python extractor stamps on a Celery
|
||||
// dispatch placeholder — a `task.delay(...)` / `.apply_async(...)` / `.s()`
|
||||
// or a `send_task("name")` the static graph cannot resolve because the task
|
||||
// runs out of process.
|
||||
const celeryVia = "celery-dispatch"
|
||||
|
||||
// celeryFanoutCap bounds the candidate set a single task name may resolve
|
||||
// against before the placeholder is left unbound, matching the framework's
|
||||
// precision-first posture.
|
||||
const celeryFanoutCap = 80
|
||||
|
||||
// ResolveCeleryCalls binds Celery task dispatches to the decorated task
|
||||
// function: `send_email.delay(...)` → `send_email`, and
|
||||
// `send_task("emails.send")` → the `@task(name="emails.send")` function.
|
||||
// The decorator gate makes this precise, so edges land at the typed
|
||||
// framework tier (ConfidenceTyped / ProvenanceFramework).
|
||||
//
|
||||
// Returns the number of placeholders landed on a real task.
|
||||
func ResolveCeleryCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
byName := map[string][]*graph.Node{}
|
||||
byRegistered := map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindFunction, graph.KindMethod) {
|
||||
if n == nil || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if task, _ := n.Meta["celery_task"].(string); task != "" {
|
||||
byName[task] = append(byName[task], n)
|
||||
}
|
||||
if reg, _ := n.Meta["celery_registered_name"].(string); reg != "" {
|
||||
byRegistered[reg] = append(byRegistered[reg], n)
|
||||
}
|
||||
}
|
||||
if len(byName) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != celeryVia {
|
||||
continue
|
||||
}
|
||||
task, _ := e.Meta["celery_task"].(string)
|
||||
if task == "" {
|
||||
continue
|
||||
}
|
||||
var cands []*graph.Node
|
||||
if reg, _ := e.Meta["celery_registered_name"].(string); reg != "" {
|
||||
cands = byRegistered[reg]
|
||||
} else {
|
||||
cands = byName[task]
|
||||
}
|
||||
var target *graph.Node
|
||||
if len(cands) <= celeryFanoutCap {
|
||||
target = pickStoreAction(g, e, sameBoundaryCandidates(g, e.From, cands))
|
||||
}
|
||||
|
||||
want := "unresolved::*." + task
|
||||
if target != nil {
|
||||
want = target.ID
|
||||
}
|
||||
if e.To == want {
|
||||
if target != nil {
|
||||
resolved++
|
||||
}
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = want
|
||||
if target != nil {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = ConfidenceTyped
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped)
|
||||
StampSynthesizedTyped(e, SynthCelery)
|
||||
resolved++
|
||||
} else {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0
|
||||
e.ConfidenceLabel = ""
|
||||
UnstampSynthesized(e)
|
||||
}
|
||||
reindex = append(reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func celeryTask(g *graph.Graph, id, file, name, registered string) {
|
||||
meta := map[string]any{"celery_task": name}
|
||||
if registered != "" {
|
||||
meta["celery_registered_name"] = registered
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: file, Language: "python", Meta: meta})
|
||||
}
|
||||
|
||||
func celeryDispatch(g *graph.Graph, fromID, file, task, registered string) {
|
||||
if g.GetNode(fromID) == nil {
|
||||
g.AddNode(&graph.Node{ID: fromID, Kind: graph.KindFunction, Name: lastSeg(fromID), FilePath: file, Language: "python"})
|
||||
}
|
||||
meta := map[string]any{"via": celeryVia, "celery_task": task}
|
||||
if registered != "" {
|
||||
meta["celery_registered_name"] = registered
|
||||
}
|
||||
g.AddEdge(&graph.Edge{From: fromID, To: "unresolved::*." + task, Kind: graph.EdgeCalls, FilePath: file, Meta: meta})
|
||||
}
|
||||
|
||||
func synthCeleryEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthCelery {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveCeleryCalls_DelayBindsTaskCrossModule(t *testing.T) {
|
||||
g := graph.New()
|
||||
celeryTask(g, "tasks.py::send_email", "tasks.py", "send_email", "")
|
||||
celeryDispatch(g, "views.py::handle", "views.py", "send_email", "")
|
||||
|
||||
n := ResolveCeleryCalls(g)
|
||||
require.Equal(t, 1, n)
|
||||
e := synthCeleryEdge(g, "views.py::handle", "tasks.py::send_email")
|
||||
require.NotNil(t, e, "view should reach the task across modules")
|
||||
assert.Equal(t, ConfidenceTyped, e.Confidence)
|
||||
assert.Equal(t, ProvenanceFramework, e.Meta[MetaProvenance])
|
||||
}
|
||||
|
||||
func TestResolveCeleryCalls_SendTaskByRegisteredName(t *testing.T) {
|
||||
g := graph.New()
|
||||
celeryTask(g, "tasks.py::send_named", "tasks.py", "send_named", "emails.send")
|
||||
celeryDispatch(g, "views.py::handle", "views.py", "send", "emails.send")
|
||||
|
||||
require.Equal(t, 1, ResolveCeleryCalls(g))
|
||||
assert.NotNil(t, synthCeleryEdge(g, "views.py::handle", "tasks.py::send_named"),
|
||||
"send_task('emails.send') binds via the registered name")
|
||||
}
|
||||
|
||||
func TestResolveCeleryCalls_AmbiguousSameNameNotBound(t *testing.T) {
|
||||
g := graph.New()
|
||||
celeryTask(g, "a.py::process", "a.py", "process", "")
|
||||
celeryTask(g, "b.py::process", "b.py", "process", "")
|
||||
celeryDispatch(g, "c.py::run", "c.py", "process", "")
|
||||
|
||||
assert.Equal(t, 0, ResolveCeleryCalls(g), "two tasks of the same name in different modules are ambiguous")
|
||||
}
|
||||
|
||||
func TestResolveCeleryCalls_UnknownTaskStaysPlaceholder(t *testing.T) {
|
||||
g := graph.New()
|
||||
celeryTask(g, "tasks.py::known", "tasks.py", "known", "")
|
||||
celeryDispatch(g, "v.py::h", "v.py", "ghost", "")
|
||||
|
||||
assert.Equal(t, 0, ResolveCeleryCalls(g))
|
||||
assert.Nil(t, synthCeleryEdge(g, "v.py::h", "tasks.py::known"))
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// ResolveFactoryChains binds method calls on a static-factory / fluent-builder
|
||||
// chain (`New().With(x).Build().Run()`) whose return types and methods live in
|
||||
// different files — the cross-file completion the in-extractor (file-local)
|
||||
// chain walk cannot do without a semantic provider.
|
||||
//
|
||||
// The extractor stamps the receiver expression on the call edge as
|
||||
// Meta["receiver_expr"] when it could not type the chain itself. This pass
|
||||
// re-walks that expression over the whole graph: the base segment's factory
|
||||
// return type, then each hop's method return type, with a conformance walk to
|
||||
// an implementor/subtype when a hop's method is declared on a supertype. The
|
||||
// final method is bound on the resulting type, and the call edge re-targeted.
|
||||
//
|
||||
// It only ever touches edges still on an `unresolved::` placeholder, so an
|
||||
// LSP-/compiler-resolved chain (already bound to a real node) is never
|
||||
// overridden. Runs in the framework-synthesizer settle window, after the
|
||||
// implements/extends edges exist, so the conformance walk sees them.
|
||||
func ResolveFactoryChains(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
resolved := 0
|
||||
var batch []graph.EdgeReindex
|
||||
// Scoped to the two kinds this pass ever acts on (below), instead of
|
||||
// AllEdges() decoding every kind in the graph — calls+references is a
|
||||
// fraction of the total edge count on a large multi-repo graph.
|
||||
for e := range edgesByKinds(g, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if !graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
expr, _ := e.Meta["receiver_expr"].(string)
|
||||
if expr == "" {
|
||||
continue
|
||||
}
|
||||
method := graph.UnresolvedName(e.To)
|
||||
if i := strings.LastIndexByte(method, '.'); i >= 0 {
|
||||
method = method[i+1:]
|
||||
}
|
||||
if method == "" {
|
||||
continue
|
||||
}
|
||||
recvType := walkChainExprType(g, expr)
|
||||
if recvType == "" {
|
||||
continue
|
||||
}
|
||||
target, conformanceWalked := resolveMemberByTypeConformant(g, recvType, method)
|
||||
if target == "" || target == e.From {
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = target
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Meta["via"] = "factory_chain"
|
||||
if conformanceWalked {
|
||||
e.Meta["conformance_walked"] = true
|
||||
}
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
resolved++
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
g.ReindexEdges(batch)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// walkChainExprType returns the type a factory-chain receiver expression
|
||||
// evaluates to, walking the graph: the base segment's factory return type (or
|
||||
// the base itself when it names a known type), then each subsequent segment's
|
||||
// method return type (conformance-aware). Returns "" on the first hop it cannot
|
||||
// type.
|
||||
func walkChainExprType(g graph.Store, expr string) string {
|
||||
parts := strings.Split(stripChainArgs(strings.ReplaceAll(expr, "::", ".")), ".")
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return ""
|
||||
}
|
||||
currentType := graphFactoryReturnType(g, strings.TrimSpace(parts[0]))
|
||||
if currentType == "" {
|
||||
if graphHasType(g, strings.TrimSpace(parts[0])) {
|
||||
currentType = strings.TrimSpace(parts[0])
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(parts); i++ {
|
||||
seg := strings.TrimSpace(parts[i])
|
||||
if seg == "" {
|
||||
return ""
|
||||
}
|
||||
n, _ := findMethodNodeConformant(g, currentType, seg)
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
rt, _ := n.Meta["return_type"].(string)
|
||||
if rt == "" {
|
||||
return ""
|
||||
}
|
||||
currentType = rt
|
||||
}
|
||||
return currentType
|
||||
}
|
||||
|
||||
// stripChainArgs removes call-argument groups from a chain expression so only
|
||||
// the dotted segment names remain (`New().With(x).Build()` → `New.With.Build`).
|
||||
func stripChainArgs(expr string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range expr {
|
||||
switch r {
|
||||
case '(', '[', '{':
|
||||
depth++
|
||||
case ')', ']', '}':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if depth == 0 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// graphFactoryReturnType returns the declared return type of a free function /
|
||||
// constructor named name (the chain seed). A receiver-less declaration wins
|
||||
// over a same-named method; ambiguity among free functions drops.
|
||||
func graphFactoryReturnType(g graph.Store, name string) string {
|
||||
fnRT, methodRT := "", ""
|
||||
for _, n := range g.FindNodesByName(name) {
|
||||
if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) {
|
||||
continue
|
||||
}
|
||||
rt, _ := n.Meta["return_type"].(string)
|
||||
if rt == "" {
|
||||
continue
|
||||
}
|
||||
if _, hasRecv := n.Meta["receiver"]; hasRecv {
|
||||
methodRT = rt
|
||||
} else {
|
||||
if fnRT != "" && fnRT != rt {
|
||||
return "" // ambiguous free function
|
||||
}
|
||||
fnRT = rt
|
||||
}
|
||||
}
|
||||
if fnRT != "" {
|
||||
return fnRT
|
||||
}
|
||||
return methodRT
|
||||
}
|
||||
|
||||
// graphHasType reports whether the graph holds a type/interface named name.
|
||||
func graphHasType(g graph.Store, name string) bool {
|
||||
for _, n := range g.FindNodesByName(name) {
|
||||
if n != nil && isTypeNodeKind(n.Kind) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTypeNodeKind(k graph.NodeKind) bool {
|
||||
return k == graph.KindType || k == graph.KindInterface
|
||||
}
|
||||
|
||||
// resolveMemberByTypeConformant binds member to typeName's method, or — when
|
||||
// typeName declares it nowhere — to the method on a unique implementor/subtype
|
||||
// of typeName (the conformance walk via implements/extends edges). The second
|
||||
// return reports whether a conformance hop was needed.
|
||||
func resolveMemberByTypeConformant(g graph.Store, typeName, member string) (string, bool) {
|
||||
if direct := resolveMemberByType(g, typeName, member); direct != "" {
|
||||
return direct, false
|
||||
}
|
||||
if n, walked := findMethodNodeConformant(g, typeName, member); n != nil && walked {
|
||||
return n.ID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findMethodNodeConformant returns the method node named member on typeName,
|
||||
// or — via the implements/extends conformance walk — on a unique subtype /
|
||||
// implementor of typeName. The second return reports whether the conformance
|
||||
// walk supplied the match.
|
||||
func findMethodNodeConformant(g graph.Store, typeName, member string) (*graph.Node, bool) {
|
||||
if n := findMethodNodeByType(g, typeName, member); n != nil {
|
||||
return n, false
|
||||
}
|
||||
var match *graph.Node
|
||||
for _, tn := range g.FindNodesByName(typeName) {
|
||||
if tn == nil || !isTypeNodeKind(tn.Kind) {
|
||||
continue
|
||||
}
|
||||
for _, ie := range g.GetInEdges(tn.ID) {
|
||||
if ie == nil || (ie.Kind != graph.EdgeImplements && ie.Kind != graph.EdgeExtends) {
|
||||
continue
|
||||
}
|
||||
impl := g.GetNode(ie.From)
|
||||
if impl == nil || impl.Name == "" {
|
||||
continue
|
||||
}
|
||||
if n := findMethodNodeByType(g, impl.Name, member); n != nil {
|
||||
if match != nil && match.ID != n.ID {
|
||||
return nil, true // ambiguous across implementors — drop
|
||||
}
|
||||
match = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return match, match != nil
|
||||
}
|
||||
|
||||
// findMethodNodeByType returns the sole method named member whose
|
||||
// Meta["receiver"] is typeName, or nil when none or more than one exists.
|
||||
func findMethodNodeByType(g graph.Store, typeName, member string) *graph.Node {
|
||||
var match *graph.Node
|
||||
for _, n := range g.FindNodesByName(member) {
|
||||
if n == nil || (n.Kind != graph.KindMethod && n.Kind != graph.KindFunction) {
|
||||
continue
|
||||
}
|
||||
if recv, _ := n.Meta["receiver"].(string); recv != typeName {
|
||||
continue
|
||||
}
|
||||
if match != nil && match.ID != n.ID {
|
||||
return nil
|
||||
}
|
||||
match = n
|
||||
}
|
||||
return match
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func fn(id, name, file, retType string) *graph.Node {
|
||||
n := &graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: file}
|
||||
if retType != "" {
|
||||
n.Meta = map[string]any{"return_type": retType}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func method(id, name, file, recv, retType string) *graph.Node {
|
||||
m := map[string]any{"receiver": recv}
|
||||
if retType != "" {
|
||||
m["return_type"] = retType
|
||||
}
|
||||
return &graph.Node{ID: id, Kind: graph.KindMethod, Name: name, FilePath: file, Meta: m}
|
||||
}
|
||||
|
||||
// TestResolveFactoryChains_CrossFile pins that a fluent chain whose return types
|
||||
// and methods live in different files resolves the final method.
|
||||
func TestResolveFactoryChains_CrossFile(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(fn("a.go::New", "New", "a.go", "Builder"))
|
||||
g.AddNode(&graph.Node{ID: "a.go::Builder", Kind: graph.KindType, Name: "Builder", FilePath: "a.go"})
|
||||
g.AddNode(method("a.go::Builder.With", "With", "a.go", "Builder", "Builder"))
|
||||
g.AddNode(method("a.go::Builder.Build", "Build", "a.go", "Builder", "Widget"))
|
||||
g.AddNode(&graph.Node{ID: "b.go::Widget", Kind: graph.KindType, Name: "Widget", FilePath: "b.go"})
|
||||
g.AddNode(method("b.go::Widget.Run", "Run", "b.go", "Widget", ""))
|
||||
g.AddNode(fn("main.go::main", "main", "main.go", ""))
|
||||
e := &graph.Edge{
|
||||
From: "main.go::main", To: "unresolved::*.Run", Kind: graph.EdgeCalls,
|
||||
FilePath: "main.go", Meta: map[string]any{"receiver_expr": "New().With(x).Build()"},
|
||||
}
|
||||
g.AddEdge(e)
|
||||
|
||||
assert.Equal(t, 1, ResolveFactoryChains(g))
|
||||
assert.Equal(t, "b.go::Widget.Run", e.To)
|
||||
assert.Equal(t, "factory_chain", e.Meta["via"])
|
||||
assert.Equal(t, graph.OriginASTInferred, e.Origin)
|
||||
}
|
||||
|
||||
// TestResolveFactoryChains_Conformance pins that a factory returning an
|
||||
// interface resolves the chained method to a unique concrete implementor, with
|
||||
// the conformance flag set.
|
||||
func TestResolveFactoryChains_Conformance(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(fn("a.go::factory", "factory", "a.go", "Iface"))
|
||||
g.AddNode(&graph.Node{ID: "a.go::Iface", Kind: graph.KindInterface, Name: "Iface", FilePath: "a.go"})
|
||||
g.AddNode(&graph.Node{ID: "b.go::Impl", Kind: graph.KindType, Name: "Impl", FilePath: "b.go"})
|
||||
g.AddNode(method("b.go::Impl.bar", "bar", "b.go", "Impl", ""))
|
||||
g.AddEdge(&graph.Edge{From: "b.go::Impl", To: "a.go::Iface", Kind: graph.EdgeImplements})
|
||||
g.AddNode(fn("m.go::run", "run", "m.go", ""))
|
||||
e := &graph.Edge{
|
||||
From: "m.go::run", To: "unresolved::*.bar", Kind: graph.EdgeCalls,
|
||||
FilePath: "m.go", Meta: map[string]any{"receiver_expr": "factory()"},
|
||||
}
|
||||
g.AddEdge(e)
|
||||
|
||||
assert.Equal(t, 1, ResolveFactoryChains(g))
|
||||
assert.Equal(t, "b.go::Impl.bar", e.To, "chained method binds to the concrete implementor")
|
||||
assert.Equal(t, true, e.Meta["conformance_walked"])
|
||||
}
|
||||
|
||||
// TestResolveFactoryChains_AmbiguousConformanceDropped pins that two
|
||||
// implementors declaring the method drop the edge.
|
||||
func TestResolveFactoryChains_AmbiguousConformanceDropped(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(fn("a.go::factory", "factory", "a.go", "Iface"))
|
||||
g.AddNode(&graph.Node{ID: "a.go::Iface", Kind: graph.KindInterface, Name: "Iface", FilePath: "a.go"})
|
||||
g.AddNode(&graph.Node{ID: "b.go::A", Kind: graph.KindType, Name: "A", FilePath: "b.go"})
|
||||
g.AddNode(method("b.go::A.bar", "bar", "b.go", "A", ""))
|
||||
g.AddNode(&graph.Node{ID: "c.go::B", Kind: graph.KindType, Name: "B", FilePath: "c.go"})
|
||||
g.AddNode(method("c.go::B.bar", "bar", "c.go", "B", ""))
|
||||
g.AddEdge(&graph.Edge{From: "b.go::A", To: "a.go::Iface", Kind: graph.EdgeImplements})
|
||||
g.AddEdge(&graph.Edge{From: "c.go::B", To: "a.go::Iface", Kind: graph.EdgeImplements})
|
||||
e := &graph.Edge{
|
||||
From: "m.go::run", To: "unresolved::*.bar", Kind: graph.EdgeCalls,
|
||||
FilePath: "m.go", Meta: map[string]any{"receiver_expr": "factory()"},
|
||||
}
|
||||
g.AddNode(fn("m.go::run", "run", "m.go", ""))
|
||||
g.AddEdge(e)
|
||||
|
||||
assert.Equal(t, 0, ResolveFactoryChains(g), "ambiguous implementor dropped")
|
||||
}
|
||||
|
||||
// TestResolveFactoryChains_DoesNotOverrideResolved pins that an already-resolved
|
||||
// (e.g. LSP-confirmed) edge is never re-targeted.
|
||||
func TestResolveFactoryChains_DoesNotOverrideResolved(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(fn("a.go::New", "New", "a.go", "Widget"))
|
||||
g.AddNode(&graph.Node{ID: "a.go::Widget", Kind: graph.KindType, Name: "Widget", FilePath: "a.go"})
|
||||
g.AddNode(method("a.go::Widget.Run", "Run", "a.go", "Widget", ""))
|
||||
g.AddNode(method("lsp.go::Other.Run", "Run", "lsp.go", "Other", ""))
|
||||
e := &graph.Edge{
|
||||
From: "m.go::main", To: "lsp.go::Other.Run", Kind: graph.EdgeCalls,
|
||||
Origin: graph.OriginLSPResolved, FilePath: "m.go",
|
||||
Meta: map[string]any{"receiver_expr": "New()"},
|
||||
}
|
||||
g.AddEdge(e)
|
||||
|
||||
assert.Equal(t, 0, ResolveFactoryChains(g))
|
||||
assert.Equal(t, "lsp.go::Other.Run", e.To, "resolved edge is not overridden")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// closureCollectionVia marks a synthesized closure-collection dispatch edge.
|
||||
const closureCollectionVia = "closure.collection"
|
||||
|
||||
// ccFanoutCap skips a collection field with more dispatchers or registrars than
|
||||
// this — a generic field name shared across unrelated classes would otherwise
|
||||
// fan out into noise.
|
||||
const ccFanoutCap = 8
|
||||
|
||||
// ResolveClosureCollectionCalls is the speculative framework-dispatch
|
||||
// synthesizer for closure-collection dynamic dispatch (Swift-first). A method
|
||||
// iterates a collection property invoking each element
|
||||
// (`prop.forEach { $0() }`); another method appends a closure to the same-named
|
||||
// property (`prop.append(...)`). The Swift extractor stamps the dispatcher with
|
||||
// Meta["cc_dispatch_field"] and the registrar with Meta["cc_append_field"].
|
||||
// This pass pairs them globally by field name — cross-file and cross-class by
|
||||
// design (a base class iterating a collection its subclass appends to) — and
|
||||
// synthesizes a dispatcher → registrar edge so a flow reaches the registration
|
||||
// site, where the appended closure's body and its callers live.
|
||||
//
|
||||
// Speculative and low-recall: the dispatcher's element-invoke is the gate, so a
|
||||
// repo with no closure-collection dispatch yields zero edges regardless of how
|
||||
// many append sites it has; pairing is fan-out capped. Edges ride at
|
||||
// OriginSpeculative and carry synthesizer provenance; graph.AddEdge dedupes and
|
||||
// graph.EvictFile drops them on reindex.
|
||||
//
|
||||
// Returns the number of closure-collection dispatch edges synthesized.
|
||||
func ResolveClosureCollectionCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
dispatchersByField := map[string][]*graph.Node{}
|
||||
registrarsByField := map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod, graph.KindFunction) {
|
||||
if n == nil || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if f, _ := n.Meta["cc_dispatch_field"].(string); f != "" {
|
||||
dispatchersByField[f] = append(dispatchersByField[f], n)
|
||||
}
|
||||
if f, _ := n.Meta["cc_append_field"].(string); f != "" {
|
||||
registrarsByField[f] = append(registrarsByField[f], n)
|
||||
}
|
||||
}
|
||||
if len(dispatchersByField) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
fields := make([]string, 0, len(dispatchersByField))
|
||||
for f := range dispatchersByField {
|
||||
fields = append(fields, f)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
|
||||
var batch []*graph.Edge
|
||||
synthesized := 0
|
||||
for _, field := range fields {
|
||||
disps := dispatchersByField[field]
|
||||
regs := registrarsByField[field]
|
||||
if len(regs) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(disps) > ccFanoutCap || len(regs) > ccFanoutCap {
|
||||
continue
|
||||
}
|
||||
for _, d := range disps {
|
||||
for _, r := range regs {
|
||||
if d.ID == r.ID {
|
||||
continue
|
||||
}
|
||||
if !sameDispatchBoundary(d, r) {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, closureCollectionEdge(d, r, field))
|
||||
synthesized++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return synthesized
|
||||
}
|
||||
|
||||
// closureCollectionEdge builds one dispatcher→registrar speculative edge.
|
||||
func closureCollectionEdge(from, to *graph.Node, field string) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from.ID,
|
||||
To: to.ID,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: from.FilePath,
|
||||
Line: from.StartLine,
|
||||
Confidence: 0.4,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, 0.4),
|
||||
Origin: graph.OriginSpeculative,
|
||||
Meta: map[string]any{
|
||||
"via": closureCollectionVia,
|
||||
"channel_field": field,
|
||||
"speculative": true,
|
||||
MetaSynthesizedBy: SynthClosureCollection,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func ccEdgeBetween(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To == to && e.Kind == graph.EdgeCalls && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == closureCollectionVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ccMethod(g graph.Store, id, name, file string, line int, metaKey, field string) {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: id, Kind: graph.KindMethod, Name: name, FilePath: file, StartLine: line,
|
||||
Language: "swift", Meta: map[string]any{metaKey: field},
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveClosureCollectionCalls_PairsDispatcherToRegistrar(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Base class iterates `validators`; subclass appends to `validators`.
|
||||
ccMethod(g, "Request.swift::Request.didCompleteTask", "didCompleteTask", "Request.swift", 40, "cc_dispatch_field", "validators")
|
||||
ccMethod(g, "DataRequest.swift::DataRequest.validate", "validate", "DataRequest.swift", 12, "cc_append_field", "validators")
|
||||
|
||||
n := ResolveClosureCollectionCalls(g)
|
||||
assert.Equal(t, 1, n)
|
||||
|
||||
e := ccEdgeBetween(g, "Request.swift::Request.didCompleteTask", "DataRequest.swift::DataRequest.validate")
|
||||
require.NotNil(t, e, "dispatcher should reach the cross-class registrar")
|
||||
assert.Equal(t, "validators", e.Meta["channel_field"])
|
||||
assert.Equal(t, SynthClosureCollection, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, graph.OriginSpeculative, e.Origin)
|
||||
assert.Equal(t, true, e.Meta["speculative"])
|
||||
}
|
||||
|
||||
func TestResolveClosureCollectionCalls_NoDispatcherNoEdge(t *testing.T) {
|
||||
g := graph.New()
|
||||
// An append with no forEach-dispatcher on the same field — no edge.
|
||||
ccMethod(g, "a.swift::A.add", "add", "a.swift", 3, "cc_append_field", "items")
|
||||
assert.Equal(t, 0, ResolveClosureCollectionCalls(g))
|
||||
}
|
||||
|
||||
func TestResolveClosureCollectionCalls_Idempotent(t *testing.T) {
|
||||
g := graph.New()
|
||||
ccMethod(g, "a.swift::A.fire", "fire", "a.swift", 5, "cc_dispatch_field", "handlers")
|
||||
ccMethod(g, "b.swift::B.register", "register", "b.swift", 9, "cc_append_field", "handlers")
|
||||
|
||||
first := ResolveClosureCollectionCalls(g)
|
||||
second := ResolveClosureCollectionCalls(g)
|
||||
assert.Equal(t, first, second)
|
||||
|
||||
count := 0
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e != nil && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == closureCollectionVia {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
// TestNoCrossRepoSpeculativeDispatch is the B5 named test: a dispatcher and a
|
||||
// registrar that share a generic field name but live in different workspaces
|
||||
// (repos) must NOT be paired — the multi-repo graph's reach must not fan a
|
||||
// generic name across unrelated repositories.
|
||||
func TestNoCrossRepoSpeculativeDispatch(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "a.swift::A.fire", Kind: graph.KindMethod, Name: "fire", FilePath: "a.swift", StartLine: 5,
|
||||
Language: "swift", WorkspaceID: "repoA", Meta: map[string]any{"cc_dispatch_field": "handlers"},
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "b.swift::B.register", Kind: graph.KindMethod, Name: "register", FilePath: "b.swift", StartLine: 9,
|
||||
Language: "swift", WorkspaceID: "repoB", Meta: map[string]any{"cc_append_field": "handlers"},
|
||||
})
|
||||
assert.Equal(t, 0, ResolveClosureCollectionCalls(g), "a generic field name must not cross-pair between repos")
|
||||
assert.Nil(t, ccEdgeBetween(g, "a.swift::A.fire", "b.swift::B.register"))
|
||||
}
|
||||
|
||||
// TestRepoScopedDispatchSameWorkspacePairs confirms the gate does not break the
|
||||
// legitimate single-workspace (incl. monorepo) case: same WorkspaceID still
|
||||
// pairs, so the precision gain is a strict win, not a recall regression.
|
||||
func TestRepoScopedDispatchSameWorkspacePairs(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "a.swift::A.fire", Kind: graph.KindMethod, Name: "fire", FilePath: "a.swift", StartLine: 5,
|
||||
Language: "swift", WorkspaceID: "mono", Meta: map[string]any{"cc_dispatch_field": "handlers"},
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "b.swift::B.register", Kind: graph.KindMethod, Name: "register", FilePath: "b.swift", StartLine: 9,
|
||||
Language: "swift", WorkspaceID: "mono", Meta: map[string]any{"cc_append_field": "handlers"},
|
||||
})
|
||||
assert.Equal(t, 1, ResolveClosureCollectionCalls(g), "same-workspace pairing must still synthesize the edge")
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestResolver_ConcurrentResolveFile guards against the daemon-crashing
|
||||
// "concurrent map writes" panic in buildDirIndexes — two file-watcher
|
||||
// debounce goroutines firing on the same per-repo Indexer both call
|
||||
// Resolver.ResolveFile, both reset the dirIndex/lastDirIndex fields,
|
||||
// fatal-error the runtime. Run under `go test -race` for full
|
||||
// detection; the runtime fatal still triggers without -race when the
|
||||
// scheduler interleaves the resets exactly.
|
||||
func TestResolver_ConcurrentResolveFile(t *testing.T) {
|
||||
g := buildSmallGraph(t)
|
||||
r := New(g)
|
||||
|
||||
const goroutines = 16
|
||||
const itersEach = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < itersEach; j++ {
|
||||
_ = r.ResolveFile("a.go")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestCrossRepoResolver_ConcurrentResolveForRepo locks in the same
|
||||
// guarantee for the multi-repo resolver. MultiWatcher fires per-repo,
|
||||
// so concurrent ResolveForRepo calls on different prefixes are normal
|
||||
// and must not race on the shared dirIndex maps.
|
||||
func TestCrossRepoResolver_ConcurrentResolveForRepo(t *testing.T) {
|
||||
g := buildSmallGraph(t)
|
||||
cr := NewCrossRepo(g)
|
||||
|
||||
const goroutines = 16
|
||||
const itersEach = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < itersEach; j++ {
|
||||
_ = cr.ResolveForRepo("repo-a")
|
||||
_ = cr.ResolveAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestResolver_CrossRepoResolver_SerializeOnGraph pins the cross-type
|
||||
// race reported in the daemon: the per-repo Watcher's debounce timer
|
||||
// fires Resolver.ResolveFile (which holds g.ResolveMutex) while
|
||||
// MultiWatcher.forwardEvents fires CrossRepoResolver.ResolveForRepo.
|
||||
// Both iterate graph.AllEdges()/AllNodes() and rewrite Edge.To in
|
||||
// place on the shared graph, so they must share the same lock — not
|
||||
// two different ones. Without the shared mu pointer, `go test -race`
|
||||
// flags edge mutations between the two resolver types.
|
||||
func TestResolver_CrossRepoResolver_SerializeOnGraph(t *testing.T) {
|
||||
g := buildSmallGraph(t)
|
||||
r := New(g)
|
||||
cr := NewCrossRepo(g)
|
||||
|
||||
const iters = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iters; i++ {
|
||||
_ = r.ResolveFile("a.go")
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iters; i++ {
|
||||
_ = cr.ResolveForRepo("repo-a")
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// buildSmallGraph populates a graph with a handful of file nodes plus
|
||||
// one unresolved edge so the resolver actually has work to do during
|
||||
// the race test. The shape doesn't matter — only that buildDirIndexes
|
||||
// observes >0 file nodes and the resolveEdge inner loop runs.
|
||||
func buildSmallGraph(t *testing.T) graph.Store {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
for _, fp := range []string{"repo-a/lib/a.go", "repo-a/lib/b.go", "repo-b/main.go"} {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: fp,
|
||||
Kind: graph.KindFile,
|
||||
Name: fp,
|
||||
FilePath: fp,
|
||||
RepoPrefix: firstSegment(fp),
|
||||
})
|
||||
}
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "a.go",
|
||||
Kind: graph.KindFunction,
|
||||
Name: "Foo",
|
||||
FilePath: "a.go",
|
||||
RepoPrefix: "repo-a",
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "a.go",
|
||||
To: "unresolved::Bar",
|
||||
Kind: graph.EdgeCalls,
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
func firstSegment(p string) string {
|
||||
for i, c := range p {
|
||||
if c == '/' {
|
||||
return p[:i]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// In-engine C++ overload resolution. Picks which same-named function/method a
|
||||
// call binds to by ISO C++ rules — arity (with defaults + variadics), then
|
||||
// implicit-conversion-sequence (ICS) ranking with pairwise dominance for the
|
||||
// best-viable function — entirely from the signature metadata the cpp extractor
|
||||
// stamps, no compiler needed. It runs in CI/sandbox where clangd cannot.
|
||||
//
|
||||
// Strict invariant (from GitNexus, sharpened): DEGRADE, NEVER LIE. Any axis it
|
||||
// cannot decide keeps the candidate; a genuinely ambiguous best-viable set
|
||||
// (≥2 non-dominated) returns nil so the resolver suppresses the edge rather
|
||||
// than binding the wrong overload.
|
||||
|
||||
const cppRankInf = 1 << 30
|
||||
|
||||
// cppArithmetic is the set of normalized arithmetic base types eligible for
|
||||
// standard arithmetic conversions (rank 2).
|
||||
var cppArithmetic = map[string]bool{
|
||||
"int": true, "double": true, "char": true, "bool": true,
|
||||
"long": true, "short": true, "float": true, "unsigned": true,
|
||||
}
|
||||
|
||||
// cppIntegralPromotion maps a small integral type to its promoted form
|
||||
// (rank 1, better than a general arithmetic conversion).
|
||||
var cppIntegralPromotion = map[string]string{
|
||||
"char": "int", "bool": "int", "short": "int",
|
||||
}
|
||||
|
||||
// cppShape is the decoded per-parameter indirection sidecar.
|
||||
type cppShape struct {
|
||||
isPointer bool
|
||||
isLRef bool
|
||||
isRRef bool
|
||||
isConst bool
|
||||
}
|
||||
|
||||
func decodeCppShape(code string) cppShape {
|
||||
s := cppShape{}
|
||||
if strings.HasPrefix(code, "c") {
|
||||
s.isConst = true
|
||||
code = code[1:]
|
||||
}
|
||||
switch code {
|
||||
case "p":
|
||||
s.isPointer = true
|
||||
case "l":
|
||||
s.isLRef = true
|
||||
case "r":
|
||||
s.isRRef = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// cppConversionRank returns the implicit-conversion-sequence rank from argType
|
||||
// to paramType (lower = better): 0 exact, 1 integral promotion, 2 standard
|
||||
// conversion (arithmetic, nullptr→T*, T*→bool, T*→void*), 3 nullptr→bool,
|
||||
// 5 ellipsis, cppRankInf mismatch. (User-defined conversions — rank 4 — need a
|
||||
// converting-ctor index not yet built; their absence means a UDC-only match is
|
||||
// conservatively a non-match, never a wrong bind.)
|
||||
func cppConversionRank(argType, paramType string, arg, param cppShape) int {
|
||||
if argType == paramType {
|
||||
if exactShapeCompatible(arg, param) {
|
||||
return 0
|
||||
}
|
||||
return cppRankInf
|
||||
}
|
||||
if paramType == "..." {
|
||||
return 5
|
||||
}
|
||||
if cppIntegralPromotion[argType] == paramType && paramType != "" {
|
||||
return 1
|
||||
}
|
||||
if cppArithmetic[argType] && cppArithmetic[paramType] {
|
||||
return 2
|
||||
}
|
||||
if argType == "null" && param.isPointer {
|
||||
return 2
|
||||
}
|
||||
if argType == "null" && paramType == "bool" {
|
||||
return 3
|
||||
}
|
||||
if arg.isPointer && paramType == "bool" {
|
||||
return 2
|
||||
}
|
||||
if arg.isPointer && param.isPointer && paramType == "void" {
|
||||
return 2
|
||||
}
|
||||
return cppRankInf
|
||||
}
|
||||
|
||||
// exactShapeCompatible: an exact base-type match is only a rank-0 conversion
|
||||
// when the indirection agrees (int ≠ int*). A value arg binds to a value or a
|
||||
// (const) reference parameter; a pointer arg binds to a pointer parameter.
|
||||
func exactShapeCompatible(a, p cppShape) bool {
|
||||
return a.isPointer == p.isPointer
|
||||
}
|
||||
|
||||
// cppCandSig is a candidate's parsed signature.
|
||||
type cppCandSig struct {
|
||||
node *graph.Node
|
||||
paramTypes []string
|
||||
shapes []cppShape
|
||||
reqParams int
|
||||
variadic bool
|
||||
}
|
||||
|
||||
// parseCppCandidate reads the cpp_* signature Meta off a node. ok is false when
|
||||
// the node carries no extracted signature (so the resolver can't rank it).
|
||||
func parseCppCandidate(n *graph.Node) (cppCandSig, bool) {
|
||||
if n == nil || n.Meta == nil {
|
||||
return cppCandSig{}, false
|
||||
}
|
||||
if _, ok := n.Meta["cpp_sig"]; !ok {
|
||||
return cppCandSig{}, false
|
||||
}
|
||||
c := cppCandSig{node: n}
|
||||
if pt, _ := n.Meta["cpp_param_types"].(string); pt != "" {
|
||||
c.paramTypes = strings.Split(pt, ",")
|
||||
}
|
||||
if ps, _ := n.Meta["cpp_param_shapes"].(string); ps != "" {
|
||||
for _, code := range strings.Split(ps, ",") {
|
||||
c.shapes = append(c.shapes, decodeCppShape(code))
|
||||
}
|
||||
}
|
||||
c.reqParams = cppMetaInt(n.Meta, "cpp_req_params")
|
||||
if _, ok := n.Meta["cpp_variadic"]; ok {
|
||||
c.variadic = true
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
func cppMetaInt(m map[string]any, k string) int {
|
||||
switch v := m[k].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ResolveCppOverload selects the best-viable overload among same-name
|
||||
// candidates, or nil to degrade to the caller's namespace cascade.
|
||||
func ResolveCppOverload(argHints []string, candidates []*graph.Node) *graph.Node {
|
||||
var sigs []cppCandSig
|
||||
for _, c := range candidates {
|
||||
if c == nil || (c.Kind != graph.KindFunction && c.Kind != graph.KindMethod) {
|
||||
continue
|
||||
}
|
||||
s, ok := parseCppCandidate(c)
|
||||
if !ok {
|
||||
continue // no signature → not rankable; leave to the cascade
|
||||
}
|
||||
if !cppArityCompatible(s, len(argHints)) {
|
||||
continue
|
||||
}
|
||||
sigs = append(sigs, s)
|
||||
}
|
||||
switch len(sigs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return sigs[0].node
|
||||
}
|
||||
// Multiple arity-viable candidates: need argument types to rank further.
|
||||
if len(argHints) == 0 {
|
||||
return nil // can't disambiguate → suppress
|
||||
}
|
||||
normArgs := make([]string, len(argHints))
|
||||
for i, a := range argHints {
|
||||
normArgs[i] = graph.NormalizeCppType(a)
|
||||
}
|
||||
argShapes := make([]cppShape, len(argHints)) // literal/value args; unknown = value
|
||||
|
||||
type ranked struct {
|
||||
node *graph.Node
|
||||
vec []int
|
||||
}
|
||||
var viable []ranked
|
||||
for _, s := range sigs {
|
||||
vec := make([]int, len(normArgs))
|
||||
bad := false
|
||||
for j := range normArgs {
|
||||
if normArgs[j] == "" {
|
||||
// Unknown arg type: compatible with any parameter, and neutral
|
||||
// for dominance (every candidate scores 0 here). Degrade, never
|
||||
// lie — an untyped arg never makes a candidate non-viable.
|
||||
vec[j] = 0
|
||||
continue
|
||||
}
|
||||
pt, psh := cppParamAt(s, j)
|
||||
r := cppConversionRank(normArgs[j], pt, argShapes[j], psh)
|
||||
if r >= cppRankInf {
|
||||
bad = true
|
||||
break
|
||||
}
|
||||
vec[j] = r
|
||||
}
|
||||
if !bad {
|
||||
viable = append(viable, ranked{s.node, vec})
|
||||
}
|
||||
}
|
||||
switch len(viable) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return viable[0].node
|
||||
}
|
||||
// Pairwise dominance → non-dominated set ([over.ics.rank]).
|
||||
var nondom []ranked
|
||||
for i := range viable {
|
||||
dominated := false
|
||||
for k := range viable {
|
||||
if i != k && cppDominates(viable[k].vec, viable[i].vec) {
|
||||
dominated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dominated {
|
||||
nondom = append(nondom, viable[i])
|
||||
}
|
||||
}
|
||||
if len(nondom) == 1 {
|
||||
return nondom[0].node
|
||||
}
|
||||
return nil // ≥2 non-dominated → ambiguous → suppress (never lie)
|
||||
}
|
||||
|
||||
func cppParamAt(s cppCandSig, j int) (string, cppShape) {
|
||||
if j < len(s.paramTypes) {
|
||||
sh := cppShape{}
|
||||
if j < len(s.shapes) {
|
||||
sh = s.shapes[j]
|
||||
}
|
||||
return s.paramTypes[j], sh
|
||||
}
|
||||
if s.variadic {
|
||||
return "...", cppShape{}
|
||||
}
|
||||
return "", cppShape{}
|
||||
}
|
||||
|
||||
func cppArityCompatible(s cppCandSig, argCount int) bool {
|
||||
if s.variadic {
|
||||
return argCount >= s.reqParams
|
||||
}
|
||||
return argCount >= s.reqParams && argCount <= len(s.paramTypes)
|
||||
}
|
||||
|
||||
// cppDominates: a is not-worse-everywhere and strictly-better-somewhere than b.
|
||||
func cppDominates(a, b []int) bool {
|
||||
better := false
|
||||
for i := range a {
|
||||
if a[i] > b[i] {
|
||||
return false
|
||||
}
|
||||
if a[i] < b[i] {
|
||||
better = true
|
||||
}
|
||||
}
|
||||
return better
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestCppConversionRank(t *testing.T) {
|
||||
v := cppShape{}
|
||||
ptr := cppShape{isPointer: true}
|
||||
ellipsis := cppShape{}
|
||||
cases := []struct {
|
||||
arg, param string
|
||||
ash, psh cppShape
|
||||
want int
|
||||
}{
|
||||
{"int", "int", v, v, 0}, // exact
|
||||
{"int", "int", v, ptr, cppRankInf}, // value≠pointer (shape-aware)
|
||||
{"int", "int", ptr, ptr, 0}, // pointer exact
|
||||
{"char", "int", v, v, 1}, // integral promotion
|
||||
{"bool", "int", v, v, 1}, // bool→int promotion
|
||||
{"int", "double", v, v, 2}, // arithmetic standard
|
||||
{"null", "int", v, ptr, 2}, // nullptr→T*
|
||||
{"null", "bool", v, v, 3}, // nullptr→bool (worse than →T*)
|
||||
{"int", "...", v, ellipsis, 5}, // ellipsis
|
||||
{"string", "int", v, v, cppRankInf}, // mismatch
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cppConversionRank(c.arg, c.param, c.ash, c.psh); got != c.want {
|
||||
t.Errorf("rank(%q→%q) = %d, want %d", c.arg, c.param, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cppFn(id, params, shapes string, req int) *graph.Node {
|
||||
m := map[string]any{"cpp_sig": "1"}
|
||||
if params != "" {
|
||||
m["cpp_param_types"] = params
|
||||
}
|
||||
if shapes != "" {
|
||||
m["cpp_param_shapes"] = shapes
|
||||
}
|
||||
if req > 0 {
|
||||
m["cpp_req_params"] = req
|
||||
}
|
||||
return &graph.Node{ID: id, Kind: graph.KindFunction, Name: "process", Meta: m}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_ArithmeticSelection(t *testing.T) {
|
||||
intFn := cppFn("f::process#int", "int", "v", 1)
|
||||
dblFn := cppFn("f::process#double", "double", "v", 1)
|
||||
cands := []*graph.Node{intFn, dblFn}
|
||||
|
||||
if got := ResolveCppOverload([]string{"double"}, cands); got != dblFn {
|
||||
t.Errorf("double arg should pick process(double), got %v", got)
|
||||
}
|
||||
if got := ResolveCppOverload([]string{"int"}, cands); got != intFn {
|
||||
t.Errorf("int arg should pick process(int), got %v", got)
|
||||
}
|
||||
if got := ResolveCppOverload([]string{"char"}, cands); got != intFn {
|
||||
t.Errorf("char arg should promote to process(int), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_ShapeDistinguishesPointer(t *testing.T) {
|
||||
valFn := cppFn("f::process#int", "int", "v", 1)
|
||||
ptrFn := cppFn("f::process#intptr", "int", "p", 1)
|
||||
// A value int literal must not match the int* overload.
|
||||
if got := ResolveCppOverload([]string{"int"}, []*graph.Node{valFn, ptrFn}); got != valFn {
|
||||
t.Errorf("int value arg should pick the value overload, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_Arity(t *testing.T) {
|
||||
zero := cppFn("f::process#0", "", "", 0)
|
||||
one := cppFn("f::process#1", "int", "v", 1)
|
||||
if got := ResolveCppOverload([]string{"int"}, []*graph.Node{zero, one}); got != one {
|
||||
t.Errorf("1 arg should pick the 1-param overload, got %v", got)
|
||||
}
|
||||
if got := ResolveCppOverload(nil, []*graph.Node{zero, one}); got != zero {
|
||||
t.Errorf("0 args should pick the 0-param overload, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_DefaultsAndVariadic(t *testing.T) {
|
||||
// process(int, int = 0): req 1, total 2.
|
||||
def := cppFn("f::process#def", "int,int", "v,v", 1)
|
||||
if got := ResolveCppOverload([]string{"int"}, []*graph.Node{def}); got != def {
|
||||
t.Errorf("1 arg must satisfy a 2-param-with-default overload, got %v", got)
|
||||
}
|
||||
// variadic process(int, ...): req 1.
|
||||
vfn := cppFn("f::process#var", "int", "v", 1)
|
||||
vfn.Meta["cpp_variadic"] = "1"
|
||||
if got := ResolveCppOverload([]string{"int", "double", "char"}, []*graph.Node{vfn}); got != vfn {
|
||||
t.Errorf("variadic overload must accept extra args, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_AmbiguousSuppressed(t *testing.T) {
|
||||
// Two overloads the arg ranks equally well against → ambiguous → nil.
|
||||
a := cppFn("f::process#a", "int", "v", 1)
|
||||
b := cppFn("f::process#b", "int", "v", 1)
|
||||
if got := ResolveCppOverload([]string{"int"}, []*graph.Node{a, b}); got != nil {
|
||||
t.Errorf("equally-ranked overloads must suppress (nil), got %v", got)
|
||||
}
|
||||
// No arg hints + 2 viable → suppress.
|
||||
if got := ResolveCppOverload(nil, []*graph.Node{cppFn("x", "int", "v", 1), cppFn("y", "double", "v", 1)}); got != nil {
|
||||
t.Errorf("no hints with 2 viable must suppress, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCppOverload_NoSignatureDegrade(t *testing.T) {
|
||||
// Candidates without extracted signatures → degrade (nil) to the cascade.
|
||||
a := &graph.Node{ID: "x", Kind: graph.KindFunction, Name: "process"}
|
||||
b := &graph.Node{ID: "y", Kind: graph.KindFunction, Name: "process"}
|
||||
if got := ResolveCppOverload([]string{"int"}, []*graph.Node{a, b}); got != nil {
|
||||
t.Errorf("no signature metadata must degrade to nil, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package resolver
|
||||
|
||||
// cppStdlibHeaders is a curated allow-list of C, C++, and common POSIX
|
||||
// standard-library headers, keyed by the include path exactly as it appears
|
||||
// between the angle brackets (the extractor has already stripped the `<>`).
|
||||
//
|
||||
// It is consulted before the include resolver probes any `-I` directory: a
|
||||
// known standard header is classified external up front and never probed, so
|
||||
// a real STL header like `<vector>` can never accidentally bind to an in-tree
|
||||
// file that happens to share its basename (e.g. a file literally named
|
||||
// `vector` with no extension, or a different-language `string`). The list is
|
||||
// advisory for recall (it short-circuits the search) and load-bearing for
|
||||
// correctness (it is the basename-collision guard for angle includes).
|
||||
var cppStdlibHeaders = func() map[string]struct{} {
|
||||
headers := []string{
|
||||
// C standard library (C89 … C23).
|
||||
"assert.h", "complex.h", "ctype.h", "errno.h", "fenv.h", "float.h",
|
||||
"inttypes.h", "iso646.h", "limits.h", "locale.h", "math.h", "setjmp.h",
|
||||
"signal.h", "stdalign.h", "stdarg.h", "stdatomic.h", "stdbit.h",
|
||||
"stdbool.h", "stdckdint.h", "stddef.h", "stdint.h", "stdio.h",
|
||||
"stdlib.h", "stdnoreturn.h", "string.h", "tgmath.h", "threads.h",
|
||||
"time.h", "uchar.h", "wchar.h", "wctype.h",
|
||||
// C++ standard library (containers, utilities, concurrency, IO, …).
|
||||
"algorithm", "any", "array", "atomic", "barrier", "bit", "bitset",
|
||||
"charconv", "chrono", "compare", "complex", "concepts",
|
||||
"condition_variable", "coroutine", "deque", "exception", "execution",
|
||||
"expected", "filesystem", "format", "forward_list", "fstream",
|
||||
"functional", "future", "initializer_list", "iomanip", "ios",
|
||||
"iosfwd", "iostream", "istream", "iterator", "latch", "limits",
|
||||
"list", "locale", "map", "memory", "memory_resource", "mutex", "new",
|
||||
"numbers", "numeric", "optional", "ostream", "queue", "random",
|
||||
"ranges", "ratio", "regex", "scoped_allocator", "semaphore",
|
||||
"shared_mutex", "source_location", "span", "spanstream", "sstream",
|
||||
"stack", "stacktrace", "stdexcept", "stdfloat", "stop_token",
|
||||
"streambuf", "string", "string_view", "strstream", "syncstream",
|
||||
"system_error", "thread", "tuple", "type_traits", "typeindex",
|
||||
"typeinfo", "unordered_map", "unordered_set", "utility", "valarray",
|
||||
"variant", "vector", "version",
|
||||
// C++ <cXXX> wrappers over the C headers.
|
||||
"cassert", "cctype", "cerrno", "cfenv", "cfloat", "cinttypes",
|
||||
"ciso646", "climits", "clocale", "cmath", "csetjmp", "csignal",
|
||||
"cstdalign", "cstdarg", "cstdbool", "cstddef", "cstdint", "cstdio",
|
||||
"cstdlib", "cstring", "ctgmath", "ctime", "cuchar", "cwchar",
|
||||
"cwctype",
|
||||
// Common POSIX / system headers (the ones an in-tree scan would
|
||||
// otherwise be tempted to mis-bind).
|
||||
"unistd.h", "pthread.h", "fcntl.h", "dirent.h", "dlfcn.h", "poll.h",
|
||||
"sched.h", "semaphore.h", "termios.h", "grp.h", "pwd.h", "syslog.h",
|
||||
"glob.h", "fnmatch.h", "ftw.h", "getopt.h", "libgen.h", "strings.h",
|
||||
"regex.h", "netdb.h", "ifaddrs.h", "endian.h", "byteswap.h",
|
||||
"malloc.h", "alloca.h", "memory.h", "mqueue.h", "aio.h", "spawn.h",
|
||||
"utime.h", "wordexp.h", "langinfo.h", "iconv.h", "search.h",
|
||||
"ucontext.h", "sys/types.h", "sys/stat.h", "sys/socket.h",
|
||||
"sys/wait.h", "sys/mman.h", "sys/time.h", "sys/select.h",
|
||||
"sys/ioctl.h", "sys/resource.h", "sys/uio.h", "sys/un.h",
|
||||
"sys/epoll.h", "sys/eventfd.h", "sys/sem.h", "sys/shm.h", "sys/msg.h",
|
||||
"sys/ipc.h", "sys/file.h", "sys/param.h", "sys/utsname.h",
|
||||
"netinet/in.h", "netinet/tcp.h", "arpa/inet.h", "net/if.h",
|
||||
}
|
||||
set := make(map[string]struct{}, len(headers))
|
||||
for _, h := range headers {
|
||||
set[h] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}()
|
||||
|
||||
// IsCppStdlibHeader reports whether name is a C / C++ / POSIX standard-library
|
||||
// header (the include path between the angle brackets, e.g. "vector",
|
||||
// "stdio.h", "sys/types.h"). Used both by the include resolver's angle-include
|
||||
// guard and by the resolution-outcome analyzer.
|
||||
func IsCppStdlibHeader(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := cppStdlibHeaders[name]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package resolver
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsCppStdlibHeader(t *testing.T) {
|
||||
stdlib := []string{
|
||||
"stdio.h", "stdlib.h", "string.h", "stdatomic.h", // C
|
||||
"vector", "memory", "string", "unordered_map", "filesystem", // C++
|
||||
"cstdio", "cstring", "cstdint", // <cXXX> wrappers
|
||||
"unistd.h", "pthread.h", "sys/types.h", "arpa/inet.h", // POSIX
|
||||
}
|
||||
for _, h := range stdlib {
|
||||
if !IsCppStdlibHeader(h) {
|
||||
t.Errorf("IsCppStdlibHeader(%q) = false, want true", h)
|
||||
}
|
||||
}
|
||||
notStdlib := []string{
|
||||
"", "proj/api.h", "myheader.h", "vector.h", "foo", "config.h", "string_view.h",
|
||||
}
|
||||
for _, h := range notStdlib {
|
||||
if IsCppStdlibHeader(h) {
|
||||
t.Errorf("IsCppStdlibHeader(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// buildGraphFromSources extracts every fixture file with the extractor
|
||||
// matching its suffix (.ts/.tsx → TypeScript, otherwise JavaScript) and
|
||||
// loads the resulting nodes and edges into a fresh graph. It is the
|
||||
// faithful end-to-end harness for the resolver tests below: a real
|
||||
// extractor produces the unresolved edges, then ResolveAll runs against
|
||||
// them exactly as it does on a live index.
|
||||
func buildGraphFromSources(t *testing.T, files map[string]string) graph.Store {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
ts := languages.NewTypeScriptExtractor()
|
||||
js := languages.NewJavaScriptExtractor()
|
||||
for path, src := range files {
|
||||
if strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".tsx") {
|
||||
r, err := ts.Extract(path, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("ts extract %s: %v", path, err)
|
||||
}
|
||||
for _, n := range r.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, e := range r.Edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r, err := js.Extract(path, []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("js extract %s: %v", path, err)
|
||||
}
|
||||
for _, n := range r.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, e := range r.Edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// callEdgeTo returns the resolved To-end of the call/reference edge that
|
||||
// leaves fromID at the given 1-based line. Empty string when no such
|
||||
// edge exists.
|
||||
func callEdgeTo(g graph.Store, fromID string, line int) string {
|
||||
for _, e := range g.GetOutEdges(fromID) {
|
||||
if (e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences) && e.Line == line {
|
||||
return e.To
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestJSTSCallEdges_FalsePositivesAndNegatives drives the three mis-
|
||||
// resolution patterns through a real extract → resolve pipeline. Each
|
||||
// row asserts both halves of the contract: the genuine edge that must
|
||||
// still resolve, and the false-positive edge that must be suppressed.
|
||||
func TestJSTSCallEdges_FalsePositivesAndNegatives(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
// callerID + callLine identify the call edge under test.
|
||||
callerID string
|
||||
callLine int
|
||||
// wantTo, when set, is the node the call MUST resolve to.
|
||||
wantTo string
|
||||
// forbidTo, when set, is a node the call must NOT resolve to.
|
||||
forbidTo string
|
||||
// wantUnresolved requires the edge to stay an `unresolved::`
|
||||
// placeholder (the false positive was suppressed, with no
|
||||
// reachable genuine target to fall back to).
|
||||
wantUnresolved bool
|
||||
}{
|
||||
{
|
||||
// Pattern 1, true positive: a call to an object-literal
|
||||
// shorthand method must bind to that member.
|
||||
name: "object-literal shorthand resolves to member",
|
||||
files: map[string]string{
|
||||
"svc/api.ts": `function doWork(): number { return 1; }
|
||||
export const api = {
|
||||
process(): number { return doWork(); },
|
||||
};
|
||||
function caller(): void {
|
||||
api.process();
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "svc/api.ts::caller",
|
||||
callLine: 6,
|
||||
wantTo: "svc/api.ts::api.process@3",
|
||||
},
|
||||
{
|
||||
// Pattern 1, false positive: an unrelated free `process` in
|
||||
// another package must NOT capture `api.process()`.
|
||||
name: "object-literal shorthand does not bind to free function",
|
||||
files: map[string]string{
|
||||
"svc/api.ts": `export const api = {
|
||||
process(): number { return 1; },
|
||||
};
|
||||
function caller(): void {
|
||||
api.process();
|
||||
}
|
||||
`,
|
||||
"other/free.ts": `export function process(): number { return 99; }`,
|
||||
},
|
||||
callerID: "svc/api.ts::caller",
|
||||
callLine: 5,
|
||||
wantTo: "svc/api.ts::api.process@2",
|
||||
forbidTo: "other/free.ts::process",
|
||||
},
|
||||
{
|
||||
// Pattern 2, true positive: a factory result whose handler
|
||||
// implementations are same-package must still resolve.
|
||||
name: "factory dispatch resolves same-package handler",
|
||||
files: map[string]string{
|
||||
"app/run.ts": `function run(): void {
|
||||
const h = makeHandler('a');
|
||||
h.handle();
|
||||
}
|
||||
`,
|
||||
"app/handlers.ts": `export class AlphaHandler {
|
||||
handle(): number { return 1; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "app/run.ts::run",
|
||||
callLine: 3,
|
||||
wantTo: "app/handlers.ts::AlphaHandler.handle",
|
||||
},
|
||||
{
|
||||
// Pattern 2, false positive: a factory result whose only
|
||||
// `handle` candidate lives in an un-imported package must
|
||||
// not produce a call edge to it.
|
||||
name: "factory dispatch does not bind across un-imported package",
|
||||
files: map[string]string{
|
||||
"app/run.ts": `function run(): void {
|
||||
const h = makeHandler('a');
|
||||
h.handle();
|
||||
}
|
||||
`,
|
||||
"vendor/other.ts": `export class OtherHandler {
|
||||
handle(): number { return 1; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "app/run.ts::run",
|
||||
callLine: 3,
|
||||
forbidTo: "vendor/other.ts::OtherHandler.handle",
|
||||
wantUnresolved: true,
|
||||
},
|
||||
{
|
||||
// Pattern 3, false positive: a `ns.foo()` call where `ns` is
|
||||
// a plain local object must not resolve to a same-named free
|
||||
// function in an un-imported module.
|
||||
name: "namespace member call does not bind to free function",
|
||||
files: map[string]string{
|
||||
"app/main.ts": `function run(): void {
|
||||
const ns = { other: 1 };
|
||||
ns.lookup('x');
|
||||
}
|
||||
`,
|
||||
"lib/lookup.ts": `export function lookup(s: string): string { return s; }`,
|
||||
},
|
||||
callerID: "app/main.ts::run",
|
||||
callLine: 3,
|
||||
forbidTo: "lib/lookup.ts::lookup",
|
||||
wantUnresolved: true,
|
||||
},
|
||||
{
|
||||
// Pattern 3, true positive: when the namespace is genuinely
|
||||
// imported, the member call resolves to the imported symbol.
|
||||
name: "imported namespace member call resolves",
|
||||
files: map[string]string{
|
||||
"app/main.ts": `import * as helpers from './helpers';
|
||||
function run(): void {
|
||||
helpers.lookup('x');
|
||||
}
|
||||
`,
|
||||
"app/helpers/index.ts": `export function lookup(s: string): string { return s; }`,
|
||||
},
|
||||
callerID: "app/main.ts::run",
|
||||
callLine: 3,
|
||||
wantTo: "app/helpers/index.ts::lookup",
|
||||
},
|
||||
{
|
||||
// Pattern 1 in JavaScript: the shorthand method must resolve
|
||||
// and must not fall through to a free function.
|
||||
name: "javascript object-literal shorthand resolves to member",
|
||||
files: map[string]string{
|
||||
"svc/api.js": `export const api = {
|
||||
process() { return 1; },
|
||||
};
|
||||
function caller() {
|
||||
api.process();
|
||||
}
|
||||
`,
|
||||
"other/free.js": `export function process() { return 99; }`,
|
||||
},
|
||||
callerID: "svc/api.js::caller",
|
||||
callLine: 5,
|
||||
wantTo: "svc/api.js::api.process@2",
|
||||
forbidTo: "other/free.js::process",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := buildGraphFromSources(t, tc.files)
|
||||
New(g).ResolveAll()
|
||||
|
||||
got := callEdgeTo(g, tc.callerID, tc.callLine)
|
||||
if got == "" {
|
||||
t.Fatalf("no call edge found from %s at line %d", tc.callerID, tc.callLine)
|
||||
}
|
||||
|
||||
if tc.wantTo != "" && got != tc.wantTo {
|
||||
t.Errorf("call resolved to %q, want %q", got, tc.wantTo)
|
||||
}
|
||||
if tc.forbidTo != "" && got == tc.forbidTo {
|
||||
t.Errorf("call mis-resolved to forbidden cross-package target %q", tc.forbidTo)
|
||||
}
|
||||
if tc.wantUnresolved && !strings.HasPrefix(got, "unresolved::") {
|
||||
t.Errorf("call resolved to %q, expected it to stay unresolved (false positive should be suppressed)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPackageGuard_RevertsUnreachableNameMatch exercises the guard
|
||||
// directly on a hand-built graph: a function call whose only same-name
|
||||
// candidate lives in a package the caller never imports must be
|
||||
// reverted to its unresolved placeholder, while the same call resolves
|
||||
// and stays when the candidate's package is imported or same-package.
|
||||
func TestCrossPackageGuard_RevertsUnreachableNameMatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// importedDir, when non-empty, adds an EdgeImports from the
|
||||
// caller file to that directory.
|
||||
importedDir string
|
||||
// targetDir is the directory the only `helper` candidate lives in.
|
||||
targetDir string
|
||||
// wantResolved is the expected resolved To (empty → must stay
|
||||
// unresolved).
|
||||
wantResolved string
|
||||
}{
|
||||
{
|
||||
name: "same-package candidate is kept",
|
||||
targetDir: "pkgA",
|
||||
wantResolved: "pkgA/b.go::helper",
|
||||
},
|
||||
{
|
||||
name: "imported-package candidate is kept",
|
||||
importedDir: "pkgB",
|
||||
targetDir: "pkgB",
|
||||
wantResolved: "pkgB/b.go::helper",
|
||||
},
|
||||
{
|
||||
name: "un-imported-package candidate is reverted",
|
||||
importedDir: "pkgB",
|
||||
targetDir: "pkgC",
|
||||
wantResolved: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "pkgA/a.go", Kind: graph.KindFile, FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"})
|
||||
|
||||
// The only `helper` candidate lives in targetDir.
|
||||
targetFile := tc.targetDir + "/b.go"
|
||||
targetID := targetFile + "::helper"
|
||||
g.AddNode(&graph.Node{ID: targetFile, Kind: graph.KindFile, FilePath: targetFile, Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: targetID, Kind: graph.KindFunction, Name: "helper", FilePath: targetFile, Language: "go", RepoPrefix: "r"})
|
||||
|
||||
// A decoy file in the imported package so the import resolves
|
||||
// to a real directory even when the candidate lives elsewhere.
|
||||
if tc.importedDir != "" && tc.importedDir != tc.targetDir {
|
||||
decoy := tc.importedDir + "/x.go"
|
||||
g.AddNode(&graph.Node{ID: decoy, Kind: graph.KindFile, FilePath: decoy, Language: "go", RepoPrefix: "r"})
|
||||
}
|
||||
if tc.importedDir != "" {
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "pkgA/a.go", To: "unresolved::import::" + tc.importedDir,
|
||||
Kind: graph.EdgeImports, FilePath: "pkgA/a.go", Line: 1,
|
||||
})
|
||||
}
|
||||
|
||||
call := &graph.Edge{
|
||||
From: "pkgA/a.go::Caller", To: "unresolved::helper",
|
||||
Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5,
|
||||
}
|
||||
g.AddEdge(call)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
// Whatever the resolver and guard did, the edge's identity
|
||||
// stays internally consistent — the guard routes its Origin
|
||||
// revert through SetEdgeProvenance, so the out- and in-edge
|
||||
// views never disagree on provenance.
|
||||
if err := g.VerifyEdgeIdentities(); err != nil {
|
||||
t.Fatalf("edge identities inconsistent after resolve: %v", err)
|
||||
}
|
||||
|
||||
if tc.wantResolved == "" {
|
||||
if call.To != "unresolved::helper" {
|
||||
t.Errorf("guard should have reverted the edge; To = %q, want unresolved::helper", call.To)
|
||||
}
|
||||
// A reverted edge must carry no resolution provenance.
|
||||
if call.Origin != "" {
|
||||
t.Errorf("reverted edge kept Origin %q, want empty", call.Origin)
|
||||
}
|
||||
return
|
||||
}
|
||||
if call.To != tc.wantResolved {
|
||||
t.Errorf("call resolved to %q, want %q", call.To, tc.wantResolved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPackageGuard_RevertRoutedThroughProvenance proves the guard's
|
||||
// provenance revert goes through Graph.SetEdgeProvenance rather than a
|
||||
// bare Origin write: when the edge being reverted carries a resolution
|
||||
// Origin, clearing it is counted as an edge-identity revision, and the
|
||||
// resulting graph stays identity-consistent across both adjacency views.
|
||||
//
|
||||
// The guard internally resets To and Origin together; this test stamps
|
||||
// a weak Origin on the same logical edge through the sanctioned path,
|
||||
// then re-derives the guard's exact revert sequence (SetEdgeProvenance
|
||||
// to drop the Origin, then the target revert + re-bucket) to assert
|
||||
// that path records the churn.
|
||||
func TestCrossPackageGuard_RevertRoutedThroughProvenance(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: "pkgC/b.go::helper", Kind: graph.KindFunction, Name: "helper", FilePath: "pkgC/b.go", Language: "go", RepoPrefix: "r"})
|
||||
|
||||
// A call edge as it sits post-resolution: pointed at a (to be
|
||||
// rejected) cross-package target with a weak resolution Origin.
|
||||
call := &graph.Edge{
|
||||
From: "pkgA/a.go::Caller", To: "pkgC/b.go::helper",
|
||||
Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5,
|
||||
Origin: graph.OriginTextMatched,
|
||||
}
|
||||
g.AddEdge(call)
|
||||
baseline := g.EdgeIdentityRevisions()
|
||||
|
||||
// The guard's revert: drop provenance via SetEdgeProvenance, then
|
||||
// revert the target and re-bucket — mirrors cross_pkg_guard.go.
|
||||
oldResolved := call.To
|
||||
if !g.SetEdgeProvenance(call, "") {
|
||||
t.Fatal("clearing a non-empty resolution Origin must change identity")
|
||||
}
|
||||
call.To = "unresolved::helper"
|
||||
call.Confidence = 0
|
||||
g.ReindexEdge(call, oldResolved)
|
||||
|
||||
if g.EdgeIdentityRevisions() != baseline+1 {
|
||||
t.Errorf("guard revert must record exactly one identity revision: got %d, want %d",
|
||||
g.EdgeIdentityRevisions(), baseline+1)
|
||||
}
|
||||
if err := g.VerifyEdgeIdentities(); err != nil {
|
||||
t.Fatalf("edge identities inconsistent after guarded revert: %v", err)
|
||||
}
|
||||
if got := g.GetOutEdges("pkgA/a.go::Caller"); len(got) != 1 || got[0].To != "unresolved::helper" || got[0].Origin != "" {
|
||||
t.Errorf("reverted edge has wrong state: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPackageGuard_KeepsImportedMethodCall verifies the guard does
|
||||
// not strip a genuine cross-package method call: a `*.handle` member
|
||||
// call resolving to a method whose package the caller imports survives.
|
||||
func TestCrossPackageGuard_KeepsImportedMethodCall(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "pkgA/a.go", Kind: graph.KindFile, FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: "pkgB/b.go", Kind: graph.KindFile, FilePath: "pkgB/b.go", Language: "go", RepoPrefix: "r"})
|
||||
g.AddNode(&graph.Node{ID: "pkgB/b.go::Worker.handle", Kind: graph.KindMethod, Name: "handle", FilePath: "pkgB/b.go", Language: "go", RepoPrefix: "r"})
|
||||
|
||||
g.AddEdge(&graph.Edge{From: "pkgA/a.go", To: "unresolved::import::pkgB", Kind: graph.EdgeImports, FilePath: "pkgA/a.go", Line: 1})
|
||||
call := &graph.Edge{From: "pkgA/a.go::Caller", To: "unresolved::*.handle", Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5}
|
||||
g.AddEdge(call)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
if call.To != "pkgB/b.go::Worker.handle" {
|
||||
t.Errorf("imported-package method call was dropped; To = %q, want pkgB/b.go::Worker.handle", call.To)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPackageGuard_LeavesExternEdges confirms the guard never
|
||||
// touches `extern::`-shaped resolutions: those carry an explicit import
|
||||
// path as evidence and are not name-only guesses, so a cross-package
|
||||
// extern call to an indexed symbol must stay resolved.
|
||||
func TestCrossPackageGuard_LeavesExternEdges(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "consumer/main.go", Kind: graph.KindFile, FilePath: "consumer/main.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "consumer/main.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "consumer/main.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "lib/pkg/pkg.go", Kind: graph.KindFile, FilePath: "lib/pkg/pkg.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "lib/pkg/pkg.go::DoThing", Kind: graph.KindFunction, Name: "DoThing", FilePath: "lib/pkg/pkg.go", Language: "go"})
|
||||
|
||||
call := &graph.Edge{
|
||||
From: "consumer/main.go::Caller", To: "unresolved::extern::lib/pkg::DoThing",
|
||||
Kind: graph.EdgeCalls, FilePath: "consumer/main.go", Line: 5,
|
||||
}
|
||||
g.AddEdge(call)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
if call.To != "lib/pkg/pkg.go::DoThing" {
|
||||
t.Errorf("extern-qualified call was wrongly reverted by the guard; To = %q", call.To)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestCrossRepoResolveAll_ConcurrentEdits is the safety gate for the chunked
|
||||
// resolve. It runs CrossRepoResolver.ResolveAll while an "editor" goroutine
|
||||
// repeatedly evicts and re-indexes caller files, taking the SAME resolve mutex
|
||||
// an interactive single-file edit takes — exactly the interleaving the chunked
|
||||
// path enables. Without the resolveEdge liveness guards this corrupts the graph
|
||||
// (ReindexEdge half-resurrects an evicted edge and later panics with an
|
||||
// index-out-of-range during eviction); with them the run is clean and every
|
||||
// resolved edge points at a live node.
|
||||
//
|
||||
// Run with -race -count=N for scheduling variation.
|
||||
func TestCrossRepoResolveAll_ConcurrentEdits(t *testing.T) {
|
||||
const (
|
||||
files = 40
|
||||
callersPerFile = 600 // 24000 pending -> ~12 chunks at the default 2048
|
||||
)
|
||||
g := graph.New()
|
||||
|
||||
callerFile := func(k int) string { return fmt.Sprintf("repoA/a%d.go", k) }
|
||||
|
||||
// repoB targets: one Helper per (file,caller) slot.
|
||||
for k := 0; k < files; k++ {
|
||||
for i := 0; i < callersPerFile; i++ {
|
||||
id := fmt.Sprintf("repoB/c%d_%d.go::Helper%d_%d", k, i, k, i)
|
||||
g.AddNode(&graph.Node{
|
||||
ID: id, Kind: graph.KindFunction, Name: fmt.Sprintf("Helper%d_%d", k, i),
|
||||
FilePath: fmt.Sprintf("repoB/c%d_%d.go", k, i), Language: "go", RepoPrefix: "repoB",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// addCallerFile (re)indexes one repoA caller file: its caller functions, the
|
||||
// unresolved call edges into repoB, and the import-reachability evidence.
|
||||
addCallerFile := func(k int) {
|
||||
for i := 0; i < callersPerFile; i++ {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: fmt.Sprintf("%s::Caller%d_%d", callerFile(k), k, i), Kind: graph.KindFunction,
|
||||
Name: fmt.Sprintf("Caller%d_%d", k, i), FilePath: callerFile(k), Language: "go", RepoPrefix: "repoA",
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: fmt.Sprintf("%s::Caller%d_%d", callerFile(k), k, i),
|
||||
To: fmt.Sprintf("unresolved::Helper%d_%d", k, i),
|
||||
Kind: graph.EdgeCalls, FilePath: callerFile(k), Line: i + 1,
|
||||
})
|
||||
}
|
||||
wireImport(g, callerFile(k), "repoB", fmt.Sprintf("repoB/c%d_0.go", k))
|
||||
}
|
||||
for k := 0; k < files; k++ {
|
||||
addCallerFile(k)
|
||||
}
|
||||
|
||||
var resolveDone atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cr := NewCrossRepo(g)
|
||||
cr.ResolveAll()
|
||||
resolveDone.Store(true)
|
||||
}()
|
||||
|
||||
// Editor: between ResolveAll's chunks (when it yields the resolve mutex),
|
||||
// re-index a caller file — evict it (drops its nodes + the call edges the
|
||||
// resolver's pending snapshot still points at) and add it back. Takes the
|
||||
// resolve mutex, exactly as an interactive edit's ResolveFileAndIncoming does.
|
||||
mu := g.ResolveMutex()
|
||||
var edits int
|
||||
for k := 0; !resolveDone.Load(); k = (k + 1) % files {
|
||||
mu.Lock()
|
||||
g.EvictFile(callerFile(k))
|
||||
addCallerFile(k)
|
||||
mu.Unlock()
|
||||
edits++
|
||||
runtime.Gosched()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Greater(t, edits, 0, "editor never interleaved — increase the work size")
|
||||
|
||||
// No resolved edge may point at a missing node: that is the dangling-edge /
|
||||
// half-resurrection signature the guards exist to prevent.
|
||||
for _, e := range g.AllEdges() {
|
||||
if e == nil || strings.HasPrefix(e.To, "unresolved::") || isSyntheticResolveTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
require.NotNilf(t, g.GetNode(e.To),
|
||||
"edge %s -> %s resolved to a node not in the graph (dangling)", e.From, e.To)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// DetectCrossRepoEdges is the graph-wide materialisation pass for the
|
||||
// cross-repo edge layer (M3). It walks every resolved calls / implements
|
||||
// / extends edge and, whenever the From node and the To node live in
|
||||
// two different repos, emits a parallel edge of the matching
|
||||
// cross_repo_* kind and sets Edge.CrossRepo on the base edge so the
|
||||
// bool flag and the dedicated kind never disagree.
|
||||
//
|
||||
// The pass is a full recompute and is idempotent: graph.AddEdge dedupes
|
||||
// by edgeKey, so re-emitting an unchanged parallel edge is a no-op. It
|
||||
// is also incremental-safe — graph.EvictFile removes a node's edges in
|
||||
// both directions, so when either endpoint's file is reindexed the
|
||||
// stale parallel edge is gone before this pass re-runs. Parallel
|
||||
// cross_repo_* edges are themselves skipped (CrossRepoKindFor only maps
|
||||
// the three base kinds), so the pass never feeds on its own output.
|
||||
//
|
||||
// Runs at every resolver "settle" point: the tail of
|
||||
// CrossRepoResolver.ResolveAll / ResolveForRepo (cross-repo calls just
|
||||
// lifted by the boundary resolver) and inside the indexers'
|
||||
// RunGlobalGraphPasses (cross-repo implements / extends just produced
|
||||
// by InferImplements / InferOverrides).
|
||||
//
|
||||
// Returns the count of cross-repo relationships found this pass — the
|
||||
// number of parallel edges that exist after it, modulo graph dedup.
|
||||
func DetectCrossRepoEdges(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
emitted := 0
|
||||
for _, row := range crossRepoCandidates(g) {
|
||||
e := row.Edge
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
crKind, ok := graph.CrossRepoKindFor(e.Kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Keep the bool flag on the base edge consistent with the
|
||||
// dedicated kind — existing consumers (smart_context's
|
||||
// cross_repo_dependencies, the Cypher / GraphML exporters) read
|
||||
// Edge.CrossRepo, and structurally-resolved cross-repo edges
|
||||
// would otherwise carry the parallel kind without the flag.
|
||||
e.CrossRepo = true
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: e.From,
|
||||
To: e.To,
|
||||
Kind: crKind,
|
||||
FilePath: e.FilePath,
|
||||
Line: e.Line,
|
||||
Confidence: e.Confidence,
|
||||
ConfidenceLabel: e.ConfidenceLabel,
|
||||
Origin: e.Origin,
|
||||
CrossRepo: true,
|
||||
Meta: map[string]any{
|
||||
"base_kind": string(e.Kind),
|
||||
"source_repo": row.FromRepo,
|
||||
"target_repo": row.ToRepo,
|
||||
},
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
return emitted
|
||||
}
|
||||
|
||||
// crossRepoCandidates returns every edge whose Kind has a parallel
|
||||
// cross_repo_* kind AND whose endpoints carry two distinct, non-empty
|
||||
// RepoPrefix values. Routed through the storage layer's
|
||||
// CrossRepoCandidates capability when the backend implements it (one
|
||||
// query — a join with the kind + repo-prefix filters in WHERE); falls
|
||||
// back to the AllEdges + per-edge GetNode walk otherwise.
|
||||
//
|
||||
// The base-kind set is derived from graph.CrossRepoKindFor by
|
||||
// iterating the in-process registry — the disk backend uses the same
|
||||
// kind list verbatim so single-repo graphs return no rows without a
|
||||
// whole-table scan.
|
||||
func crossRepoCandidates(g graph.Store) []graph.CrossRepoCandidateRow {
|
||||
baseKinds := graph.BaseKindsForCrossRepo()
|
||||
if cap, ok := g.(graph.CrossRepoCandidates); ok {
|
||||
return cap.CrossRepoCandidates(baseKinds)
|
||||
}
|
||||
if len(baseKinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
kset := make(map[graph.EdgeKind]struct{}, len(baseKinds))
|
||||
for _, k := range baseKinds {
|
||||
kset[k] = struct{}{}
|
||||
}
|
||||
var out []graph.CrossRepoCandidateRow
|
||||
// Meta-less kind-scoped scan (see LightEdgeScanner): this fallback only runs
|
||||
// for a store without the CrossRepoCandidates capability, and reads just
|
||||
// e.Kind and endpoints — the emitted row propagates e but its consumer
|
||||
// (DetectCrossRepoEdges) touches only promoted fields, never Meta.
|
||||
for _, e := range graph.EdgesForKindsLight(g, baseKinds...) {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := kset[e.Kind]; !ok {
|
||||
continue
|
||||
}
|
||||
from := g.GetNode(e.From)
|
||||
to := g.GetNode(e.To)
|
||||
if from == nil || to == nil {
|
||||
continue
|
||||
}
|
||||
if from.RepoPrefix == "" || to.RepoPrefix == "" {
|
||||
continue
|
||||
}
|
||||
if from.RepoPrefix == to.RepoPrefix {
|
||||
continue
|
||||
}
|
||||
out = append(out, graph.CrossRepoCandidateRow{
|
||||
Edge: e,
|
||||
FromRepo: from.RepoPrefix,
|
||||
ToRepo: to.RepoPrefix,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// countOutEdgesByKind returns how many out-edges of the given kind the
|
||||
// node fromID has.
|
||||
func countOutEdgesByKind(g graph.Store, fromID string, kind graph.EdgeKind) int {
|
||||
n := 0
|
||||
for _, e := range g.GetOutEdges(fromID) {
|
||||
if e.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// firstOutEdgeByKind returns the first out-edge of fromID with the given
|
||||
// kind, or nil.
|
||||
func firstOutEdgeByKind(g graph.Store, fromID string, kind graph.EdgeKind) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(fromID) {
|
||||
if e.Kind == kind {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDetectCrossRepoEdges_CallsAcrossRepos(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Callee", Kind: graph.KindFunction, Name: "Callee", FilePath: "repoB/b.go", RepoPrefix: "repoB"})
|
||||
|
||||
base := &graph.Edge{
|
||||
From: "repoA/a.go::Caller", To: "repoB/b.go::Callee",
|
||||
Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 7,
|
||||
Confidence: 0.8, Origin: graph.OriginASTInferred,
|
||||
}
|
||||
g.AddEdge(base)
|
||||
|
||||
emitted := DetectCrossRepoEdges(g)
|
||||
assert.Equal(t, 1, emitted)
|
||||
|
||||
// Parallel edge materialised.
|
||||
cr := firstOutEdgeByKind(g, "repoA/a.go::Caller", graph.EdgeCrossRepoCalls)
|
||||
if assert.NotNil(t, cr, "expected a cross_repo_calls edge") {
|
||||
assert.Equal(t, "repoB/b.go::Callee", cr.To)
|
||||
assert.Equal(t, "repoA/a.go", cr.FilePath)
|
||||
assert.Equal(t, 7, cr.Line)
|
||||
// Origin / confidence inherited from the base edge.
|
||||
assert.Equal(t, graph.OriginASTInferred, cr.Origin)
|
||||
assert.Equal(t, 0.8, cr.Confidence)
|
||||
assert.True(t, cr.CrossRepo)
|
||||
assert.Equal(t, "calls", cr.Meta["base_kind"])
|
||||
assert.Equal(t, "repoA", cr.Meta["source_repo"])
|
||||
assert.Equal(t, "repoB", cr.Meta["target_repo"])
|
||||
}
|
||||
|
||||
// Base edge keeps its kind and now carries the bool flag.
|
||||
assert.Equal(t, graph.EdgeCalls, base.Kind)
|
||||
assert.True(t, base.CrossRepo)
|
||||
}
|
||||
|
||||
func TestDetectCrossRepoEdges_ImplementsAndExtends(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Impl", Kind: graph.KindType, Name: "Impl", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Iface", Kind: graph.KindInterface, Name: "Iface", FilePath: "repoB/b.go", RepoPrefix: "repoB"})
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Child", Kind: graph.KindType, Name: "Child", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Parent", Kind: graph.KindType, Name: "Parent", FilePath: "repoB/b.go", RepoPrefix: "repoB"})
|
||||
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Impl", To: "repoB/b.go::Iface", Kind: graph.EdgeImplements, FilePath: "repoA/a.go", Line: 3, Origin: graph.OriginASTResolved})
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Child", To: "repoB/b.go::Parent", Kind: graph.EdgeExtends, FilePath: "repoA/a.go", Line: 4, Origin: graph.OriginASTResolved})
|
||||
|
||||
emitted := DetectCrossRepoEdges(g)
|
||||
assert.Equal(t, 2, emitted)
|
||||
|
||||
assert.Equal(t, 1, countOutEdgesByKind(g, "repoA/a.go::Impl", graph.EdgeCrossRepoImplements))
|
||||
assert.Equal(t, 1, countOutEdgesByKind(g, "repoA/a.go::Child", graph.EdgeCrossRepoExtends))
|
||||
}
|
||||
|
||||
func TestDetectCrossRepoEdges_SameRepoUntouched(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoA/b.go::Callee", Kind: graph.KindFunction, Name: "Callee", FilePath: "repoA/b.go", RepoPrefix: "repoA"})
|
||||
|
||||
base := &graph.Edge{From: "repoA/a.go::Caller", To: "repoA/b.go::Callee", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 1}
|
||||
g.AddEdge(base)
|
||||
|
||||
emitted := DetectCrossRepoEdges(g)
|
||||
assert.Equal(t, 0, emitted)
|
||||
assert.Equal(t, 0, countOutEdgesByKind(g, "repoA/a.go::Caller", graph.EdgeCrossRepoCalls))
|
||||
assert.False(t, base.CrossRepo)
|
||||
}
|
||||
|
||||
func TestDetectCrossRepoEdges_SkipsStubsAndUnstampedNodes(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Caller in repoA; one edge to an unresolved stub, one to a node
|
||||
// whose RepoPrefix was never stamped.
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "loose.go::Loose", Kind: graph.KindFunction, Name: "Loose", FilePath: "loose.go"}) // no RepoPrefix
|
||||
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Caller", To: "unresolved::Missing", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 1})
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Caller", To: "external::net/http", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 2})
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Caller", To: "loose.go::Loose", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 3})
|
||||
|
||||
emitted := DetectCrossRepoEdges(g)
|
||||
assert.Equal(t, 0, emitted)
|
||||
assert.Equal(t, 0, countOutEdgesByKind(g, "repoA/a.go::Caller", graph.EdgeCrossRepoCalls))
|
||||
}
|
||||
|
||||
func TestDetectCrossRepoEdges_Idempotent(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Callee", Kind: graph.KindFunction, Name: "Callee", FilePath: "repoB/b.go", RepoPrefix: "repoB"})
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Caller", To: "repoB/b.go::Callee", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 7})
|
||||
|
||||
first := DetectCrossRepoEdges(g)
|
||||
second := DetectCrossRepoEdges(g)
|
||||
assert.Equal(t, 1, first)
|
||||
// Re-running re-counts the same relationship but graph.AddEdge
|
||||
// dedupes by edgeKey — the parallel edge is not duplicated.
|
||||
assert.Equal(t, 1, second)
|
||||
assert.Equal(t, 1, countOutEdgesByKind(g, "repoA/a.go::Caller", graph.EdgeCrossRepoCalls))
|
||||
}
|
||||
|
||||
// The pass must not feed on its own output: a cross_repo_* edge is not
|
||||
// itself a base kind, so a second pass finds nothing new from it.
|
||||
func TestDetectCrossRepoEdges_DoesNotRecurseOnOwnOutput(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Callee", Kind: graph.KindFunction, Name: "Callee", FilePath: "repoB/b.go", RepoPrefix: "repoB"})
|
||||
g.AddEdge(&graph.Edge{From: "repoA/a.go::Caller", To: "repoB/b.go::Callee", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 7})
|
||||
|
||||
DetectCrossRepoEdges(g)
|
||||
// Exactly one base + one parallel edge — never a cross_repo_calls
|
||||
// parallel of a cross_repo_calls edge.
|
||||
assert.Equal(t, 1, countOutEdgesByKind(g, "repoA/a.go::Caller", graph.EdgeCalls))
|
||||
assert.Equal(t, 1, countOutEdgesByKind(g, "repoA/a.go::Caller", graph.EdgeCrossRepoCalls))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
type fakeProber struct {
|
||||
hit bool
|
||||
decl RemoteDecl
|
||||
calls int
|
||||
lastName string
|
||||
lastHint string
|
||||
}
|
||||
|
||||
func (f *fakeProber) ProbeDeclaration(_ context.Context, name, importHint string) (RemoteDecl, bool) {
|
||||
f.calls++
|
||||
f.lastName = name
|
||||
f.lastHint = importHint
|
||||
if !f.hit {
|
||||
return RemoteDecl{}, false
|
||||
}
|
||||
return f.decl, true
|
||||
}
|
||||
|
||||
// stitchFixture builds a graph with a caller whose unresolved call to
|
||||
// `Helper` has no local target. withImport controls whether the caller
|
||||
// file carries an import edge (the evidence hint).
|
||||
func stitchFixture(withImport bool) (*graph.Graph, *graph.Edge) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller",
|
||||
FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA",
|
||||
})
|
||||
if withImport {
|
||||
wireImport(g, "repoA/pkg/a.go", "extmod", "extmod/mod.go")
|
||||
}
|
||||
edge := &graph.Edge{
|
||||
From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper",
|
||||
Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5,
|
||||
}
|
||||
g.AddEdge(edge)
|
||||
return g, edge
|
||||
}
|
||||
|
||||
func hitDecl() RemoteDecl {
|
||||
return RemoteDecl{
|
||||
Slug: "remoteB", RemoteID: "rb/lib/c.go::Helper", Kind: graph.KindFunction,
|
||||
RepoPrefix: "rb", WorkspaceID: "wsB", File: "rb/lib/c.go", Line: 12,
|
||||
}
|
||||
}
|
||||
|
||||
// (a) bare name + no import hint -> the evidence gate blocks the probe.
|
||||
func TestStitch_NoImportHint_NeverProbes(t *testing.T) {
|
||||
g, edge := stitchFixture(false)
|
||||
cr := NewCrossRepo(g)
|
||||
p := &fakeProber{hit: true, decl: hitDecl()}
|
||||
cr.EnableRemoteStitch(p, 100)
|
||||
cr.ResolveAll()
|
||||
|
||||
if p.calls != 0 {
|
||||
t.Errorf("with no import hint the prober must NOT be called; calls=%d", p.calls)
|
||||
}
|
||||
if edge.To != "unresolved::Helper" {
|
||||
t.Errorf("edge must stay unresolved; got %q", edge.To)
|
||||
}
|
||||
}
|
||||
|
||||
// (b) import hint + remote miss -> probed, but no mint.
|
||||
func TestStitch_ImportHint_RemoteMiss_NoMint(t *testing.T) {
|
||||
g, edge := stitchFixture(true)
|
||||
cr := NewCrossRepo(g)
|
||||
p := &fakeProber{hit: false}
|
||||
cr.EnableRemoteStitch(p, 100)
|
||||
cr.ResolveAll()
|
||||
|
||||
if p.calls == 0 {
|
||||
t.Error("with an import hint the prober should be consulted")
|
||||
}
|
||||
if p.lastHint == "" {
|
||||
t.Error("the prober must receive a non-empty import hint")
|
||||
}
|
||||
if edge.To != "unresolved::Helper" {
|
||||
t.Errorf("a remote miss must not mint; edge.To=%q", edge.To)
|
||||
}
|
||||
}
|
||||
|
||||
// (c) import hint + remote hit -> proxy minted, edge rewritten with honest
|
||||
// provenance.
|
||||
func TestStitch_ImportHint_RemoteHit_Mints(t *testing.T) {
|
||||
g, edge := stitchFixture(true)
|
||||
cr := NewCrossRepo(g)
|
||||
p := &fakeProber{hit: true, decl: hitDecl()}
|
||||
cr.EnableRemoteStitch(p, 100)
|
||||
cr.ResolveAll()
|
||||
|
||||
pid := graph.ProxyNodeID("remoteB", "rb/lib/c.go::Helper")
|
||||
if edge.To != pid {
|
||||
t.Fatalf("edge should point at the proxy %q; got %q", pid, edge.To)
|
||||
}
|
||||
if edge.Origin != graph.OriginTextMatched {
|
||||
t.Errorf("stitched edge must be honest (text_matched), got %q", edge.Origin)
|
||||
}
|
||||
if !edge.CrossRepo {
|
||||
t.Error("stitched edge must be marked CrossRepo")
|
||||
}
|
||||
n := g.GetNode(pid)
|
||||
if n == nil || !graph.IsProxyNode(n) {
|
||||
t.Fatalf("proxy node should exist and be IsProxyNode; got %+v", n)
|
||||
}
|
||||
if n.Origin != "remote:remoteB" || !n.Stub {
|
||||
t.Errorf("proxy node fields wrong: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// (d) budget exceeded -> mint refused, edge stays unresolved.
|
||||
func TestStitch_BudgetExceeded_Refuses(t *testing.T) {
|
||||
g, edge := stitchFixture(true)
|
||||
// Pre-seed one proxy node so a budget of 1 is already full.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: graph.ProxyNodeID("other", "o/z.go::Zzz"), Kind: graph.KindFunction,
|
||||
Name: "Zzz", Origin: "remote:other", Stub: true,
|
||||
})
|
||||
cr := NewCrossRepo(g)
|
||||
p := &fakeProber{hit: true, decl: hitDecl()}
|
||||
cr.EnableRemoteStitch(p, 1)
|
||||
cr.ResolveAll()
|
||||
|
||||
if edge.To != "unresolved::Helper" {
|
||||
t.Errorf("over budget, the mint must be refused; edge.To=%q", edge.To)
|
||||
}
|
||||
if g.GetNode(graph.ProxyNodeID("remoteB", "rb/lib/c.go::Helper")) != nil {
|
||||
t.Error("no new proxy node should be minted over budget")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
// --- Unit tests for CrossRepoResolver (Task 7.1) ---
|
||||
|
||||
// wireImport adds a resolved EdgeImports edge from callerFile into a
|
||||
// file node in targetRepo, plus the target file node itself. This is
|
||||
// the import-reachability *evidence* CrossRepoResolver now requires
|
||||
// before it will resolve a name-only call across a repo boundary —
|
||||
// without it, a bare name like `Helper` could land on any repo that
|
||||
// happens to define a `Helper`, which is the exact name-collision
|
||||
// false-positive class this guards against.
|
||||
func wireImport(g graph.Store, callerFile, targetRepo, targetFile string) {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: targetFile, Kind: graph.KindFile, Name: targetFile,
|
||||
FilePath: targetFile, Language: "go", RepoPrefix: targetRepo,
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: callerFile, To: targetFile,
|
||||
Kind: graph.EdgeImports, FilePath: callerFile, Line: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossRepoResolveAll_SameRepoPreferred(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Repo A: caller and a target function.
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/b.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoA/pkg/b.go", Language: "go", RepoPrefix: "repoA"})
|
||||
|
||||
// Repo B: same-named function.
|
||||
g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, 0, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "repoA/pkg/b.go::Helper", edge.To)
|
||||
assert.False(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// With an import edge proving repoA reaches repoB, the cross-repo
|
||||
// fallback resolves — this is the legitimate cross-repo call case.
|
||||
func TestCrossRepoResolveAll_CrossRepoFallback(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Repo A: caller, no matching function.
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
|
||||
// Repo B: target function.
|
||||
g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
// Evidence: repoA's caller file imports repoB.
|
||||
wireImport(g, "repoA/pkg/a.go", "repoB", "repoB/lib/c.go")
|
||||
|
||||
edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "repoB/lib/c.go::Helper", edge.To)
|
||||
assert.True(t, edge.CrossRepo)
|
||||
assert.Equal(t, 1, stats.ByRepo["repoB"])
|
||||
}
|
||||
|
||||
// Without an import edge, the SAME graph must NOT resolve the call
|
||||
// across the repo boundary — it stays unresolved. This is the
|
||||
// regression guard for the M3 false-positive class: a name-only match
|
||||
// in a repo the caller never imports is never selected.
|
||||
func TestCrossRepoResolveAll_RefusesUnreachableCrossRepo(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 0, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "unresolved::Helper", edge.To, "no import edge → must stay unresolved")
|
||||
assert.False(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
func TestCrossRepoResolveAll_Unresolvable(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
|
||||
edge := &graph.Edge{From: "repoA/a.go::Foo", To: "unresolved::NonExistent", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.Unresolved)
|
||||
}
|
||||
|
||||
func TestCrossRepoResolveAll_ImportCrossRepo(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "repoA/main.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/utils/utils.go", Kind: graph.KindPackage, Name: "utils", QualName: "utils", FilePath: "repoB/utils/utils.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
edge := &graph.Edge{From: "repoA/main.go", To: "unresolved::import::utils", Kind: graph.EdgeImports, FilePath: "repoA/main.go", Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "repoB/utils/utils.go", edge.To)
|
||||
assert.True(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
func TestCrossRepoResolveAll_MethodCrossRepo(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/lib/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start", FilePath: "repoB/lib/b.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
// Evidence: repoA's caller file imports repoB.
|
||||
wireImport(g, "repoA/pkg/a.go", "repoB", "repoB/lib/b.go")
|
||||
|
||||
edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::*.Start", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 10}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "repoB/lib/b.go::Server.Start", edge.To)
|
||||
assert.True(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// A method call into a repo the caller never imports must stay
|
||||
// unresolved — the receiver-type name alone is not evidence the call
|
||||
// crosses to that particular repo.
|
||||
func TestCrossRepoResolveAll_RefusesUnreachableMethodCall(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/lib/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start", FilePath: "repoB/lib/b.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::*.Start", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 10}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, "unresolved::*.Start", edge.To)
|
||||
assert.False(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
func TestCrossRepoResolveForRepo(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Repo A: caller with unresolved edge.
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
// Repo B: caller with unresolved edge + target.
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Baz", Kind: graph.KindFunction, Name: "Baz", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"})
|
||||
|
||||
// Evidence: repoA's file imports repoB.
|
||||
wireImport(g, "repoA/a.go", "repoB", "repoB/b.go")
|
||||
|
||||
edgeA := &graph.Edge{From: "repoA/a.go::Foo", To: "unresolved::Baz", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5}
|
||||
edgeB := &graph.Edge{From: "repoB/b.go::Bar", To: "unresolved::Foo", Kind: graph.EdgeCalls, FilePath: "repoB/b.go", Line: 5}
|
||||
g.AddEdge(edgeA)
|
||||
g.AddEdge(edgeB)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
|
||||
// Resolve only repoA edges.
|
||||
stats := cr.ResolveForRepo("repoA")
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.CrossRepoEdges)
|
||||
assert.Equal(t, "repoB/b.go::Baz", edgeA.To)
|
||||
assert.True(t, edgeA.CrossRepo)
|
||||
|
||||
// edgeB should still be unresolved.
|
||||
assert.Equal(t, "unresolved::Foo", edgeB.To)
|
||||
}
|
||||
|
||||
// --- Property test for Task 7.2 ---
|
||||
|
||||
// Feature: multi-repo-support, Property 10: Cross-repo resolution with same-repo preference
|
||||
func TestPropertyCrossRepoResolutionSameRepoPreference(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Generate a function name.
|
||||
funcName := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,8}`).Draw(rt, "funcName")
|
||||
|
||||
// Generate two repo prefixes.
|
||||
repoA := "repo-" + rapid.StringMatching(`[a-z]{3,6}`).Draw(rt, "repoA")
|
||||
repoB := "repo-" + rapid.StringMatching(`[a-z]{3,6}`).Draw(rt, "repoB")
|
||||
// Ensure distinct repos.
|
||||
if repoA == repoB {
|
||||
repoB = repoB + "x"
|
||||
}
|
||||
|
||||
// Decide whether the caller's repo has a same-repo match.
|
||||
hasSameRepoMatch := rapid.Bool().Draw(rt, "hasSameRepoMatch")
|
||||
|
||||
// Always add the caller in repoA.
|
||||
callerFile := repoA + "/src/caller.go"
|
||||
callerID := callerFile + "::" + "Caller"
|
||||
g.AddNode(&graph.Node{
|
||||
ID: callerID, Kind: graph.KindFunction, Name: "Caller",
|
||||
FilePath: callerFile, Language: "go", RepoPrefix: repoA,
|
||||
})
|
||||
|
||||
// Always add the target in repoB (cross-repo candidate).
|
||||
crossRepoTargetID := repoB + "/lib/target.go::" + funcName
|
||||
g.AddNode(&graph.Node{
|
||||
ID: crossRepoTargetID, Kind: graph.KindFunction, Name: funcName,
|
||||
FilePath: repoB + "/lib/target.go", Language: "go", RepoPrefix: repoB,
|
||||
})
|
||||
|
||||
// Evidence: the caller's file imports repoB — without this the
|
||||
// cross-repo fallback is (correctly) refused.
|
||||
wireImport(g, callerFile, repoB, repoB+"/lib/target.go")
|
||||
|
||||
// Optionally add a same-repo target in repoA.
|
||||
sameRepoTargetID := repoA + "/src/target.go::" + funcName
|
||||
if hasSameRepoMatch {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: sameRepoTargetID, Kind: graph.KindFunction, Name: funcName,
|
||||
FilePath: repoA + "/src/target.go", Language: "go", RepoPrefix: repoA,
|
||||
})
|
||||
}
|
||||
|
||||
// Add unresolved edge from caller.
|
||||
edge := &graph.Edge{
|
||||
From: callerID, To: "unresolved::" + funcName,
|
||||
Kind: graph.EdgeCalls, FilePath: callerFile, Line: 10,
|
||||
}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
require.Equal(rt, 1, stats.Resolved, "edge should be resolved")
|
||||
require.Equal(rt, 0, stats.Unresolved, "no edges should remain unresolved")
|
||||
|
||||
if hasSameRepoMatch {
|
||||
// Same-repo match preferred.
|
||||
require.Equal(rt, sameRepoTargetID, edge.To,
|
||||
"same-repo match should be preferred")
|
||||
require.False(rt, edge.CrossRepo,
|
||||
"same-repo edge should not be marked cross-repo")
|
||||
require.Equal(rt, 0, stats.CrossRepoEdges,
|
||||
"no cross-repo edges when same-repo match exists")
|
||||
} else {
|
||||
// Cross-repo fallback — eligible because the caller imports repoB.
|
||||
require.Equal(rt, crossRepoTargetID, edge.To,
|
||||
"cross-repo target should be used when no same-repo match")
|
||||
require.True(rt, edge.CrossRepo,
|
||||
"cross-repo edge must have CrossRepo == true")
|
||||
require.Equal(rt, 1, stats.CrossRepoEdges,
|
||||
"one cross-repo edge expected")
|
||||
// Target ID should be a Qualified_Node_ID containing the target's RepoPrefix.
|
||||
require.Contains(rt, edge.To, repoB+"/",
|
||||
"cross-repo target should use Qualified_Node_ID with target RepoPrefix")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossRepoResolveAll_RefusesCrossWorkspaceNameCollision is the
|
||||
// end-to-end regression guard for the M3 false-positive report: a
|
||||
// caller in one workspace must never resolve a bare-name call to a
|
||||
// same-named symbol in an unrelated workspace it does not import.
|
||||
func TestCrossRepoResolveAll_RefusesCrossWorkspaceNameCollision(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Workspace "gortex": a type method named `string`.
|
||||
g.AddNode(&graph.Node{ID: "gortex/edge.go::EdgeKind", Kind: graph.KindType, Name: "EdgeKind", FilePath: "gortex/edge.go", Language: "go", RepoPrefix: "gortex", WorkspaceID: "gortex"})
|
||||
g.AddNode(&graph.Node{ID: "gortex/edge.go::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "gortex/edge.go", Language: "go", RepoPrefix: "gortex", WorkspaceID: "gortex"})
|
||||
|
||||
// Unrelated workspace "rcd": a method literally named `string`.
|
||||
g.AddNode(&graph.Node{ID: "rcd/models/bot.go::BotClass.string", Kind: graph.KindMethod, Name: "string", FilePath: "rcd/models/bot.go", Language: "go", RepoPrefix: "rcd", WorkspaceID: "rcd"})
|
||||
|
||||
// gortex calls something named `string` — no import into rcd.
|
||||
edge := &graph.Edge{From: "gortex/edge.go::caller", To: "unresolved::string", Kind: graph.EdgeCalls, FilePath: "gortex/edge.go", Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.CrossRepoEdges, "must not cross into an unimported, unrelated workspace")
|
||||
assert.Equal(t, "unresolved::string", edge.To)
|
||||
assert.False(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// TestCrossRepoResolveAll_BindsInboundEdgeIntoChangedRepo locks the warm-restart
|
||||
// completeness guarantee: when an unchanged consumer repo already holds a bare
|
||||
// unresolved reference and a provider repo later adds the matching definition,
|
||||
// the whole-graph cross-repo resolve must bind that inbound reference. A pass
|
||||
// scoped to only the changed provider's OWN out-edges would never see the
|
||||
// consumer-owned edge, which is why the pre-enrich cross-repo resolve stays
|
||||
// whole-graph.
|
||||
func TestCrossRepoResolveAll_BindsInboundEdgeIntoChangedRepo(t *testing.T) {
|
||||
g := graph.New()
|
||||
|
||||
// Unchanged consumer repoA: a call with no local definition — bare unresolved.
|
||||
g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"})
|
||||
// Changed provider repoB: just added Foo.
|
||||
g.AddNode(&graph.Node{ID: "repoB/b.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"})
|
||||
wireImport(g, "repoA/a.go", "repoB", "repoB/b.go")
|
||||
|
||||
inbound := &graph.Edge{From: "repoA/a.go::Caller", To: "unresolved::Foo", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5}
|
||||
g.AddEdge(inbound)
|
||||
|
||||
// The edge is owned by the unchanged consumer, so it is absent from the
|
||||
// changed provider's own out-edge set (graph.GetRepoEdges("repoB")).
|
||||
require.Empty(t, g.GetRepoEdges("repoB"),
|
||||
"the inbound edge must not appear in the changed provider's own out-edges")
|
||||
|
||||
NewCrossRepo(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, "repoB/b.go::Foo", inbound.To,
|
||||
"the whole-graph cross-repo resolve must bind the unchanged consumer's inbound reference into the changed provider")
|
||||
assert.True(t, inbound.CrossRepo)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// strictBoundary returns a non-nil cross-workspace lookup that declares
|
||||
// no deps, so crossWorkspaceEligible enforces a hard workspace boundary.
|
||||
// (A nil lookup is intentionally permissive, which would mask the
|
||||
// worktree-instance preference under test.)
|
||||
func strictBoundary() CrossWorkspaceDepLookup {
|
||||
return func(string) []CrossWorkspaceDepRule { return nil }
|
||||
}
|
||||
|
||||
// worktreeImportGraph models the issue #47 cross-repo shape: module
|
||||
// `oas-orm` is checked out twice — the canonical copy in workspace
|
||||
// "base" and a worktree instance (its own prefix) in workspace "task" —
|
||||
// and a caller in `callerWS` imports it. The two module nodes share a
|
||||
// QualName, so the graph's single-valued qual-name index can only hold
|
||||
// one of them; resolution must still bind to the caller's own workspace.
|
||||
func worktreeImportGraph(callerWS string) (*graph.Graph, *graph.Edge) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "sherlock/main.go", Kind: graph.KindFile, Name: "main.go",
|
||||
FilePath: "sherlock/main.go", Language: "go", RepoPrefix: "sherlock", WorkspaceID: callerWS})
|
||||
// Canonical checkout (workspace "base").
|
||||
g.AddNode(&graph.Node{ID: "oas-orm/orm.go::orm", Kind: graph.KindPackage, Name: "orm", QualName: "oas-orm",
|
||||
FilePath: "oas-orm/orm.go", Language: "go", RepoPrefix: "oas-orm", WorkspaceID: "base"})
|
||||
// Worktree instance of the SAME module (workspace "task").
|
||||
g.AddNode(&graph.Node{ID: "oas-orm@task/orm.go::orm", Kind: graph.KindPackage, Name: "orm", QualName: "oas-orm",
|
||||
FilePath: "oas-orm@task/orm.go", Language: "go", RepoPrefix: "oas-orm@task", WorkspaceID: "task"})
|
||||
edge := &graph.Edge{From: "sherlock/main.go", To: "unresolved::import::oas-orm",
|
||||
Kind: graph.EdgeImports, FilePath: "sherlock/main.go", Line: 3}
|
||||
g.AddEdge(edge)
|
||||
return g, edge
|
||||
}
|
||||
|
||||
// TestCrossRepoImport_ResolvesIntoCallerWorkspaceWorktree is the core
|
||||
// resolver regression for issue #47: a caller in the task workspace
|
||||
// importing a module that exists both canonically (another workspace)
|
||||
// and as a worktree instance (its own workspace) must bind to the
|
||||
// worktree instance — regardless of which copy the single-valued
|
||||
// qual-name index happens to surface.
|
||||
func TestCrossRepoImport_ResolvesIntoCallerWorkspaceWorktree(t *testing.T) {
|
||||
g, edge := worktreeImportGraph("task")
|
||||
cr := NewCrossRepo(g)
|
||||
cr.SetCrossWorkspaceDepLookup(strictBoundary())
|
||||
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "oas-orm@task/orm.go::orm", edge.To,
|
||||
"import from a task-workspace caller must bind to the worktree instance, not the canonical")
|
||||
assert.True(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// TestCrossRepoImport_ResolvesIntoCanonicalForBaseWorkspace is the
|
||||
// mirror: a base-workspace caller binds to the canonical checkout, so
|
||||
// the two instances coexist without bleeding into one another.
|
||||
func TestCrossRepoImport_ResolvesIntoCanonicalForBaseWorkspace(t *testing.T) {
|
||||
g, edge := worktreeImportGraph("base")
|
||||
cr := NewCrossRepo(g)
|
||||
cr.SetCrossWorkspaceDepLookup(strictBoundary())
|
||||
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "oas-orm/orm.go::orm", edge.To,
|
||||
"import from the base-workspace caller must bind to the canonical checkout")
|
||||
assert.True(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// TestCrossRepoImport_UnrelatedWorkspaceStaysExternal guards the
|
||||
// boundary: a caller in a third workspace that imports neither instance
|
||||
// (no cross_workspace_deps) must stay external rather than mis-binding
|
||||
// to whichever copy the qual-name index surfaced.
|
||||
func TestCrossRepoImport_UnrelatedWorkspaceStaysExternal(t *testing.T) {
|
||||
g, edge := worktreeImportGraph("other")
|
||||
cr := NewCrossRepo(g)
|
||||
cr.SetCrossWorkspaceDepLookup(strictBoundary())
|
||||
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.CrossRepoEdges, "unrelated workspace must not bind into either instance")
|
||||
assert.Equal(t, "external::oas-orm", edge.To)
|
||||
assert.False(t, edge.CrossRepo)
|
||||
}
|
||||
|
||||
// TestCrossRepoImport_DeclaredDepResolvesToCanonical confirms the
|
||||
// existing cross_workspace_deps escape hatch still works alongside the
|
||||
// worktree preference: when "task" has no own instance but declares a
|
||||
// dep on "base", the import resolves across the boundary to the
|
||||
// canonical copy.
|
||||
func TestCrossRepoImport_DeclaredDepResolvesToCanonical(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "sherlock/main.go", Kind: graph.KindFile, Name: "main.go",
|
||||
FilePath: "sherlock/main.go", Language: "go", RepoPrefix: "sherlock", WorkspaceID: "task"})
|
||||
g.AddNode(&graph.Node{ID: "oas-orm/orm.go::orm", Kind: graph.KindPackage, Name: "orm", QualName: "oas-orm",
|
||||
FilePath: "oas-orm/orm.go", Language: "go", RepoPrefix: "oas-orm", WorkspaceID: "base"})
|
||||
edge := &graph.Edge{From: "sherlock/main.go", To: "unresolved::import::oas-orm",
|
||||
Kind: graph.EdgeImports, FilePath: "sherlock/main.go", Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
cr.SetCrossWorkspaceDepLookup(func(sourceWS string) []CrossWorkspaceDepRule {
|
||||
if sourceWS == "task" {
|
||||
return []CrossWorkspaceDepRule{{Workspace: "base", Modules: []string{"oas-orm"}}}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "oas-orm/orm.go::orm", edge.To)
|
||||
assert.True(t, edge.CrossRepo)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// isCSharpExtension reports whether n is a C# extension method (a static method
|
||||
// whose first parameter carries the `this` modifier). Such methods are bound
|
||||
// only by the type-directed extension rule, never by the locality fallback. The
|
||||
// Language check keeps this C#-only: other languages (e.g. Scala) also stamp
|
||||
// Meta["extension"], and their locality resolution must be left unchanged.
|
||||
func isCSharpExtension(n *graph.Node) bool {
|
||||
if n == nil || n.Language != "csharp" || n.Meta == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := n.Meta["extension"].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// csharpHasCompetingMethod reports whether a non-extension method of the same
|
||||
// name is among the candidates. C# resolves an instance/interface member over
|
||||
// an extension, so without receiver-type evidence the extension must not
|
||||
// preempt a competing member the locality fallback would otherwise bind.
|
||||
func csharpHasCompetingMethod(candidates []*graph.Node) bool {
|
||||
for _, c := range candidates {
|
||||
if c != nil && c.Kind == graph.KindMethod && !isCSharpExtension(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tryBindCSharpExtension binds a failed C# member call `x.Foo(...)` to a static
|
||||
// extension method `Foo(this X x)`. It runs after the receiver-type passes (an
|
||||
// instance or interface member always wins over an extension in C#) and before
|
||||
// the locality fallback. Candidates are the raw same-name in-repo nodes, so a
|
||||
// reachability drop cannot hide a valid extension. Returns true when it binds.
|
||||
//
|
||||
// Precision rules — never guess on ambiguity, which would recreate the
|
||||
// same-name-wrong-type misattribution the receiver-type gate exists to prevent:
|
||||
// - with receiver-type evidence: bind when exactly one extension's
|
||||
// this_param_type matches the receiver; more than one stays unresolved.
|
||||
// - without a matching type: bind only when the name maps to exactly one
|
||||
// extension method in the repo; otherwise stay unresolved.
|
||||
func (r *Resolver) tryBindCSharpExtension(e *graph.Edge, methodName, receiverType string, candidates []*graph.Node, stats *ResolveStats) bool {
|
||||
// C#-only: a non-C# caller must never bind to a C# extension method even
|
||||
// when a same-named one exists in a mixed-language repo.
|
||||
if cn := r.cachedGetNode(e.From); cn == nil || cn.Language != "csharp" {
|
||||
return false
|
||||
}
|
||||
var exts []*graph.Node
|
||||
for _, c := range candidates {
|
||||
if isCSharpExtension(c) {
|
||||
exts = append(exts, c)
|
||||
}
|
||||
}
|
||||
if len(exts) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// With receiver-type evidence, prefer the extension whose this_param_type
|
||||
// matches the receiver. Exactly one match binds; more than one is an
|
||||
// overload/ambiguity we refuse to guess on.
|
||||
if receiverType != "" {
|
||||
var typed []*graph.Node
|
||||
for _, c := range exts {
|
||||
if tp, _ := c.Meta["this_param_type"].(string); tp != "" && tp == receiverType {
|
||||
typed = append(typed, c)
|
||||
}
|
||||
}
|
||||
if len(typed) == 1 {
|
||||
r.bindCSharpExtension(e, typed[0], 0.9, stats)
|
||||
return true
|
||||
}
|
||||
if len(typed) > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// No type evidence (or no typed match): bind only when the name is
|
||||
// unambiguous across the repo's extension methods AND no non-extension
|
||||
// member of that name competes (C# instance-method precedence — let the
|
||||
// locality fallback bind the instance method instead).
|
||||
if len(exts) == 1 && !csharpHasCompetingMethod(candidates) {
|
||||
r.bindCSharpExtension(e, exts[0], 0.75, stats)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bindCSharpExtension points a member-call edge at a resolved extension method
|
||||
// at the ast_inferred tier — the binding is type-directed but not compiler-
|
||||
// verified (extension visibility depends on `using` scope we do not fully model).
|
||||
func (r *Resolver) bindCSharpExtension(e *graph.Edge, target *graph.Node, conf float64, stats *ResolveStats) {
|
||||
e.To = target.ID
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = conf
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf)
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta["resolution"] = "extension_method"
|
||||
stats.Resolved++
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fooCallTarget returns the To-end of the single Foo call edge leaving fromID.
|
||||
func fooCallTarget(g graph.Store, fromID string) string {
|
||||
for _, e := range g.GetOutEdges(fromID) {
|
||||
if e.Kind != graph.EdgeCalls {
|
||||
continue
|
||||
}
|
||||
if graph.IsUnresolvedTarget(e.To) {
|
||||
if graph.UnresolvedName(e.To) == "*.Foo" || graph.UnresolvedName(e.To) == "Foo" {
|
||||
return e.To
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n := g.GetNode(e.To); n != nil && n.Name == "Foo" {
|
||||
return e.To
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestResolveCSharpExtension_UniqueBinds: a call on a literal receiver with no
|
||||
// type evidence binds to the sole extension method of that name.
|
||||
func TestResolveCSharpExtension_UniqueBinds(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Ext.cs": `namespace App {
|
||||
public static class StringExt {
|
||||
public static int Foo(this string s) { return 1; }
|
||||
}
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run() {
|
||||
"a".Foo();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
target := fooCallTarget(g, "Caller.cs::Runner.Run")
|
||||
require.Equal(t, "Ext.cs::StringExt.Foo", target)
|
||||
e := g.GetNode(target)
|
||||
require.NotNil(t, e)
|
||||
}
|
||||
|
||||
// TestResolveCSharpExtension_TypedDisambiguates: with a known receiver type, the
|
||||
// extension whose this_param_type matches wins over a same-named extension on
|
||||
// another type.
|
||||
func TestResolveCSharpExtension_TypedDisambiguates(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Exts.cs": `namespace App {
|
||||
public static class E {
|
||||
public static int Foo(this Widget w) { return 1; }
|
||||
public static int Foo(this Gadget x) { return 2; }
|
||||
}
|
||||
public class Widget {}
|
||||
public class Gadget {}
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run() {
|
||||
Widget w = new Widget();
|
||||
w.Foo();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
target := fooCallTarget(g, "Caller.cs::Runner.Run")
|
||||
require.NotEmpty(t, target)
|
||||
require.False(t, graph.IsUnresolvedTarget(target), "typed receiver should resolve")
|
||||
n := g.GetNode(target)
|
||||
require.NotNil(t, n)
|
||||
assert.Equal(t, "Widget", n.Meta["this_param_type"], "should bind the Widget extension")
|
||||
}
|
||||
|
||||
// TestResolveCSharpExtension_AmbiguousStaysUnresolved: two same-named extensions
|
||||
// on different types + a receiver with no type evidence must not be guessed —
|
||||
// neither the extension rule nor the locality fallback may pick one.
|
||||
func TestResolveCSharpExtension_AmbiguousStaysUnresolved(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Exts.cs": `namespace App {
|
||||
public static class E1 { public static int Foo(this string s) { return 1; } }
|
||||
public static class E2 { public static int Foo(this int n) { return 2; } }
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run(Thing t) {
|
||||
t.Foo();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
target := fooCallTarget(g, "Caller.cs::Runner.Run")
|
||||
require.NotEmpty(t, target)
|
||||
assert.True(t, graph.IsUnresolvedTarget(target),
|
||||
"ambiguous extension call must stay unresolved, got %q", target)
|
||||
}
|
||||
|
||||
// TestResolveCSharpExtension_InstanceWinsUntypedReceiver: with an untyped
|
||||
// receiver (a field, no receiver_type), the sole extension must NOT preempt a
|
||||
// same-named instance method — the locality fallback binds the instance method.
|
||||
func TestResolveCSharpExtension_InstanceWinsUntypedReceiver(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Types.cs": `namespace App {
|
||||
public class Repo { public int Count() { return 0; } }
|
||||
public static class StringExt { public static int Count(this string s) { return s.Length; } }
|
||||
}`,
|
||||
"Consumer.cs": `namespace App {
|
||||
public class Consumer {
|
||||
private Repo _repo = new Repo();
|
||||
public void Run() {
|
||||
_repo.Count();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
var target string
|
||||
for _, e := range g.GetOutEdges("Consumer.cs::Consumer.Run") {
|
||||
if e.Kind == graph.EdgeCalls && !graph.IsUnresolvedTarget(e.To) {
|
||||
if n := g.GetNode(e.To); n != nil && n.Name == "Count" {
|
||||
target = e.To
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "Types.cs::Repo.Count", target,
|
||||
"an untyped receiver must not let the extension preempt the instance method")
|
||||
}
|
||||
|
||||
// TestIsCSharpExtension_LanguageGated: the extension guard is C#-only, so a
|
||||
// Scala extension-method node (which also stamps Meta[extension]) is never
|
||||
// treated as a C# extension — keeping Scala's locality resolution unchanged.
|
||||
func TestIsCSharpExtension_LanguageGated(t *testing.T) {
|
||||
cs := &graph.Node{Kind: graph.KindMethod, Language: "csharp", Meta: map[string]any{"extension": true}}
|
||||
scala := &graph.Node{Kind: graph.KindMethod, Language: "scala", Meta: map[string]any{"extension": true}}
|
||||
assert.True(t, isCSharpExtension(cs))
|
||||
assert.False(t, isCSharpExtension(scala),
|
||||
"a Scala extension node must not be treated as a C# extension")
|
||||
}
|
||||
|
||||
// TestResolveCSharpExtension_InstanceWins: an instance method beats an extension
|
||||
// of the same name (C# member-lookup precedence).
|
||||
func TestResolveCSharpExtension_InstanceWins(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Types.cs": `namespace App {
|
||||
public class Widget {
|
||||
public int Foo() { return 0; }
|
||||
}
|
||||
public static class E {
|
||||
public static int Foo(this Widget w) { return 1; }
|
||||
}
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run() {
|
||||
Widget w = new Widget();
|
||||
w.Foo();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
target := fooCallTarget(g, "Caller.cs::Runner.Run")
|
||||
require.Equal(t, "Types.cs::Widget.Foo", target, "instance method should win over the extension")
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Member-level C# interface-dispatch synthesis: the implements-family cascade.
|
||||
//
|
||||
// Roslyn — the reference C# resolver — treats an interface method and every
|
||||
// method that implements it (directly, or through a base class that implements
|
||||
// the interface) as ONE linked family, and reports the union of the family's
|
||||
// call sites for every member. Two mechanisms feed that union:
|
||||
//
|
||||
// 1. Through-interface calls: `x.Convert(1)` where `x` is typed as the
|
||||
// interface binds to the interface member node. Those calls must surface
|
||||
// on every concrete implementation.
|
||||
// 2. Sibling implementation calls: a converter's own `Convert(-number)`
|
||||
// (a self/recursive or same-class call) binds directly to that class's
|
||||
// method node — it never touches the interface node. Roslyn still reports
|
||||
// that site for the interface method AND for every sibling implementation.
|
||||
//
|
||||
// A fan-out anchored only on calls bound to the interface member (mechanism 1)
|
||||
// misses the dominant mass of real-corpus usages, which are mechanism 2. This
|
||||
// pass therefore builds the full implements-family per (interface, method
|
||||
// name) — the interface member plus the same-named method on every type whose
|
||||
// implements/extends chain reaches the interface — and, for every call edge
|
||||
// bound to ANY family member, synthesizes call edges to ALL other members.
|
||||
//
|
||||
// Tier: ast_inferred / ConfidenceTyped (non-speculative, type-keyed) — the
|
||||
// same tier the sibling one-to-many dispatch passes use (MediatR Publish ->
|
||||
// every handler, Spring publishEvent -> every listener), so the cascade rides
|
||||
// in the DEFAULT find_usages / get_callers result. Family membership is
|
||||
// established strictly through the implements/extends chain — never by name
|
||||
// matching alone — so unrelated same-named methods are never linked.
|
||||
|
||||
// csharpIfaceDispatchCap bounds the family size (every interface-member
|
||||
// overload node plus every implementing method node). C# overloads mint one
|
||||
// node each, so a broadly-localised interface — one implementation per locale,
|
||||
// several overloads per class — legitimately runs to ~70+ member nodes
|
||||
// (Humanizer's INumberToWordsConverter.Convert family measures 72) and is
|
||||
// exactly the shape this pass exists to cover, so the cap sits above it with
|
||||
// headroom; a family wider than the cap is dropped whole as noise
|
||||
// (pathological hub interfaces like a monorepo-wide Dispose).
|
||||
const csharpIfaceDispatchCap = 128
|
||||
|
||||
// MetaViaMethodSetInference is the Meta["via"] marker the resolver stamps on
|
||||
// EdgeImplements edges minted by structural method-set inference (as opposed
|
||||
// to a source-declared base list). Hierarchy-walking passes that must follow
|
||||
// only declared subtyping filter on it.
|
||||
const MetaViaMethodSetInference = "method-set-inference"
|
||||
|
||||
// csharpCallSiteKey identifies one attributed call site. Line is part of the
|
||||
// key on purpose: ground truth is line-based, so every call-site line of every
|
||||
// family member must fan out to every other member, not one edge per
|
||||
// (caller, callee) pair.
|
||||
func csharpCallSiteKey(from, to, filePath string, line int) string {
|
||||
return from + "\x00" + to + "\x00" + filePath + "\x00" + strconv.Itoa(line)
|
||||
}
|
||||
|
||||
// ResolveCSharpInterfaceDispatch fans every call bound to a member of a C#
|
||||
// implements-family out to all other members of that family. Returns the
|
||||
// number of fan-out edges landed.
|
||||
func ResolveCSharpInterfaceDispatch(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Subtype adjacency over the resolved type hierarchy: super → subs.
|
||||
// EdgeImplements and EdgeExtends both count — a class reaches an interface
|
||||
// through any chain of base classes / base interfaces (e.g. Afrikaans
|
||||
// extends Genderless which implements INumberToWordsConverter).
|
||||
//
|
||||
// Only SOURCE-DECLARED hierarchy edges qualify. The method-set inference
|
||||
// pass mints EdgeImplements from every type whose bare method names cover
|
||||
// an interface — with a single-method interface like IOrdinalizer.Convert
|
||||
// that "links" every Convert-bearing class in the repo, and a family built
|
||||
// over it would union unrelated hierarchies (NumberToWords converters into
|
||||
// the Ordinalizer family). Those edges carry the inference marker; skip
|
||||
// them. Origin cannot discriminate here: it is stamped/backfilled at
|
||||
// different pipeline stages, so declared and inferred edges converge.
|
||||
// This pass can run BEFORE the resolver has bound base-list targets (the
|
||||
// pipeline settles hierarchy targets across several later passes), so an
|
||||
// `unresolved::Name` target is resolved here by an exact, same-repo,
|
||||
// unique type/interface name lookup — ambiguity means skip, never guess.
|
||||
children := map[string][]string{}
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeImplements, graph.EdgeExtends} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e == nil || e.From == "" || e.To == "" {
|
||||
continue
|
||||
}
|
||||
if e.Meta != nil && e.Meta["via"] == MetaViaMethodSetInference {
|
||||
continue
|
||||
}
|
||||
toID := e.To
|
||||
if graph.IsUnresolvedTarget(toID) {
|
||||
toID = csharpResolveHierarchyTarget(g, e.From, toID)
|
||||
if toID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
children[toID] = append(children[toID], e.From)
|
||||
}
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// implementation/interface type node id → member name → method nodes.
|
||||
// Every overload matters: C# overloads mint one node each (Convert,
|
||||
// Convert_L39, ...) sharing the same Name, and real call sites bind to any
|
||||
// of them — a single-node-per-name projection would silently drop the
|
||||
// overload the corpus actually calls through.
|
||||
membersByType := csharpMemberMethodsAllByType(g)
|
||||
if len(membersByType) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Anchor discovery: every C# interface member method node, via its
|
||||
// EdgeMemberOf owner, grouped by (interface, name) so the interface's own
|
||||
// overload nodes land in ONE family rather than seeding duplicates.
|
||||
type anchorGroup struct {
|
||||
ifaceID string
|
||||
name string
|
||||
repoPrefix string
|
||||
nodeIDs []string
|
||||
}
|
||||
anchorGroups := map[string]*anchorGroup{}
|
||||
var anchorOrder []string
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
if e == nil || graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
m := g.GetNode(e.From)
|
||||
if m == nil || m.Kind != graph.KindMethod || m.Language != "csharp" || !csharpIsIfaceMember(m) {
|
||||
continue
|
||||
}
|
||||
key := e.To + "\x00" + m.Name
|
||||
ag := anchorGroups[key]
|
||||
if ag == nil {
|
||||
ag = &anchorGroup{ifaceID: e.To, name: m.Name, repoPrefix: m.RepoPrefix}
|
||||
anchorGroups[key] = ag
|
||||
anchorOrder = append(anchorOrder, key)
|
||||
}
|
||||
ag.nodeIDs = append(ag.nodeIDs, m.ID)
|
||||
}
|
||||
if len(anchorGroups) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Descendant closure per interface, computed once and shared across that
|
||||
// interface's anchors (one per member name).
|
||||
descCache := map[string][]string{}
|
||||
descendants := func(ifaceID string) []string {
|
||||
if d, ok := descCache[ifaceID]; ok {
|
||||
return d
|
||||
}
|
||||
var out []string
|
||||
visited := map[string]bool{ifaceID: true}
|
||||
queue := append([]string(nil), children[ifaceID]...)
|
||||
for len(queue) > 0 {
|
||||
t := queue[0]
|
||||
queue = queue[1:]
|
||||
if visited[t] {
|
||||
continue
|
||||
}
|
||||
visited[t] = true
|
||||
out = append(out, t)
|
||||
queue = append(queue, children[t]...)
|
||||
}
|
||||
descCache[ifaceID] = out
|
||||
return out
|
||||
}
|
||||
|
||||
// Build families and the member → families index.
|
||||
type family struct {
|
||||
ifaceID string
|
||||
members []string
|
||||
}
|
||||
var families []family
|
||||
famsOfMember := map[string][]int{}
|
||||
for _, key := range anchorOrder {
|
||||
ag := anchorGroups[key]
|
||||
memberIDs := append([]string(nil), ag.nodeIDs...)
|
||||
anchorSet := map[string]bool{}
|
||||
for _, id := range ag.nodeIDs {
|
||||
anchorSet[id] = true
|
||||
}
|
||||
implCount := 0
|
||||
for _, sub := range descendants(ag.ifaceID) {
|
||||
byName := membersByType[sub]
|
||||
if byName == nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range byName[ag.name] {
|
||||
if m == nil || anchorSet[m.ID] {
|
||||
continue
|
||||
}
|
||||
// In-repo only: cross-repo dispatch is CrossRepoResolver's domain.
|
||||
if m.RepoPrefix != ag.repoPrefix {
|
||||
continue
|
||||
}
|
||||
memberIDs = append(memberIDs, m.ID)
|
||||
implCount++
|
||||
}
|
||||
}
|
||||
// A family needs an interface member plus at least one implementation
|
||||
// to cascade; one wider than the cap is dropped whole as noise.
|
||||
if implCount == 0 || len(memberIDs) > csharpIfaceDispatchCap {
|
||||
continue
|
||||
}
|
||||
idx := len(families)
|
||||
families = append(families, family{ifaceID: ag.ifaceID, members: memberIDs})
|
||||
for _, id := range memberIDs {
|
||||
famsOfMember[id] = append(famsOfMember[id], idx)
|
||||
}
|
||||
}
|
||||
if len(families) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Existing resolved call sites, keyed per line, so a fan-out edge never
|
||||
// duplicates a real call at the same site (a caller that already reaches
|
||||
// the member directly, or a prior run of this pass).
|
||||
existing := map[string]bool{}
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
existing[csharpCallSiteKey(e.From, e.To, e.FilePath, e.Line)] = true
|
||||
}
|
||||
|
||||
var batch []*graph.Edge
|
||||
seen := map[string]bool{}
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
// Never re-fan from this pass's own output — real call sites only.
|
||||
if e.Meta != nil && e.Meta[MetaSynthesizedBy] == SynthCSharpIfaceDispatch {
|
||||
continue
|
||||
}
|
||||
fams := famsOfMember[e.To]
|
||||
if len(fams) == 0 {
|
||||
continue
|
||||
}
|
||||
// Tier-gate the SOURCE: a typed or scope-resolved binding (and an
|
||||
// untagged legacy edge, which carries unknown — not low — confidence,
|
||||
// mirroring SuppressRedundantTextMatches) fans from any caller. A
|
||||
// text_matched binding is a name-only guess that can land on a family
|
||||
// member from a completely unrelated same-named method (an
|
||||
// IOrdinalizer.Convert self-call text-matched into the
|
||||
// INumberToWordsConverter family); those fan ONLY when the caller is
|
||||
// itself a member of the same family — the intra-family self/sibling-
|
||||
// call shape the weak tier legitimately carries (overload self-calls
|
||||
// bind text_matched).
|
||||
weakSource := e.Origin == graph.OriginTextMatched
|
||||
var fromFams []int
|
||||
if weakSource {
|
||||
fromFams = famsOfMember[e.From]
|
||||
if len(fromFams) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, fi := range fams {
|
||||
if weakSource && !containsInt(fromFams, fi) {
|
||||
continue
|
||||
}
|
||||
f := families[fi]
|
||||
for _, member := range f.members {
|
||||
if member == e.To {
|
||||
continue
|
||||
}
|
||||
k := csharpCallSiteKey(e.From, member, e.FilePath, e.Line)
|
||||
if existing[k] || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
batch = append(batch, csharpIfaceDispatchEdge(e, member, f.ifaceID, len(f.members)-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ne := range batch {
|
||||
g.AddEdge(ne)
|
||||
}
|
||||
return len(batch)
|
||||
}
|
||||
|
||||
// csharpIsIfaceMember reports whether n is a bodyless (or default) interface
|
||||
// member declaration emitted by the C# extractor.
|
||||
func csharpIsIfaceMember(n *graph.Node) bool {
|
||||
if n == nil || n.Meta == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := n.Meta["iface_member"].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// csharpIfaceDispatchEdge builds one fan-out call edge from the call site e to
|
||||
// another family member, at the non-speculative ast_inferred tier so it
|
||||
// survives the default speculative filter on find_usages / get_callers. The
|
||||
// fan-out width rides in candidate_count for auditing; only one implementation
|
||||
// runs at a site, but Roslyn reports the reference on every family member and
|
||||
// this pass mirrors that.
|
||||
func csharpIfaceDispatchEdge(e *graph.Edge, to, ifaceTypeID string, fanout int) *graph.Edge {
|
||||
ne := &graph.Edge{
|
||||
From: e.From, To: to, Kind: graph.EdgeCalls,
|
||||
FilePath: e.FilePath, Line: e.Line,
|
||||
Origin: graph.OriginASTInferred,
|
||||
Confidence: ConfidenceTyped,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped),
|
||||
Meta: map[string]any{
|
||||
"via": "csharp-iface-dispatch",
|
||||
"iface_type": ifaceTypeID,
|
||||
"candidate_count": fanout,
|
||||
},
|
||||
}
|
||||
StampSynthesized(ne, SynthCSharpIfaceDispatch)
|
||||
return ne
|
||||
}
|
||||
|
||||
// csharpMemberMethodsAllByType is the overload-preserving variant of
|
||||
// memberMethodNodesByType: type node id → member name → EVERY method node with
|
||||
// that name (C# overloads mint one node per declaration, so a name maps to
|
||||
// several nodes). Uses the backend's MemberMethodsByType projection when
|
||||
// available, else walks EdgeMemberOf.
|
||||
func csharpMemberMethodsAllByType(g graph.Store) map[string]map[string][]*graph.Node {
|
||||
if cap, ok := g.(graph.MemberMethodsByType); ok {
|
||||
raw := cap.MemberMethodsByType()
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]map[string][]*graph.Node, len(raw))
|
||||
for typeID, methods := range raw {
|
||||
set := make(map[string][]*graph.Node, len(methods))
|
||||
for _, m := range methods {
|
||||
set[m.Name] = append(set[m.Name], &graph.Node{
|
||||
ID: m.MethodID,
|
||||
Kind: graph.KindMethod,
|
||||
Name: m.Name,
|
||||
FilePath: m.FilePath,
|
||||
StartLine: m.StartLine,
|
||||
RepoPrefix: m.RepoPrefix,
|
||||
})
|
||||
}
|
||||
out[typeID] = set
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := map[string]map[string][]*graph.Node{}
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
method := g.GetNode(e.From)
|
||||
if method == nil || method.Kind != graph.KindMethod {
|
||||
continue
|
||||
}
|
||||
set := out[e.To]
|
||||
if set == nil {
|
||||
set = make(map[string][]*graph.Node)
|
||||
out[e.To] = set
|
||||
}
|
||||
set[method.Name] = append(set[method.Name], method)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsInt reports whether xs contains v. Family lists are tiny (a method
|
||||
// belongs to one or two families), so a linear scan beats a map.
|
||||
func containsInt(xs []int, v int) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// csharpResolveHierarchyTarget binds an `unresolved::Name` base-list target to
|
||||
// the unique same-repo C# type/interface node with that exact name, or ""
|
||||
// when the caller is not C#, the name is unknown, or the name is ambiguous —
|
||||
// a wrong hierarchy link unions unrelated families, so no guess is ever made.
|
||||
func csharpResolveHierarchyTarget(g graph.Store, fromID, unresolvedTo string) string {
|
||||
name := graph.UnresolvedName(unresolvedTo)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
from := g.GetNode(fromID)
|
||||
if from == nil || from.Language != "csharp" {
|
||||
return ""
|
||||
}
|
||||
var cand *graph.Node
|
||||
for _, n := range g.FindNodesByNameInRepo(name, from.RepoPrefix) {
|
||||
if n == nil || (n.Kind != graph.KindType && n.Kind != graph.KindInterface) {
|
||||
continue
|
||||
}
|
||||
if n.Language != "csharp" {
|
||||
continue
|
||||
}
|
||||
if cand != nil {
|
||||
return "" // ambiguous — do not guess
|
||||
}
|
||||
cand = n
|
||||
}
|
||||
if cand == nil {
|
||||
return ""
|
||||
}
|
||||
return cand.ID
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// isIfaceDispatchEdge reports whether e is a fan-out edge minted by the C#
|
||||
// interface-dispatch synthesizer.
|
||||
func isIfaceDispatchEdge(e *graph.Edge) bool {
|
||||
return e != nil && e.Kind == graph.EdgeCalls && e.Meta != nil &&
|
||||
e.Meta[MetaSynthesizedBy] == SynthCSharpIfaceDispatch
|
||||
}
|
||||
|
||||
// buildCSharpResolverGraph extracts each C# fixture with the real extractor and
|
||||
// loads its nodes/edges into a fresh graph — the same unresolved shape a live
|
||||
// index produces, ready for New(g).ResolveAll().
|
||||
func buildCSharpResolverGraph(t *testing.T, files map[string]string) graph.Store {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
e := languages.NewCSharpExtractor()
|
||||
for path, src := range files {
|
||||
r, err := e.Extract(path, []byte(src))
|
||||
require.NoError(t, err, "csharp extract %s", path)
|
||||
for _, n := range r.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, ed := range r.Edges {
|
||||
g.AddEdge(ed)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestResolveCSharpInterfaceDispatch_EndToEnd drives the full path: the
|
||||
// extractor emits the interface member + the through-interface call, ResolveAll
|
||||
// binds the call to the interface member, and the synthesizer fans it out to
|
||||
// both concrete implementations at the speculative tier.
|
||||
func TestResolveCSharpInterfaceDispatch_EndToEnd(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"IConverter.cs": `namespace App {
|
||||
public interface IConverter {
|
||||
string Convert(int n);
|
||||
}
|
||||
}`,
|
||||
"English.cs": `namespace App {
|
||||
public class EnglishConverter : IConverter {
|
||||
public string Convert(int n) { return "en"; }
|
||||
}
|
||||
}`,
|
||||
"Ukrainian.cs": `namespace App {
|
||||
public class UkrainianConverter : IConverter {
|
||||
public string Convert(int n) { return "uk"; }
|
||||
}
|
||||
}`,
|
||||
"Runner.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run(IConverter c) {
|
||||
IConverter conv = c;
|
||||
conv.Convert(1);
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
callerID := "Runner.cs::Runner.Run"
|
||||
ifaceMember := "IConverter.cs::IConverter.Convert"
|
||||
|
||||
// (a) the through-interface call binds to the interface member.
|
||||
require.Contains(t, callTargetsFrom(g, callerID), ifaceMember,
|
||||
"through-interface call should bind to the interface member node")
|
||||
|
||||
// (b) fan-out to both implementations at the ast_inferred tier.
|
||||
n := ResolveCSharpInterfaceDispatch(g)
|
||||
require.Equal(t, 2, n, "one fan-out edge per implementation")
|
||||
|
||||
fanout := map[string]*graph.Edge{}
|
||||
for _, e := range g.GetOutEdges(callerID) {
|
||||
if isIfaceDispatchEdge(e) {
|
||||
fanout[e.To] = e
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"English.cs::EnglishConverter.Convert", "Ukrainian.cs::UkrainianConverter.Convert"} {
|
||||
e := fanout[id]
|
||||
require.NotNil(t, e, "expected fan-out edge to %s", id)
|
||||
assert.Equal(t, graph.OriginASTInferred, e.Origin)
|
||||
assert.False(t, e.IsSpeculative(),
|
||||
"fan-out must NOT be speculative or the default find_usages filter drops it")
|
||||
assert.Equal(t, SynthCSharpIfaceDispatch, e.Meta[MetaSynthesizedBy])
|
||||
}
|
||||
|
||||
// (c) find_usages-equivalent: the through-interface call site surfaces as an
|
||||
// in-edge on each concrete implementation AND survives the default
|
||||
// speculative filter, so find_usages(<Impl>.Convert) returns it.
|
||||
for _, id := range []string{"English.cs::EnglishConverter.Convert", "Ukrainian.cs::UkrainianConverter.Convert"} {
|
||||
found := false
|
||||
for _, e := range g.GetInEdges(id) {
|
||||
if e.From == callerID && e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "through-interface usage of %s must be a default-visible in-edge", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveCSharpInterfaceDispatch_FamilyCascade drives the sibling
|
||||
// self-call mechanism end-to-end: subclasses reach the interface through an
|
||||
// abstract base class (extends -> implements), and a subclass's own recursive
|
||||
// Convert call — which binds to its OWN method node, never the interface —
|
||||
// must still surface as a usage of the interface member and of every sibling
|
||||
// implementation, mirroring the reference resolver's family union.
|
||||
func TestResolveCSharpInterfaceDispatch_FamilyCascade(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"IConverter.cs": `namespace App {
|
||||
public interface IConverter {
|
||||
string Convert(long n);
|
||||
}
|
||||
}`,
|
||||
"BaseConverter.cs": `namespace App {
|
||||
public abstract class BaseConverter : IConverter {
|
||||
public abstract string Convert(long n);
|
||||
}
|
||||
}`,
|
||||
"Afrikaans.cs": `namespace App {
|
||||
public class AfrikaansConverter : BaseConverter {
|
||||
public override string Convert(long n) {
|
||||
if (n < 0) {
|
||||
return "minus " + Convert(-n);
|
||||
}
|
||||
return "af";
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"Serbian.cs": `namespace App {
|
||||
public class SerbianConverter : BaseConverter {
|
||||
public override string Convert(long n) { return "sr"; }
|
||||
}
|
||||
}`,
|
||||
"Danish.cs": `namespace App {
|
||||
public class DanishConverter : BaseConverter {
|
||||
public override string Convert(long n) { return Convert(n, false); }
|
||||
public string Convert(long n, bool suffix) {
|
||||
if (n < 0) {
|
||||
return "minus " + Convert(-n, suffix);
|
||||
}
|
||||
return "da";
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"Unrelated.cs": `namespace App {
|
||||
public class Codec {
|
||||
public string Convert(long n) { return "x"; }
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
selfCaller := "Afrikaans.cs::AfrikaansConverter.Convert"
|
||||
|
||||
// Precondition: the recursive call binds to the caller's own method node
|
||||
// (same-class resolution) — NOT the interface — which is exactly why an
|
||||
// interface-anchored fan-out misses it.
|
||||
require.Contains(t, callTargetsFrom(g, selfCaller), selfCaller,
|
||||
"the recursive Convert call should bind to the class's own method")
|
||||
|
||||
n := ResolveCSharpInterfaceDispatch(g)
|
||||
require.Greater(t, n, 0, "the family cascade must land fan-out edges")
|
||||
|
||||
// The self-call site must surface on the sibling implementation and on the
|
||||
// interface member — the find_usages-equivalent in-edge walk, default tier.
|
||||
for _, id := range []string{"Serbian.cs::SerbianConverter.Convert", "IConverter.cs::IConverter.Convert"} {
|
||||
found := false
|
||||
for _, e := range g.GetInEdges(id) {
|
||||
if e.From == selfCaller && e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "the sibling self-call must be a default-visible usage of %s", id)
|
||||
}
|
||||
|
||||
// Overload split: C# overloads mint one node per declaration (Convert,
|
||||
// Convert_L<line>, ...) and the recursive call inside Danish's second
|
||||
// overload binds to one of Danish's own nodes — the cascade must still
|
||||
// carry it to the sibling implementation, whichever overload node it
|
||||
// landed on.
|
||||
danishAttributed := false
|
||||
for _, e := range g.GetInEdges("Serbian.cs::SerbianConverter.Convert") {
|
||||
if strings.HasPrefix(e.From, "Danish.cs::DanishConverter.Convert") &&
|
||||
e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
|
||||
danishAttributed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, danishAttributed,
|
||||
"a self-call bound to an overload node must still cascade to sibling implementations")
|
||||
|
||||
// Precision: the unrelated same-named method is outside the
|
||||
// implements-family and must receive nothing.
|
||||
for _, e := range g.GetInEdges("Unrelated.cs::Codec.Convert") {
|
||||
assert.NotEqual(t, SynthCSharpIfaceDispatch, edgeMetaValue(e, MetaSynthesizedBy),
|
||||
"a same-named method with no implements/extends link must not join the family")
|
||||
}
|
||||
}
|
||||
|
||||
// edgeMetaValue reads one Meta key off an edge, tolerating nil Meta.
|
||||
func edgeMetaValue(e *graph.Edge, key string) any {
|
||||
if e == nil || e.Meta == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Meta[key]
|
||||
}
|
||||
|
||||
// TestResolveCSharpInterfaceDispatch_UnresolvedHierarchy models the pipeline
|
||||
// state the synthesizer actually runs in: call edges already bound, base-list
|
||||
// targets still `unresolved::Name` (hierarchy settles in later passes). The
|
||||
// pass must bind those names itself — exact, same-repo, unique — and cascade.
|
||||
func TestResolveCSharpInterfaceDispatch_UnresolvedHierarchy(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"IConverter.cs": `namespace App {
|
||||
public interface IConverter {
|
||||
string Convert(long n);
|
||||
}
|
||||
}`,
|
||||
"BaseConverter.cs": `namespace App {
|
||||
public abstract class BaseConverter : IConverter {
|
||||
public abstract string Convert(long n);
|
||||
}
|
||||
}`,
|
||||
"Afrikaans.cs": `namespace App {
|
||||
public class AfrikaansConverter : BaseConverter {
|
||||
public override string Convert(long n) { return "af"; }
|
||||
}
|
||||
}`,
|
||||
"Serbian.cs": `namespace App {
|
||||
public class SerbianConverter : BaseConverter {
|
||||
public override string Convert(long n) { return "sr"; }
|
||||
}
|
||||
}`,
|
||||
})
|
||||
// NO ResolveAll: base-list edges keep their unresolved:: targets. Bind one
|
||||
// call edge by hand — the state the resolver leaves calls in by synth time.
|
||||
selfCaller := "Afrikaans.cs::AfrikaansConverter.Convert"
|
||||
g.AddEdge(&graph.Edge{From: selfCaller, To: selfCaller, Kind: graph.EdgeCalls,
|
||||
FilePath: "Afrikaans.cs", Line: 3, Origin: graph.OriginASTResolved})
|
||||
|
||||
n := ResolveCSharpInterfaceDispatch(g)
|
||||
require.Greater(t, n, 0, "the cascade must work over unresolved base-list targets")
|
||||
|
||||
for _, id := range []string{"Serbian.cs::SerbianConverter.Convert", "IConverter.cs::IConverter.Convert"} {
|
||||
found := false
|
||||
for _, e := range g.GetInEdges(id) {
|
||||
if e.From == selfCaller && isIfaceDispatchEdge(e) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "self-call must cascade to %s through unresolved hierarchy names", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveCSharpInterfaceDispatch_WeakSourceGate pins the precision rule
|
||||
// for text_matched sources: a name-only binding that lands on a family member
|
||||
// from an UNRELATED same-named method (a different interface's Convert) must
|
||||
// not fan into the family, while an intra-family text_matched self-call (the
|
||||
// shape overload self-calls bind at) must.
|
||||
func TestResolveCSharpInterfaceDispatch_WeakSourceGate(t *testing.T) {
|
||||
g := graph.New()
|
||||
addType := func(file, name string) string {
|
||||
id := file + "::" + name
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindType, Name: name, FilePath: file, Language: "csharp"})
|
||||
return id
|
||||
}
|
||||
addMethod := func(file, typ, name string, iface bool) string {
|
||||
id := file + "::" + typ + "." + name
|
||||
meta := map[string]any{"receiver": typ}
|
||||
if iface {
|
||||
meta["iface_member"] = true
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: name, FilePath: file, Language: "csharp", Meta: meta})
|
||||
g.AddEdge(&graph.Edge{From: id, To: file + "::" + typ, Kind: graph.EdgeMemberOf, FilePath: file})
|
||||
return id
|
||||
}
|
||||
|
||||
// Family 1: IConv.Do <- ConvA.Do, ConvB.Do
|
||||
iconv := addType("IConv.cs", "IConv")
|
||||
iconvDo := addMethod("IConv.cs", "IConv", "Do", true)
|
||||
_ = iconvDo
|
||||
convA := addType("A.cs", "ConvA")
|
||||
aDo := addMethod("A.cs", "ConvA", "Do", false)
|
||||
convB := addType("B.cs", "ConvB")
|
||||
bDo := addMethod("B.cs", "ConvB", "Do", false)
|
||||
g.AddEdge(&graph.Edge{From: convA, To: iconv, Kind: graph.EdgeImplements, FilePath: "A.cs", Origin: graph.OriginASTInferred})
|
||||
g.AddEdge(&graph.Edge{From: convB, To: iconv, Kind: graph.EdgeImplements, FilePath: "B.cs", Origin: graph.OriginASTInferred})
|
||||
|
||||
// Unrelated type with a same-named method, outside any family hierarchy.
|
||||
ord := addType("Ord.cs", "Ordinalizer")
|
||||
ordDo := addMethod("Ord.cs", "Ordinalizer", "Do", false)
|
||||
|
||||
// Cross-family pollution: the Ordinalizer's own Do call was text-matched
|
||||
// onto a family member. It must NOT fan into the family.
|
||||
g.AddEdge(&graph.Edge{From: ordDo, To: aDo, Kind: graph.EdgeCalls, FilePath: "Ord.cs", Line: 7,
|
||||
Origin: graph.OriginTextMatched})
|
||||
// Intra-family text_matched self-call (the overload self-call shape): the
|
||||
// caller is a family member, so it fans to the sibling and the interface.
|
||||
g.AddEdge(&graph.Edge{From: aDo, To: aDo, Kind: graph.EdgeCalls, FilePath: "A.cs", Line: 9,
|
||||
Origin: graph.OriginTextMatched})
|
||||
|
||||
// Method-set inference pollution: an inference-marked implements edge
|
||||
// (the shape the structural inference pass mints — every Convert-bearing
|
||||
// class "implements" a single-method interface) must NOT pull the
|
||||
// unrelated type into the family.
|
||||
g.AddEdge(&graph.Edge{From: ord, To: iconv, Kind: graph.EdgeImplements, FilePath: "Ord.cs",
|
||||
Meta: map[string]any{"via": MetaViaMethodSetInference}})
|
||||
|
||||
n := ResolveCSharpInterfaceDispatch(g)
|
||||
require.Equal(t, 2, n, "only the intra-family self-call fans (to sibling + interface member)")
|
||||
|
||||
for _, e := range g.GetInEdges(ordDo) {
|
||||
assert.False(t, isIfaceDispatchEdge(e),
|
||||
"a method-set-inferred implements edge must not admit a type into the family")
|
||||
}
|
||||
|
||||
var fromCallers []string
|
||||
for _, e := range g.GetInEdges(bDo) {
|
||||
if isIfaceDispatchEdge(e) {
|
||||
fromCallers = append(fromCallers, e.From)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{aDo}, fromCallers,
|
||||
"the sibling receives the family self-call but never the unrelated text-matched caller")
|
||||
}
|
||||
|
||||
// TestResolveCSharpInterfaceDispatch_FanoutTierAndCap uses a hand-built graph to
|
||||
// pin the fan-out shape, provenance/tier, dedup, and the fan-out cap.
|
||||
func TestResolveCSharpInterfaceDispatch_FanoutTierAndCap(t *testing.T) {
|
||||
newGraph := func(nImpls int) graph.Store {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "I.cs::IC", Kind: graph.KindInterface, Name: "IC", FilePath: "I.cs", Language: "csharp"})
|
||||
g.AddNode(&graph.Node{ID: "I.cs::IC.Do", Kind: graph.KindMethod, Name: "Do", FilePath: "I.cs", Language: "csharp",
|
||||
Meta: map[string]any{"receiver": "IC", "iface_member": true}})
|
||||
g.AddEdge(&graph.Edge{From: "I.cs::IC.Do", To: "I.cs::IC", Kind: graph.EdgeMemberOf, FilePath: "I.cs"})
|
||||
for i := 0; i < nImpls; i++ {
|
||||
typ := fmt.Sprintf("Impl%d", i)
|
||||
file := typ + ".cs"
|
||||
g.AddNode(&graph.Node{ID: file + "::" + typ, Kind: graph.KindType, Name: typ, FilePath: file, Language: "csharp"})
|
||||
g.AddNode(&graph.Node{ID: file + "::" + typ + ".Do", Kind: graph.KindMethod, Name: "Do", FilePath: file, Language: "csharp",
|
||||
Meta: map[string]any{"receiver": typ}})
|
||||
g.AddEdge(&graph.Edge{From: file + "::" + typ + ".Do", To: file + "::" + typ, Kind: graph.EdgeMemberOf, FilePath: file})
|
||||
g.AddEdge(&graph.Edge{From: file + "::" + typ, To: "I.cs::IC", Kind: graph.EdgeImplements, FilePath: file, Origin: graph.OriginASTInferred})
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: "C.cs::App.Run", Kind: graph.KindMethod, Name: "Run", FilePath: "C.cs", Language: "csharp",
|
||||
Meta: map[string]any{"receiver": "App"}})
|
||||
g.AddEdge(&graph.Edge{From: "C.cs::App.Run", To: "I.cs::IC.Do", Kind: graph.EdgeCalls, FilePath: "C.cs", Line: 3})
|
||||
return g
|
||||
}
|
||||
|
||||
t.Run("fan-out tier + dedup", func(t *testing.T) {
|
||||
g := newGraph(2)
|
||||
require.Equal(t, 2, ResolveCSharpInterfaceDispatch(g))
|
||||
|
||||
fanout := map[string]*graph.Edge{}
|
||||
for _, e := range g.GetOutEdges("C.cs::App.Run") {
|
||||
if isIfaceDispatchEdge(e) {
|
||||
fanout[e.To] = e
|
||||
}
|
||||
}
|
||||
require.Len(t, fanout, 2)
|
||||
for _, id := range []string{"Impl0.cs::Impl0.Do", "Impl1.cs::Impl1.Do"} {
|
||||
e := fanout[id]
|
||||
require.NotNil(t, e, id)
|
||||
assert.Equal(t, graph.OriginASTInferred, e.Origin)
|
||||
assert.False(t, e.IsSpeculative())
|
||||
assert.Equal(t, SynthCSharpIfaceDispatch, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, "C.cs", e.FilePath)
|
||||
assert.Equal(t, 3, e.Line)
|
||||
}
|
||||
|
||||
// Idempotent: a second run must not duplicate fan-out edges.
|
||||
ResolveCSharpInterfaceDispatch(g)
|
||||
got := 0
|
||||
for _, e := range g.GetOutEdges("C.cs::App.Run") {
|
||||
if isIfaceDispatchEdge(e) {
|
||||
got++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, got, "re-run must not duplicate fan-out edges")
|
||||
})
|
||||
|
||||
t.Run("fan-out cap drops noise", func(t *testing.T) {
|
||||
g := newGraph(csharpIfaceDispatchCap + 1)
|
||||
assert.Equal(t, 0, ResolveCSharpInterfaceDispatch(g),
|
||||
"a fan-out wider than the cap is dropped as noise")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Receiver-type gating for C# member-call attribution.
|
||||
//
|
||||
// The extractor stamps Meta["receiver_type"] on a member-call candidate when
|
||||
// the local type environment knows the receiver. When such a call cannot bind
|
||||
// to a member of that exact type (nor of a base/interface it derives from) and
|
||||
// a weak resolver tier falls back to a same-named member on an *unrelated*
|
||||
// type, the attribution is wrong: an edge that names its receiver type must not
|
||||
// attach to a same-named member of an unrelated type. This pass demotes those
|
||||
// edges to the speculative tier so they drop out of every default query and
|
||||
// min_tier filter — while a genuine inherited / interface-dispatch call (where
|
||||
// the target's receiver is a super-type of the receiver_type) and a valid
|
||||
// extension-method binding are both preserved, so the gate adds no false
|
||||
// negatives.
|
||||
//
|
||||
// The demotion re-writes the edge (remove + re-add as speculative) rather than
|
||||
// mutating it in place: an in-place Origin/Meta change is a no-op on the
|
||||
// non-memory backends, which return decoded copies from EdgesByKind. RemoveEdge
|
||||
// is keyed only by (from,to,kind), so every edge in an affected group is
|
||||
// snapshotted and re-added, preserving legitimately-typed sibling calls.
|
||||
|
||||
// demoteCSharpMisattributedMemberCalls demotes weak-tier C# member calls whose
|
||||
// bound target belongs to a type unrelated to the edge's receiver_type. Returns
|
||||
// the number of edges demoted.
|
||||
func demoteCSharpMisattributedMemberCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
// C# type/interface name → node ids, and each type's super-type / interface
|
||||
// (up) edges, for hierarchy-relatedness checks by name.
|
||||
nameToTypeIDs := map[string][]string{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindType, graph.KindInterface) {
|
||||
if n == nil || n.Language != "csharp" || n.Name == "" {
|
||||
continue
|
||||
}
|
||||
nameToTypeIDs[n.Name] = append(nameToTypeIDs[n.Name], n.ID)
|
||||
}
|
||||
if len(nameToTypeIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
up := map[string][]string{}
|
||||
// incompleteHier[name] marks a C# type that declares a base or interface the
|
||||
// index could not resolve (an external assembly, a generic type parameter) —
|
||||
// its hierarchy is only partially known, so an "unrelated to the target"
|
||||
// verdict for a receiver of that type is unreliable.
|
||||
incompleteHier := map[string]bool{}
|
||||
for _, k := range []graph.EdgeKind{graph.EdgeExtends, graph.EdgeImplements} {
|
||||
for e := range g.EdgesByKind(k) {
|
||||
if e == nil || e.From == "" {
|
||||
continue
|
||||
}
|
||||
if graph.IsUnresolvedTarget(e.To) {
|
||||
if from := g.GetNode(e.From); from != nil && from.Language == "csharp" && from.Name != "" {
|
||||
incompleteHier[from.Name] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
up[e.From] = append(up[e.From], e.To)
|
||||
}
|
||||
}
|
||||
|
||||
groupKey := func(from, to string, kind graph.EdgeKind) string {
|
||||
return from + "\x00" + to + "\x00" + string(kind)
|
||||
}
|
||||
edgeKey := func(e *graph.Edge) string {
|
||||
return e.From + "\x00" + e.To + "\x00" + string(e.Kind) + "\x00" + e.FilePath + "\x00" + strconv.Itoa(e.Line)
|
||||
}
|
||||
|
||||
// Pass 1: which edges to demote, and which (from,to,kind) groups they touch.
|
||||
demote := map[string]bool{}
|
||||
affected := map[string]bool{}
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if !csharpShouldDemote(g, e, nameToTypeIDs, up, incompleteHier) {
|
||||
continue
|
||||
}
|
||||
demote[edgeKey(e)] = true
|
||||
affected[groupKey(e.From, e.To, e.Kind)] = true
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Pass 2: snapshot every edge in an affected group (siblings included) so
|
||||
// the coarse RemoveEdge can be reversed precisely.
|
||||
groups := map[string][]*graph.Edge{}
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
gk := groupKey(e.From, e.To, e.Kind)
|
||||
if affected[gk] {
|
||||
groups[gk] = append(groups[gk], e)
|
||||
}
|
||||
}
|
||||
|
||||
demoted := 0
|
||||
for _, edges := range groups {
|
||||
if len(edges) == 0 {
|
||||
continue
|
||||
}
|
||||
f := edges[0]
|
||||
g.RemoveEdge(f.From, f.To, f.Kind)
|
||||
for _, e := range edges {
|
||||
if demote[edgeKey(e)] {
|
||||
e.Origin = graph.OriginSpeculative
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta[graph.MetaSpeculative] = true
|
||||
e.Meta["demoted"] = "receiver_type_mismatch"
|
||||
demoted++
|
||||
}
|
||||
g.AddEdge(e)
|
||||
}
|
||||
}
|
||||
return demoted
|
||||
}
|
||||
|
||||
// csharpShouldDemote reports whether a resolved C# member-call edge is a
|
||||
// same-named-unrelated-type misattribution that should be demoted.
|
||||
func csharpShouldDemote(g graph.Store, e *graph.Edge, nameToTypeIDs, up map[string][]string, incompleteHier map[string]bool) bool {
|
||||
if e == nil || e.Meta == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) {
|
||||
return false
|
||||
}
|
||||
rt, _ := e.Meta["receiver_type"].(string)
|
||||
if rt == "" {
|
||||
return false
|
||||
}
|
||||
// A valid extension binding names the extension's static host class as the
|
||||
// target receiver, which is by definition unrelated to the receiver it
|
||||
// extends — never demote those.
|
||||
if res, _ := e.Meta["resolution"].(string); res == "extension_method" {
|
||||
return false
|
||||
}
|
||||
// Only the weak tiers are gated; never demote ast_resolved / lsp evidence.
|
||||
// An empty Origin resolves to its confidence-derived tier.
|
||||
eff := e.Origin
|
||||
if eff == "" {
|
||||
eff = graph.DefaultOriginFor(e.Kind, e.Confidence, "")
|
||||
}
|
||||
if graph.OriginRank(eff) > graph.OriginRank(graph.OriginASTInferred) {
|
||||
return false
|
||||
}
|
||||
caller := g.GetNode(e.From)
|
||||
if caller == nil || caller.Language != "csharp" {
|
||||
return false
|
||||
}
|
||||
target := g.GetNode(e.To)
|
||||
if target == nil || target.Kind != graph.KindMethod || target.Language != "csharp" || target.Meta == nil {
|
||||
return false
|
||||
}
|
||||
// An extension target reached without the extension_method resolution tag
|
||||
// (e.g. a locality pick) is still a legitimate extension — keep it.
|
||||
if isCSharpExtension(target) {
|
||||
return false
|
||||
}
|
||||
tr, _ := target.Meta["receiver"].(string)
|
||||
if tr == "" || tr == rt {
|
||||
return false
|
||||
}
|
||||
// Only demote when both endpoints are known indexed types — otherwise we
|
||||
// cannot establish that the mismatch is a genuinely unrelated-type
|
||||
// misattribution, and keeping the edge avoids a false negative.
|
||||
if len(nameToTypeIDs[rt]) == 0 || len(nameToTypeIDs[tr]) == 0 {
|
||||
return false
|
||||
}
|
||||
// A receiver whose own hierarchy is incompletely indexed may reach the
|
||||
// target through the unindexed base/interface, so the "unrelated" verdict is
|
||||
// unreliable — keep rather than demote a possibly-legitimate polymorphic
|
||||
// call. This is the same conservatism as the both-endpoints-known guard
|
||||
// above, extended to hierarchy completeness.
|
||||
if incompleteHier[rt] {
|
||||
return false
|
||||
}
|
||||
// A related receiver (the target lives on a base type / interface the
|
||||
// receiver_type derives from) is a legitimate polymorphic call — keep.
|
||||
return !csharpTypesRelated(nameToTypeIDs, up, rt, tr)
|
||||
}
|
||||
|
||||
// csharpTypesRelated reports whether type names a and b are related through the
|
||||
// C# type hierarchy in either direction (one derives from / implements the
|
||||
// other, transitively).
|
||||
func csharpTypesRelated(nameToTypeIDs, up map[string][]string, a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return csharpNameReaches(nameToTypeIDs, up, a, b) || csharpNameReaches(nameToTypeIDs, up, b, a)
|
||||
}
|
||||
|
||||
// csharpNameReaches reports whether any type named `from` reaches any type named
|
||||
// `to` by following super-type / interface (up) edges transitively.
|
||||
func csharpNameReaches(nameToTypeIDs, up map[string][]string, from, to string) bool {
|
||||
targets := map[string]bool{}
|
||||
for _, id := range nameToTypeIDs[to] {
|
||||
targets[id] = true
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return false
|
||||
}
|
||||
visited := map[string]bool{}
|
||||
queue := append([]string{}, nameToTypeIDs[from]...)
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
if visited[cur] {
|
||||
continue
|
||||
}
|
||||
visited[cur] = true
|
||||
for _, p := range up[cur] {
|
||||
if targets[p] {
|
||||
return true
|
||||
}
|
||||
if !visited[p] {
|
||||
queue = append(queue, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func findCallEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.Kind == graph.EdgeCalls && e.To == to {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestReceiverGate_DemotesUnrelatedAttribution: a `g.Convert()` on a Gadget
|
||||
// (which has no Convert) locality-falls-back to an unrelated Ukrainian.Convert;
|
||||
// the receiver-type gate demotes that misattribution to the speculative tier so
|
||||
// find_usages on Ukrainian.Convert no longer returns the Gadget site.
|
||||
func TestReceiverGate_DemotesUnrelatedAttribution(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Widget.cs": `namespace App {
|
||||
public class Gadget {}
|
||||
public class Widget {
|
||||
public void DoThing() {
|
||||
Gadget g = new Gadget();
|
||||
g.Convert();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"Ukr.cs": `namespace App {
|
||||
public class Ukrainian {
|
||||
public string Convert() { return "uk"; }
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
edge := findCallEdge(g, "Widget.cs::Widget.DoThing", "Ukr.cs::Ukrainian.Convert")
|
||||
require.NotNil(t, edge, "locality fallback should have bound the misattribution")
|
||||
require.Equal(t, "Gadget", edge.Meta["receiver_type"])
|
||||
require.False(t, edge.IsSpeculative(), "edge is visible before the gate")
|
||||
|
||||
n := demoteCSharpMisattributedMemberCalls(g)
|
||||
require.Equal(t, 1, n)
|
||||
// Re-fetch: the demotion removes and re-adds the edge, so query the graph
|
||||
// for its current state rather than trusting the pre-demote pointer.
|
||||
edge = findCallEdge(g, "Widget.cs::Widget.DoThing", "Ukr.cs::Ukrainian.Convert")
|
||||
require.NotNil(t, edge)
|
||||
assert.True(t, edge.IsSpeculative(), "unrelated-type attribution must be demoted")
|
||||
assert.Equal(t, graph.OriginSpeculative, edge.Origin)
|
||||
assert.Equal(t, "receiver_type_mismatch", edge.Meta["demoted"])
|
||||
}
|
||||
|
||||
// TestReceiverGate_PreservesValidExtensionBinding: the extension rule binds
|
||||
// `w.Foo()` to a `static Foo(this Widget)` extension whose target receiver is
|
||||
// the static host class (unrelated to Widget). The gate must NOT demote that
|
||||
// valid binding.
|
||||
func TestReceiverGate_PreservesValidExtensionBinding(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Types.cs": `namespace App {
|
||||
public class Widget {}
|
||||
public static class E {
|
||||
public static int Foo(this Widget w) { return 1; }
|
||||
}
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run() {
|
||||
Widget w = new Widget();
|
||||
w.Foo();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
edge := findCallEdge(g, "Caller.cs::Runner.Run", "Types.cs::E.Foo")
|
||||
require.NotNil(t, edge, "w.Foo() should bind to the extension E.Foo")
|
||||
require.Equal(t, "Widget", edge.Meta["receiver_type"])
|
||||
require.Equal(t, "extension_method", edge.Meta["resolution"])
|
||||
|
||||
n := demoteCSharpMisattributedMemberCalls(g)
|
||||
assert.Equal(t, 0, n, "a valid extension binding must not be demoted")
|
||||
edge = findCallEdge(g, "Caller.cs::Runner.Run", "Types.cs::E.Foo")
|
||||
require.NotNil(t, edge)
|
||||
assert.False(t, edge.IsSpeculative(), "extension binding stays visible")
|
||||
}
|
||||
|
||||
// TestReceiverGate_PreservesSiblingCall: when a caller has both a misattributed
|
||||
// and a correctly-typed call to the same target, only the misattribution is
|
||||
// demoted; the coarse RemoveEdge must not drop the legitimate sibling.
|
||||
func TestReceiverGate_PreservesSiblingCall(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Types.cs": `namespace App {
|
||||
public class Gadget {}
|
||||
public class Ukrainian { public string Convert() { return "uk"; } }
|
||||
}`,
|
||||
"Caller.cs": `namespace App {
|
||||
public class Runner {
|
||||
public void Run() {
|
||||
Gadget g = new Gadget();
|
||||
g.Convert();
|
||||
Ukrainian u = new Ukrainian();
|
||||
u.Convert();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
n := demoteCSharpMisattributedMemberCalls(g)
|
||||
require.Equal(t, 1, n, "only the Gadget-receiver misattribution is demoted")
|
||||
|
||||
var visible, speculative int
|
||||
for _, e := range g.GetOutEdges("Caller.cs::Runner.Run") {
|
||||
if e.Kind != graph.EdgeCalls || e.To != "Types.cs::Ukrainian.Convert" {
|
||||
continue
|
||||
}
|
||||
if e.IsSpeculative() {
|
||||
speculative++
|
||||
} else {
|
||||
visible++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, visible, "the correctly-typed u.Convert() call stays visible")
|
||||
assert.Equal(t, 1, speculative, "the Gadget-receiver call is demoted")
|
||||
}
|
||||
|
||||
// TestReceiverGate_PreservesInheritedCall: a call on a subtype receiver bound to
|
||||
// a method declared on its base type is a legitimate inherited call; the gate
|
||||
// must NOT demote it (no false negatives on polymorphism).
|
||||
func TestReceiverGate_PreservesInheritedCall(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Base.cs": `namespace App {
|
||||
public class Base {
|
||||
public string Describe() { return "base"; }
|
||||
}
|
||||
}`,
|
||||
"Derived.cs": `namespace App {
|
||||
public class Derived : Base {}
|
||||
public class User {
|
||||
public void Use() {
|
||||
Derived d = new Derived();
|
||||
d.Describe();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
edge := findCallEdge(g, "Derived.cs::User.Use", "Base.cs::Base.Describe")
|
||||
require.NotNil(t, edge, "inherited call should bind to the base method")
|
||||
require.Equal(t, "Derived", edge.Meta["receiver_type"])
|
||||
|
||||
n := demoteCSharpMisattributedMemberCalls(g)
|
||||
assert.Equal(t, 0, n, "an inherited (related-type) call must not be demoted")
|
||||
assert.False(t, edge.IsSpeculative())
|
||||
}
|
||||
|
||||
// TestReceiverGate_PreservesCallThroughIncompleteHierarchy: a receiver whose
|
||||
// base/interface is defined outside the indexed set (another assembly) has an
|
||||
// unresolved supertype edge, so its hierarchy is only partially known. A call
|
||||
// that locality-binds to a same-named method on an unrelated indexed type must
|
||||
// NOT be demoted — the real target may live on the unindexed supertype. Without
|
||||
// the hierarchy-completeness guard this legitimate polymorphic call is trimmed.
|
||||
func TestReceiverGate_PreservesCallThroughIncompleteHierarchy(t *testing.T) {
|
||||
g := buildCSharpResolverGraph(t, map[string]string{
|
||||
"Shape.cs": `namespace App {
|
||||
public class Shape {
|
||||
public void Draw() {}
|
||||
}
|
||||
}`,
|
||||
"Widget.cs": `namespace App {
|
||||
public class Widget : IExternalDrawable {
|
||||
public void Render() {
|
||||
Widget w = new Widget();
|
||||
w.Draw();
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
edge := findCallEdge(g, "Widget.cs::Widget.Render", "Shape.cs::Shape.Draw")
|
||||
require.NotNil(t, edge, "w.Draw() locality-binds to the only indexed Draw")
|
||||
require.Equal(t, "Widget", edge.Meta["receiver_type"])
|
||||
require.False(t, edge.IsSpeculative())
|
||||
|
||||
n := demoteCSharpMisattributedMemberCalls(g)
|
||||
assert.Equal(t, 0, n, "a call through an incompletely-indexed hierarchy must not be demoted")
|
||||
edge = findCallEdge(g, "Widget.cs::Widget.Render", "Shape.cs::Shape.Draw")
|
||||
require.NotNil(t, edge)
|
||||
assert.False(t, edge.IsSpeculative(),
|
||||
"a possibly-legitimate polymorphic call through an unindexed supertype stays visible")
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// bindDataflowCalleeRefs lifts the callee side of dataflow edges
|
||||
// (arg_of / value_flow) from an `unresolved::` placeholder onto the real node
|
||||
// the callee denotes.
|
||||
//
|
||||
// The main resolver's resolveEdge already binds `calls` edges to these
|
||||
// callees, but it keys the candidate lookup off the edge's From node's repo —
|
||||
// and a dataflow edge's From is an `unresolved::` argument placeholder with no
|
||||
// node, so the lookup is scoped to the empty repo, matches nothing in a
|
||||
// multi-repo graph, and the callee never lifts. materializeDataflowParams
|
||||
// (which refines a resolved callee to its param node) then skips the edge
|
||||
// because its target is still an `unresolved::` stub. The result was ~half of
|
||||
// all arg_of edges left dangling placeholder→placeholder even when the callee
|
||||
// was defined in the same file.
|
||||
//
|
||||
// This pass closes that gap cheaply and without touching the hot per-edge
|
||||
// resolver, via three call-site-local / index lookups (O(1) per edge):
|
||||
//
|
||||
// - bare `unresolved::<name>` callee → the sole SAME-FILE function/method of
|
||||
// that name, else the sole SAME-PACKAGE function (Go package function names
|
||||
// are unique, so a same-dir function match is unambiguous). Matched by
|
||||
// FilePath / dir equality, so no repo-prefix logic — identical behaviour in
|
||||
// bare and prefixed graphs.
|
||||
// - `unresolved::*.<method>` callee → the method the call resolver already
|
||||
// bound for a `calls` / `references` edge at the SAME call site (file+line),
|
||||
// reusing the receiver-type work resolveMethodCall already did there.
|
||||
//
|
||||
// Ordering (see runFileAttributionPassesLocked): after bindBareNameScopeRefs
|
||||
// (a same-scope local/param wins over a same-file function of the same name)
|
||||
// and before attributeGoBuiltins (a bare `append`/`len` argument with no
|
||||
// definition falls through to builtin attribution) and before
|
||||
// materializeDataflowParams (which then refines a resolved function/method
|
||||
// callee to its param node).
|
||||
func (r *Resolver) bindDataflowCalleeRefs() {
|
||||
idx := newCalleeIndex()
|
||||
for _, k := range []graph.NodeKind{graph.KindFunction, graph.KindMethod} {
|
||||
for n := range r.graph.NodesByKind(k) {
|
||||
if n == nil || n.Name == "" || n.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
indexName(idx.byFile, n.FilePath, n.Name, n.ID)
|
||||
if k == graph.KindFunction {
|
||||
indexName(idx.byDir, filepath.Dir(n.FilePath), n.Name, n.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
|
||||
for e := range r.graph.EdgesByKind(k) {
|
||||
idx.indexCallSite(e)
|
||||
}
|
||||
}
|
||||
if len(idx.byFile) == 0 && len(idx.bySite) == 0 {
|
||||
return
|
||||
}
|
||||
var batch []graph.EdgeReindex
|
||||
for _, ek := range []graph.EdgeKind{graph.EdgeArgOf, graph.EdgeValueFlow} {
|
||||
for e := range r.graph.EdgesByKind(ek) {
|
||||
if old := bindDataflowCalleeEdge(e, idx); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// bindDataflowCalleeRefsForFile is the single-file scope of
|
||||
// bindDataflowCalleeRefs, used on the incremental (fsnotify / edit_file)
|
||||
// re-index path. It builds the same indexes restricted to the file's own
|
||||
// nodes/edges (same-file), the package's functions via r.dirIndex (same-
|
||||
// package — buildDirIndexes runs on the incremental resolve too, so the map is
|
||||
// populated), and the file's own call sites — producing exactly the binds the
|
||||
// whole-graph sweep would for the file, keeping incremental == full-index
|
||||
// convergence, without scanning every function in the graph.
|
||||
func (r *Resolver) bindDataflowCalleeRefsForFile(filePath string) {
|
||||
idx := newCalleeIndex()
|
||||
for _, n := range r.graph.GetFileNodes(filePath) {
|
||||
if n == nil || n.Name == "" || n.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
|
||||
indexName(idx.byFile, n.FilePath, n.Name, n.ID)
|
||||
}
|
||||
}
|
||||
// Same-package functions: r.dirIndex[dir] carries one KindFile node per
|
||||
// file in the directory, so each package file is visited exactly once.
|
||||
dir := filepath.Dir(filePath)
|
||||
for _, fileNode := range r.dirIndex[dir] {
|
||||
for _, n := range r.graph.GetFileNodes(fileNode.FilePath) {
|
||||
if n != nil && n.Kind == graph.KindFunction && n.Name != "" && n.FilePath != "" {
|
||||
indexName(idx.byDir, dir, n.Name, n.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileEdges := r.fileOutEdges(filePath)
|
||||
for _, e := range fileEdges {
|
||||
if e != nil && (e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences) {
|
||||
idx.indexCallSite(e)
|
||||
}
|
||||
}
|
||||
var batch []graph.EdgeReindex
|
||||
for _, e := range fileEdges {
|
||||
if e == nil || (e.Kind != graph.EdgeArgOf && e.Kind != graph.EdgeValueFlow) {
|
||||
continue
|
||||
}
|
||||
if old := bindDataflowCalleeEdge(e, idx); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// calleeIndex holds the per-pass lookup tables bindDataflowCallee* uses.
|
||||
type calleeIndex struct {
|
||||
byFile map[string]map[string][]string // file -> name -> func/method ids
|
||||
byDir map[string]map[string][]string // dir -> name -> function ids
|
||||
bySite map[string][]string // "<file>\x00<line>" -> resolved callee ids
|
||||
}
|
||||
|
||||
func newCalleeIndex() *calleeIndex {
|
||||
return &calleeIndex{
|
||||
byFile: map[string]map[string][]string{},
|
||||
byDir: map[string]map[string][]string{},
|
||||
bySite: map[string][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// indexCallSite records a resolved calls/references edge under its call site so
|
||||
// a `*.method` dataflow callee at the same site can reuse its bound target.
|
||||
func (idx *calleeIndex) indexCallSite(e *graph.Edge) {
|
||||
if e == nil || e.Line <= 0 || e.FilePath == "" || graph.IsUnresolvedTarget(e.To) {
|
||||
return
|
||||
}
|
||||
k := siteKey(e.FilePath, e.Line)
|
||||
idx.bySite[k] = append(idx.bySite[k], e.To)
|
||||
}
|
||||
|
||||
func siteKey(file string, line int) string {
|
||||
return file + "\x00" + strconv.Itoa(line)
|
||||
}
|
||||
|
||||
func indexName(m map[string]map[string][]string, key, name, id string) {
|
||||
names := m[key]
|
||||
if names == nil {
|
||||
names = map[string][]string{}
|
||||
m[key] = names
|
||||
}
|
||||
names[name] = append(names[name], id)
|
||||
}
|
||||
|
||||
// bindDataflowCalleeEdge rewrites e.To from an `unresolved::` callee placeholder
|
||||
// to the real node it denotes, using idx. Returns the old To value when a
|
||||
// rewrite happened (for the batched reindex) or "" when the edge was left alone
|
||||
// (not an unresolved target, no unambiguous match, or a shape another pass owns).
|
||||
func bindDataflowCalleeEdge(e *graph.Edge, idx *calleeIndex) string {
|
||||
if e == nil || !graph.IsUnresolvedTarget(e.To) {
|
||||
return ""
|
||||
}
|
||||
name := graph.UnresolvedName(e.To)
|
||||
var chosen string
|
||||
switch {
|
||||
case strings.HasPrefix(name, "*."):
|
||||
// Method-call callee: reuse the target the call resolver bound for a
|
||||
// calls/references edge at the same call site.
|
||||
method := name[2:]
|
||||
if method == "" || strings.ContainsAny(method, ".*:#") {
|
||||
return ""
|
||||
}
|
||||
chosen = uniqueSiteCallee(idx.bySite[siteKey(e.FilePath, e.Line)], method)
|
||||
case name == "" || strings.ContainsAny(name, ".*:#"):
|
||||
// Qualified (a::b), extern, or per-binding (#...) shape — owned by
|
||||
// other passes.
|
||||
return ""
|
||||
default:
|
||||
// Bare identifier: same-file first, then same-package function.
|
||||
if ids := idx.byFile[e.FilePath][name]; len(ids) == 1 {
|
||||
chosen = ids[0]
|
||||
} else if len(ids) == 0 {
|
||||
if ids := idx.byDir[filepath.Dir(e.FilePath)][name]; len(ids) == 1 {
|
||||
chosen = ids[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == "" || chosen == e.To {
|
||||
return ""
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = chosen
|
||||
return oldTo
|
||||
}
|
||||
|
||||
// uniqueSiteCallee returns the sole resolved callee at a call site whose id
|
||||
// names the given method (its id ends with ".<method>" or "::<method>"), or ""
|
||||
// when there is no match or more than one.
|
||||
func uniqueSiteCallee(callees []string, method string) string {
|
||||
var chosen string
|
||||
for _, id := range callees {
|
||||
if strings.HasSuffix(id, "."+method) || strings.HasSuffix(id, "::"+method) {
|
||||
if chosen != "" && chosen != id {
|
||||
return ""
|
||||
}
|
||||
chosen = id
|
||||
}
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// addDepNode is a tiny helper to materialise a dep::<module> contract
|
||||
// node the way GoModExtractor + commitInlinedContractToGraph would.
|
||||
func addDepNode(t *testing.T, g graph.Store, repoPrefix, modulePath string) {
|
||||
t.Helper()
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "dep::" + modulePath,
|
||||
Kind: graph.KindContract,
|
||||
Name: "dep::" + modulePath,
|
||||
FilePath: repoPrefix + "/go.mod",
|
||||
Language: "contract",
|
||||
RepoPrefix: repoPrefix,
|
||||
})
|
||||
}
|
||||
|
||||
// Sub-package import: importing a path under a declared module's
|
||||
// directory should resolve to the dep::<module> contract node.
|
||||
// This is the original bug — internal/parser/tsitter/sql/sql.go
|
||||
// imports "github.com/gortexhq/tree-sitter-sql/bindings/go" and
|
||||
// dep::github.com/gortexhq/tree-sitter-sql had zero incoming edges.
|
||||
func TestResolveAll_DepBridge_SubPackageImport(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repo/internal/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repo/internal/x.go", Language: "go", RepoPrefix: "repo"})
|
||||
addDepNode(t, g, "repo", "github.com/foo/bar")
|
||||
|
||||
importEdge := &graph.Edge{
|
||||
From: "repo/internal/x.go",
|
||||
To: "unresolved::import::github.com/foo/bar/sub/pkg",
|
||||
Kind: graph.EdgeImports,
|
||||
FilePath: "repo/internal/x.go",
|
||||
Line: 3,
|
||||
}
|
||||
g.AddEdge(importEdge)
|
||||
|
||||
r := New(g)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "dep::github.com/foo/bar", importEdge.To)
|
||||
}
|
||||
|
||||
// Bare import equal to the module path also resolves to the dep node.
|
||||
func TestResolveAll_DepBridge_BareModuleImport(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repo/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repo/x.go", Language: "go", RepoPrefix: "repo"})
|
||||
addDepNode(t, g, "repo", "github.com/foo/bar")
|
||||
|
||||
e := &graph.Edge{From: "repo/x.go", To: "unresolved::import::github.com/foo/bar", Kind: graph.EdgeImports, FilePath: "repo/x.go", Line: 3}
|
||||
g.AddEdge(e)
|
||||
|
||||
r := New(g)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "dep::github.com/foo/bar", e.To)
|
||||
}
|
||||
|
||||
// When a parent module and a nested module are both declared, the
|
||||
// longer (more specific) module path must win — otherwise importing
|
||||
// "github.com/aws/aws-sdk-go-v2/service/s3/types" would attribute to
|
||||
// the parent aws-sdk-go-v2 dep instead of the s3 sub-module dep.
|
||||
func TestResolveAll_DepBridge_LongestPrefixWins(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repo/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repo/x.go", Language: "go", RepoPrefix: "repo"})
|
||||
addDepNode(t, g, "repo", "github.com/aws/aws-sdk-go-v2")
|
||||
addDepNode(t, g, "repo", "github.com/aws/aws-sdk-go-v2/service/s3")
|
||||
|
||||
e := &graph.Edge{From: "repo/x.go", To: "unresolved::import::github.com/aws/aws-sdk-go-v2/service/s3/types", Kind: graph.EdgeImports, FilePath: "repo/x.go", Line: 3}
|
||||
g.AddEdge(e)
|
||||
|
||||
r := New(g)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "dep::github.com/aws/aws-sdk-go-v2/service/s3", e.To)
|
||||
}
|
||||
|
||||
// A module path is a prefix only when the next character is '/' or
|
||||
// the strings are equal — `foo/bar` must not satisfy import `foo/barbaz`.
|
||||
func TestResolveAll_DepBridge_NoFalsePositivePathComponent(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repo/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repo/x.go", Language: "go", RepoPrefix: "repo"})
|
||||
addDepNode(t, g, "repo", "github.com/foo/bar")
|
||||
|
||||
e := &graph.Edge{From: "repo/x.go", To: "unresolved::import::github.com/foo/barbaz", Kind: graph.EdgeImports, FilePath: "repo/x.go", Line: 3}
|
||||
g.AddEdge(e)
|
||||
|
||||
r := New(g)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
// Should fall through to external::, not match dep::.../bar.
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.External)
|
||||
assert.Equal(t, "external::github.com/foo/barbaz", e.To)
|
||||
}
|
||||
|
||||
// A dep declared by repo A's go.mod must not satisfy an import in
|
||||
// repo B even if the module path matches — each go.mod scopes its
|
||||
// own dep nodes.
|
||||
func TestResolveAll_DepBridge_RepoScoped(t *testing.T) {
|
||||
g := graph.New()
|
||||
// File lives in repoB; dep node lives under repoA.
|
||||
g.AddNode(&graph.Node{ID: "repoB/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repoB/x.go", Language: "go", RepoPrefix: "repoB"})
|
||||
addDepNode(t, g, "repoA", "github.com/foo/bar")
|
||||
|
||||
e := &graph.Edge{From: "repoB/x.go", To: "unresolved::import::github.com/foo/bar", Kind: graph.EdgeImports, FilePath: "repoB/x.go", Line: 3}
|
||||
g.AddEdge(e)
|
||||
|
||||
r := New(g)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.External)
|
||||
assert.Equal(t, "external::github.com/foo/bar", e.To)
|
||||
}
|
||||
|
||||
// Same coverage on the cross-repo resolver: caller in repoB with a
|
||||
// dep declared by repoA must not bridge; caller in the dep's own
|
||||
// repo must.
|
||||
func TestCrossRepoResolveAll_DepBridge(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "repoA/x.go", Kind: graph.KindFile, Name: "x.go", FilePath: "repoA/x.go", Language: "go", RepoPrefix: "repoA"})
|
||||
g.AddNode(&graph.Node{ID: "repoB/y.go", Kind: graph.KindFile, Name: "y.go", FilePath: "repoB/y.go", Language: "go", RepoPrefix: "repoB"})
|
||||
addDepNode(t, g, "repoA", "github.com/foo/bar")
|
||||
|
||||
bridged := &graph.Edge{From: "repoA/x.go", To: "unresolved::import::github.com/foo/bar/sub", Kind: graph.EdgeImports, FilePath: "repoA/x.go", Line: 3}
|
||||
g.AddEdge(bridged)
|
||||
stranded := &graph.Edge{From: "repoB/y.go", To: "unresolved::import::github.com/foo/bar/sub", Kind: graph.EdgeImports, FilePath: "repoB/y.go", Line: 3}
|
||||
g.AddEdge(stranded)
|
||||
|
||||
cr := NewCrossRepo(g)
|
||||
stats := cr.ResolveAll()
|
||||
|
||||
require.NotNil(t, stats)
|
||||
assert.Equal(t, "dep::github.com/foo/bar", bridged.To, "caller in dep's own repo bridges")
|
||||
assert.Equal(t, "external::github.com/foo/bar/sub", stranded.To, "caller in foreign repo stays external")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// ResolveByConvention resolves a symbol name to its definition node by the
|
||||
// directory-convention heuristic shared across the per-framework
|
||||
// middleware/controller/service/helper resolvers. It:
|
||||
//
|
||||
// (a) finds candidate symbols by name — and, when suffix is set and name
|
||||
// ends with it, also by the suffix-stripped name (so `authMiddleware`
|
||||
// matches a definition named `auth`);
|
||||
// (b) prefers a candidate under one of preferDirs (substring match on the
|
||||
// candidate's file path, e.g. "/middleware/");
|
||||
// (c) falls back to a candidate in fromFile's own directory;
|
||||
// (d) returns the unambiguous top match with a confidence tier —
|
||||
// exact-dir 0.9, same-dir 0.85, sole-candidate 0.7 — or ("", 0) when
|
||||
// the choice is ambiguous or nothing matched.
|
||||
//
|
||||
// This is the one tested primitive the express/laravel/rails/spring/etc.
|
||||
// `*Service`/`*Controller` heuristics build on.
|
||||
func ResolveByConvention(g graph.Store, name, suffix string, preferDirs []string, fromFile string) (string, float64) {
|
||||
if g == nil || name == "" {
|
||||
return "", 0
|
||||
}
|
||||
cands := dirConventionCandidates(g, name, suffix)
|
||||
if len(cands) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// Tier 1 — a candidate under a preferred directory.
|
||||
var preferred []*graph.Node
|
||||
for _, c := range cands {
|
||||
if dirMatchesAny(c.FilePath, preferDirs) {
|
||||
preferred = append(preferred, c)
|
||||
}
|
||||
}
|
||||
switch len(preferred) {
|
||||
case 1:
|
||||
return preferred[0].ID, 0.9
|
||||
case 0:
|
||||
// fall through to same-dir / sole-candidate tiers
|
||||
default:
|
||||
// Several candidates in preferred dirs — break the tie by the
|
||||
// caller's own directory, else ambiguous.
|
||||
if id := uniqueInDir(preferred, dirOf(fromFile)); id != "" {
|
||||
return id, 0.9
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// Tier 2 — a candidate in the caller's own directory.
|
||||
if id := uniqueInDir(cands, dirOf(fromFile)); id != "" {
|
||||
return id, 0.85
|
||||
}
|
||||
|
||||
// Tier 3 — a sole candidate anywhere.
|
||||
if len(cands) == 1 {
|
||||
return cands[0].ID, 0.7
|
||||
}
|
||||
|
||||
// Ambiguous.
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// dirConventionCandidates returns the resolvable symbol nodes matching name
|
||||
// (and the suffix-stripped name when applicable).
|
||||
func dirConventionCandidates(g graph.Store, name, suffix string) []*graph.Node {
|
||||
names := []string{name}
|
||||
if suffix != "" && len(name) > len(suffix) && strings.HasSuffix(name, suffix) {
|
||||
names = append(names, strings.TrimSuffix(name, suffix))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []*graph.Node
|
||||
for _, nm := range names {
|
||||
for _, n := range g.FindNodesByName(nm) {
|
||||
if n == nil || seen[n.ID] || !isConventionResolvable(n) {
|
||||
continue
|
||||
}
|
||||
seen[n.ID] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isConventionResolvable reports whether a node is a real definition this
|
||||
// heuristic may bind to (a function/method/type, not a stub or unresolved
|
||||
// placeholder).
|
||||
func isConventionResolvable(n *graph.Node) bool {
|
||||
switch n.Kind {
|
||||
case graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface, graph.KindVariable, graph.KindPackage:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return !graph.IsStub(n.ID) && !graph.IsUnresolvedTarget(n.ID)
|
||||
}
|
||||
|
||||
// dirMatchesAny reports whether filePath contains any of the preferred
|
||||
// directory segments. A preferDir is matched both as written and with
|
||||
// surrounding slashes trimmed, so "/middleware/" and "middleware" both work.
|
||||
func dirMatchesAny(filePath string, dirs []string) bool {
|
||||
for _, d := range dirs {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(filePath, d) || strings.Contains(filePath, "/"+strings.Trim(d, "/")+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// uniqueInDir returns the sole candidate whose directory equals dir, or ""
|
||||
// when there are zero or more than one (and dir is non-empty).
|
||||
func uniqueInDir(cands []*graph.Node, dir string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
id := ""
|
||||
for _, c := range cands {
|
||||
if dirOf(c.FilePath) == dir {
|
||||
if id != "" {
|
||||
return "" // two in the same dir: ambiguous
|
||||
}
|
||||
id = c.ID
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// dirOf returns the directory portion of a file path.
|
||||
func dirOf(path string) string {
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func convNode(g *graph.Graph, id, file, name string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: file, Language: "javascript"})
|
||||
}
|
||||
|
||||
func TestResolveByConvention_ExactDirTier(t *testing.T) {
|
||||
// A candidate under the preferred directory wins at 0.9.
|
||||
g := graph.New()
|
||||
convNode(g, "src/middleware/auth.js::auth", "src/middleware/auth.js", "auth")
|
||||
convNode(g, "src/util/auth.js::auth", "src/util/auth.js", "auth")
|
||||
|
||||
id, conf := ResolveByConvention(g, "authMiddleware", "Middleware", []string{"/middleware/"}, "src/routes/users.js")
|
||||
assert.Equal(t, "src/middleware/auth.js::auth", id, "suffix-stripped name resolves to the /middleware/ definition")
|
||||
assert.Equal(t, 0.9, conf)
|
||||
}
|
||||
|
||||
func TestResolveByConvention_SameDirTier(t *testing.T) {
|
||||
// No preferred-dir match; a candidate in the caller's own directory wins
|
||||
// at 0.85.
|
||||
g := graph.New()
|
||||
convNode(g, "src/routes/helpers.js::format", "src/routes/helpers.js", "format")
|
||||
convNode(g, "src/lib/format.js::format", "src/lib/format.js", "format")
|
||||
|
||||
id, conf := ResolveByConvention(g, "format", "", []string{"/middleware/"}, "src/routes/users.js")
|
||||
assert.Equal(t, "src/routes/helpers.js::format", id)
|
||||
assert.Equal(t, 0.85, conf)
|
||||
}
|
||||
|
||||
func TestResolveByConvention_SoleCandidateTier(t *testing.T) {
|
||||
// One candidate anywhere → 0.7.
|
||||
g := graph.New()
|
||||
convNode(g, "src/services/UserService.js::list", "src/services/UserService.js", "list")
|
||||
|
||||
id, conf := ResolveByConvention(g, "list", "", []string{"/controllers/"}, "src/routes/users.js")
|
||||
assert.Equal(t, "src/services/UserService.js::list", id)
|
||||
assert.Equal(t, 0.7, conf)
|
||||
}
|
||||
|
||||
func TestResolveByConvention_AmbiguousReturnsEmpty(t *testing.T) {
|
||||
// Two candidates, neither in a preferred dir nor the caller's dir → empty.
|
||||
g := graph.New()
|
||||
convNode(g, "a/auth.js::auth", "a/auth.js", "auth")
|
||||
convNode(g, "b/auth.js::auth", "b/auth.js", "auth")
|
||||
|
||||
id, conf := ResolveByConvention(g, "auth", "", []string{"/middleware/"}, "c/routes.js")
|
||||
assert.Equal(t, "", id, "ambiguous candidates resolve to nothing")
|
||||
assert.Equal(t, 0.0, conf)
|
||||
}
|
||||
|
||||
func TestResolveByConvention_PreferredDirTiebreakBySameDir(t *testing.T) {
|
||||
// Two candidates both under /middleware/ → broken by caller's own dir.
|
||||
g := graph.New()
|
||||
convNode(g, "a/middleware/auth.js::auth", "a/middleware/auth.js", "auth")
|
||||
convNode(g, "b/middleware/auth.js::auth", "b/middleware/auth.js", "auth")
|
||||
|
||||
id, conf := ResolveByConvention(g, "auth", "", []string{"/middleware/"}, "a/middleware/index.js")
|
||||
assert.Equal(t, "a/middleware/auth.js::auth", id)
|
||||
assert.Equal(t, 0.9, conf)
|
||||
// Caller in neither dir → ambiguous.
|
||||
id2, _ := ResolveByConvention(g, "auth", "", []string{"/middleware/"}, "z/routes.js")
|
||||
assert.Equal(t, "", id2)
|
||||
}
|
||||
|
||||
func TestResolveByConvention_NoCandidate(t *testing.T) {
|
||||
g := graph.New()
|
||||
id, conf := ResolveByConvention(g, "missing", "", nil, "x.js")
|
||||
assert.Equal(t, "", id)
|
||||
assert.Equal(t, 0.0, conf)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// Intra-process dispatch synthesizers (closure-collection, observer-channel,
|
||||
// event-channel, store-factory) pair a dispatcher with a registrar/callback by
|
||||
// a bare name — a collection field, a channel/event topic, a store binding.
|
||||
// Those names are generic ("handlers", "items", "update", "submit") and recur
|
||||
// across unrelated repositories, so in a multi-repo graph an unguarded pairing
|
||||
// fans a dispatcher in one repo out to a same-named registrar in another — a
|
||||
// false edge a single-repo tool could never even produce.
|
||||
//
|
||||
// sameDispatchBoundary is the gate that turns that multi-repo reach from a
|
||||
// precision liability into a strict win: two endpoints may be paired only when
|
||||
// they share the graph's hard boundary, WorkspaceID — which is "" for a
|
||||
// single-repo graph (always paired) and shared across a monorepo's member
|
||||
// repos (still paired) but differs between independent projects (never paired).
|
||||
// Genuinely cross-language / cross-repo bridges (gRPC, Temporal, the native
|
||||
// bridges) are deliberately NOT routed through this gate and stay global.
|
||||
func sameDispatchBoundary(a, b *graph.Node) bool {
|
||||
return a != nil && b != nil && a.WorkspaceID == b.WorkspaceID
|
||||
}
|
||||
|
||||
// sameDispatchBoundaryIDs resolves two node IDs and reports whether they share
|
||||
// the dispatch boundary. Unknown nodes never pair.
|
||||
func sameDispatchBoundaryIDs(g graph.Store, aID, bID string) bool {
|
||||
return sameDispatchBoundary(g.GetNode(aID), g.GetNode(bID))
|
||||
}
|
||||
|
||||
// sameBoundaryCandidates filters cands to those sharing the caller's hard graph
|
||||
// boundary, so a binding/action name reused across unrelated repos cannot bind
|
||||
// a call to a target in a different workspace. Returns cands unchanged when the
|
||||
// caller's node (and thus its workspace) is unknown, so single-repo resolution
|
||||
// is never weakened.
|
||||
func sameBoundaryCandidates(g graph.Store, callerID string, cands []*graph.Node) []*graph.Node {
|
||||
caller := g.GetNode(callerID)
|
||||
if caller == nil {
|
||||
return cands
|
||||
}
|
||||
out := make([]*graph.Node, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if c != nil && c.WorkspaceID == caller.WorkspaceID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// DjangoDescriptorResolver is a claiming resolver for Django's named
|
||||
// descriptor dispatch — an attribute reference the static graph cannot
|
||||
// resolve because it names a runtime descriptor, not a declared method.
|
||||
// The flagship case is `self._iterable_class(self)` inside a QuerySet:
|
||||
// `_iterable_class` is a class attribute (default `ModelIterable`), and
|
||||
// iterating its instance runs `ModelIterable.__iter__`. This resolver
|
||||
// claims those residual `_iterable_class` references and binds them to the
|
||||
// iterable class's `__iter__`, keyed by class names present in the graph.
|
||||
type DjangoDescriptorResolver struct{}
|
||||
|
||||
// djangoDescriptorVocab is the set of Django descriptor attribute names this
|
||||
// resolver claims. Kept tight so the pre-filter only sees its own framework
|
||||
// vocabulary.
|
||||
var djangoDescriptorVocab = map[string]bool{
|
||||
"_iterable_class": true,
|
||||
}
|
||||
|
||||
// djangoDefaultIterableClass is Django's default QuerySet._iterable_class.
|
||||
const djangoDefaultIterableClass = "ModelIterable"
|
||||
|
||||
func (DjangoDescriptorResolver) Name() string { return SynthDjangoDescriptor }
|
||||
|
||||
// Claims reports whether the edge references a Django descriptor name.
|
||||
func (DjangoDescriptorResolver) Claims(e *graph.Edge) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
return djangoDescriptorVocab[djangoRefName(e.To)]
|
||||
}
|
||||
|
||||
// Resolve rebinds a claimed `_iterable_class` reference to the iterable
|
||||
// class's `__iter__` method — the class named by the QuerySet's
|
||||
// django_iterable_class hint, else Django's default ModelIterable.
|
||||
func (DjangoDescriptorResolver) Resolve(g graph.Store, e *graph.Edge) bool {
|
||||
if g == nil || e == nil || djangoRefName(e.To) != "_iterable_class" {
|
||||
return false
|
||||
}
|
||||
iterableClass := djangoIterableClassFor(g, e.From)
|
||||
if iterableClass == "" {
|
||||
iterableClass = djangoDefaultIterableClass
|
||||
}
|
||||
target := djangoFindIterMethod(g, iterableClass)
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = target.ID
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0.7
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, 0.7)
|
||||
StampSynthesized(e, SynthDjangoDescriptor)
|
||||
g.ReindexEdges([]graph.EdgeReindex{{Edge: e, OldTo: oldTo}})
|
||||
return true
|
||||
}
|
||||
|
||||
// djangoRefName extracts the bare attribute name from an unresolved target
|
||||
// id, stripping the `unresolved::` prefix and any `*.` method marker.
|
||||
func djangoRefName(to string) string {
|
||||
if !graph.IsUnresolvedTarget(to) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(graph.UnresolvedName(to), "*.")
|
||||
}
|
||||
|
||||
// djangoIterableClassFor returns the iterable-class name hinted on the class
|
||||
// enclosing the reference's source method, or "".
|
||||
func djangoIterableClassFor(g graph.Store, fromID string) string {
|
||||
n := g.GetNode(fromID)
|
||||
if n == nil || n.Meta == nil {
|
||||
return ""
|
||||
}
|
||||
recv, _ := n.Meta["receiver"].(string)
|
||||
if recv == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range g.FindNodesByName(recv) {
|
||||
if c == nil || c.Kind != graph.KindType || c.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if ic, _ := c.Meta["django_iterable_class"].(string); ic != "" {
|
||||
return ic
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// djangoFindIterMethod returns the `__iter__` method of the named class.
|
||||
func djangoFindIterMethod(g graph.Store, className string) *graph.Node {
|
||||
for _, n := range g.FindNodesByName("__iter__") {
|
||||
if n == nil || n.Kind != graph.KindMethod || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if recv, _ := n.Meta["receiver"].(string); recv == className {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fakeClaimingResolver claims any unresolved ref named "widget" and rebinds
|
||||
// it to a fixed target — exercises the generic claimsReference tier.
|
||||
type fakeClaimingResolver struct{ target string }
|
||||
|
||||
func (fakeClaimingResolver) Name() string { return "fake-claim" }
|
||||
func (fakeClaimingResolver) Claims(e *graph.Edge) bool {
|
||||
return e != nil && djangoRefName(e.To) == "widget"
|
||||
}
|
||||
func (f fakeClaimingResolver) Resolve(g graph.Store, e *graph.Edge) bool {
|
||||
if g.GetNode(f.target) == nil {
|
||||
return false
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = f.target
|
||||
StampSynthesized(e, "fake-claim")
|
||||
g.ReindexEdges([]graph.EdgeReindex{{Edge: e, OldTo: oldTo}})
|
||||
return true
|
||||
}
|
||||
|
||||
// runClaimingResolversWith offers each residual unresolved edge to a single
|
||||
// resolver — the generic tier, decoupled from the default registry.
|
||||
func runClaimingResolversWith(g graph.Store, r ClaimingResolver) int {
|
||||
var pending []*graph.Edge
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e != nil && graph.IsUnresolvedTarget(e.To) {
|
||||
pending = append(pending, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
claimed := 0
|
||||
for _, e := range pending {
|
||||
if r.Claims(e) && r.Resolve(g, e) {
|
||||
claimed++
|
||||
}
|
||||
}
|
||||
return claimed
|
||||
}
|
||||
|
||||
func TestClaimingResolver_GenericTierClaimsResidualRef(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "w.py::Widget.render", Kind: graph.KindMethod, Name: "render", FilePath: "w.py"})
|
||||
g.AddNode(&graph.Node{ID: "w.py::Caller.go", Kind: graph.KindFunction, Name: "go", FilePath: "w.py"})
|
||||
// A residual unresolved ref no declared symbol matches.
|
||||
g.AddEdge(&graph.Edge{From: "w.py::Caller.go", To: "unresolved::*.widget", Kind: graph.EdgeCalls, FilePath: "w.py"})
|
||||
|
||||
n := runClaimingResolversWith(g, fakeClaimingResolver{target: "w.py::Widget.render"})
|
||||
require.Equal(t, 1, n, "the fake resolver claims and rewrites the residual ref")
|
||||
|
||||
var bound bool
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e.From == "w.py::Caller.go" && e.To == "w.py::Widget.render" {
|
||||
bound = true
|
||||
}
|
||||
}
|
||||
assert.True(t, bound, "the unresolved ref was rebound before external-call synthesis")
|
||||
}
|
||||
|
||||
// The Django descriptor resolver must stay wired into the default claiming
|
||||
// registry — a drift fence so it cannot be silently dropped and quietly stop
|
||||
// claiming Django's named-class references. Asserts membership by Name()
|
||||
// directly, independent of any one resolver's functional behaviour.
|
||||
func TestDefaultClaimingResolvers_IncludesDjangoDescriptor(t *testing.T) {
|
||||
names := map[string]bool{}
|
||||
for _, r := range defaultClaimingResolvers() {
|
||||
require.NotNil(t, r, "registered claiming resolvers are non-nil")
|
||||
names[r.Name()] = true
|
||||
}
|
||||
require.True(t, names[SynthDjangoDescriptor],
|
||||
"DjangoDescriptorResolver must be registered in defaultClaimingResolvers (got %v)", names)
|
||||
}
|
||||
|
||||
func djangoIter(g *graph.Graph, id, file, class string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: "__iter__", FilePath: file, Language: "python",
|
||||
Meta: map[string]any{"receiver": class}})
|
||||
}
|
||||
|
||||
func djangoQuerySet(g *graph.Graph, classID, methodID, file, class, iterable string) {
|
||||
meta := map[string]any{}
|
||||
if iterable != "" {
|
||||
meta["django_iterable_class"] = iterable
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: classID, Kind: graph.KindType, Name: class, FilePath: file, Language: "python", Meta: meta})
|
||||
g.AddNode(&graph.Node{ID: methodID, Kind: graph.KindMethod, Name: "iterator", FilePath: file, Language: "python",
|
||||
Meta: map[string]any{"receiver": class}})
|
||||
g.AddEdge(&graph.Edge{From: methodID, To: "unresolved::*._iterable_class", Kind: graph.EdgeCalls, FilePath: file})
|
||||
}
|
||||
|
||||
func synthDjangoEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthDjangoDescriptor {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDjangoDescriptor_IterableClassHint(t *testing.T) {
|
||||
g := graph.New()
|
||||
djangoIter(g, "m.py::ModelIterable.__iter__", "m.py", "ModelIterable")
|
||||
djangoQuerySet(g, "m.py::QuerySet", "m.py::QuerySet.iterator", "m.py", "QuerySet", "ModelIterable")
|
||||
|
||||
claimed := RunClaimingResolvers(g)
|
||||
require.Equal(t, 1, claimed[SynthDjangoDescriptor])
|
||||
e := synthDjangoEdge(g, "m.py::QuerySet.iterator", "m.py::ModelIterable.__iter__")
|
||||
require.NotNil(t, e, "the QuerySet iterator binds to the iterable class's __iter__")
|
||||
assert.Equal(t, 0.7, e.Confidence)
|
||||
assert.Equal(t, ProvenanceHeuristic, e.Meta[MetaProvenance])
|
||||
}
|
||||
|
||||
func TestDjangoDescriptor_DefaultModelIterable(t *testing.T) {
|
||||
// No hint: fall back to Django's default ModelIterable.
|
||||
g := graph.New()
|
||||
djangoIter(g, "m.py::ModelIterable.__iter__", "m.py", "ModelIterable")
|
||||
djangoQuerySet(g, "m.py::QuerySet", "m.py::QuerySet.iterator", "m.py", "QuerySet", "")
|
||||
|
||||
RunClaimingResolvers(g)
|
||||
assert.NotNil(t, synthDjangoEdge(g, "m.py::QuerySet.iterator", "m.py::ModelIterable.__iter__"))
|
||||
}
|
||||
|
||||
func TestDjangoDescriptor_DoesNotClaimUnknownRef(t *testing.T) {
|
||||
g := graph.New()
|
||||
djangoIter(g, "m.py::ModelIterable.__iter__", "m.py", "ModelIterable")
|
||||
g.AddNode(&graph.Node{ID: "m.py::C.m", Kind: graph.KindMethod, Name: "m", FilePath: "m.py", Meta: map[string]any{"receiver": "C"}})
|
||||
g.AddEdge(&graph.Edge{From: "m.py::C.m", To: "unresolved::*.something_else", Kind: graph.EdgeCalls, FilePath: "m.py"})
|
||||
|
||||
assert.Equal(t, 0, RunClaimingResolvers(g)[SynthDjangoDescriptor])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package resolver lands cross-reference, import, and framework-dispatch edges
|
||||
// the per-file extractors leave on `unresolved::` placeholders.
|
||||
//
|
||||
// Caching strategy. The batch resolver deliberately does NOT use a
|
||||
// fixed-capacity LRU for its hot lookups. Each pass (relative-import binding,
|
||||
// Rust/Lua/Razor module resolution, framework synthesis, cross-repo joins)
|
||||
// builds its index maps once from the graph, uses them for that pass, and lets
|
||||
// them fall out of scope when the pass returns. This per-pass-clear strategy is
|
||||
// strictly better than an LRU here: it incurs no eviction bookkeeping, can
|
||||
// never serve a stale cross-pass entry, and is bounded by the actual pass work
|
||||
// set rather than a guessed capacity. There is therefore no
|
||||
// resolver-cache-size knob — the maps are not user-tunable because they are not
|
||||
// retained.
|
||||
//
|
||||
// The one resolver-adjacent cache that IS long-lived is the compile-DB
|
||||
// include-dir set built for C/C++ include resolution: it is keyed by repo root
|
||||
// and survives across reindexes, so it is bounded by a memory budget
|
||||
// (GORTEX_RESOLVER_CACHE_MAX_MB; unset = unbounded) via a small LRU in the
|
||||
// indexer package. That budget exists purely to cap a long-lived per-repo cache
|
||||
// and has no effect on hot resolution.
|
||||
package resolver
|
||||
@@ -0,0 +1,48 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// copyingStore simulates persistent backends (sqlite): every read
|
||||
// materialises fresh Edge values, so pointer identity never holds across
|
||||
// reads. The chunked ResolveAll liveness gate compared pointers only —
|
||||
// on such backends every computed resolution was judged stale and
|
||||
// silently dropped, turning the daemon's whole master resolve pass into
|
||||
// a no-op while the CLI's in-memory path kept working.
|
||||
type copyingStore struct {
|
||||
graph.Store
|
||||
}
|
||||
|
||||
func (c copyingStore) GetOutEdges(id string) []*graph.Edge {
|
||||
src := c.Store.GetOutEdges(id)
|
||||
out := make([]*graph.Edge, len(src))
|
||||
for i, e := range src {
|
||||
cp := *e
|
||||
out[i] = &cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEdgeStillLive_ValueIdentityOnCopyingStore(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go"})
|
||||
live := &graph.Edge{From: "a.go::A", To: "unresolved::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 7}
|
||||
g.AddEdge(live)
|
||||
|
||||
cs := copyingStore{Store: g}
|
||||
assert.True(t, edgeStillLive(cs, live),
|
||||
"a live edge must be recognised through a store that returns copies")
|
||||
|
||||
// Pointer identity still suffices on in-memory stores.
|
||||
assert.True(t, edgeStillLive(g, live))
|
||||
|
||||
gone := *live
|
||||
gone.Line = 999
|
||||
assert.False(t, edgeStillLive(cs, &gone),
|
||||
"an edge that no longer exists at that call site must not count as live")
|
||||
assert.False(t, edgeStillLive(cs, nil))
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// eventChannelVia is the Edge.Meta["via"] marker on a synthesized
|
||||
// event-channel call edge.
|
||||
const eventChannelVia = "event.channel"
|
||||
|
||||
// maxEventChannelFanout caps the emitter/listener count a single topic
|
||||
// may have before the pairing is skipped. An in-process channel with
|
||||
// dozens of emitters or listeners is almost always a generic logging /
|
||||
// telemetry bus where an emitter→listener call edge per pair would be
|
||||
// pure noise (and quadratic). Real domain event channels pair a handful
|
||||
// of publishers with a handful of handlers.
|
||||
const maxEventChannelFanout = 32
|
||||
|
||||
// emitterLiteralTransport labels the fallback emitter-literal channel:
|
||||
// a bare `recv.on('event', handler)` / `recv.emit('event')` pair the
|
||||
// import-gated pub/sub extractor does not recognise (no `events` /
|
||||
// `eventemitter` import), keyed only by the receiver scope + event-name
|
||||
// string literal. Its topic node ID is event::emitter::<recv>::<literal>.
|
||||
const emitterLiteralTransport = "emitter"
|
||||
|
||||
// emitterLiteralFanoutCap is the tighter fan-out cap applied to the
|
||||
// emitter-literal channel. A topic-node-backed pub/sub pair carries
|
||||
// transport + import evidence and may fan out to maxEventChannelFanout;
|
||||
// an emitter-literal pair is correlated by nothing but a bare string, so
|
||||
// it gets a much tighter cap to keep a common literal ("data", "error")
|
||||
// from fanning a publisher out to every unrelated same-named listener.
|
||||
const emitterLiteralFanoutCap = 6
|
||||
|
||||
// ResolveEventChannelCalls is the framework-dispatch synthesizer for
|
||||
// in-process and cross-language event channels. The pub/sub extractor
|
||||
// already materialises a shared KindEvent topic node per (transport,
|
||||
// topic) pair, with EdgeEmits from every publishing function and
|
||||
// EdgeListensOn from every subscribing function. Message brokers
|
||||
// (Kafka / NATS / RabbitMQ / Redis) get their producer↔consumer pairing
|
||||
// from the contracts layer (EdgeProducesTopic / EdgeConsumesTopic), so
|
||||
// this pass deliberately covers only the channels the contracts layer
|
||||
// does not: in-process emitters (Node EventEmitter, Socket.IO) and the
|
||||
// native cross-language bridges (React Native's NativeEventEmitter,
|
||||
// where a Swift/ObjC/Kotlin `sendEvent` is handled by a JS
|
||||
// `addListener`). For each such topic it synthesizes a `calls` edge from
|
||||
// each emitting function to each listening function — the runtime
|
||||
// dispatch the static call graph cannot see ("who actually runs when
|
||||
// this event fires?").
|
||||
//
|
||||
// Full recompute and idempotent: every edge is re-derived from the emit
|
||||
// / listen edges, graph.AddEdge dedupes by edge key, and graph.EvictFile
|
||||
// drops the synthesized edge in both directions when either endpoint's
|
||||
// file is reindexed — so a removed emitter or listener cannot leave a
|
||||
// dangling edge. Edges ride at ast_inferred (the pairing is a name-keyed
|
||||
// heuristic, not a typed dispatch) and carry full provenance.
|
||||
//
|
||||
// Returns the number of event-channel call edges synthesized this pass.
|
||||
func ResolveEventChannelCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
type emitSite struct {
|
||||
from string
|
||||
filePath string
|
||||
line int
|
||||
transport string
|
||||
}
|
||||
emittersByEvent := map[string][]emitSite{}
|
||||
for e := range g.EdgesByKind(graph.EdgeEmits) {
|
||||
if e == nil || e.To == "" || e.From == "" {
|
||||
continue
|
||||
}
|
||||
if !isPubsubEventNode(e.To) && !isEmitterEventNode(e.To) {
|
||||
continue
|
||||
}
|
||||
emittersByEvent[e.To] = append(emittersByEvent[e.To], emitSite{
|
||||
from: e.From,
|
||||
filePath: e.FilePath,
|
||||
line: e.Line,
|
||||
transport: edgeTransport(e),
|
||||
})
|
||||
}
|
||||
if len(emittersByEvent) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
listenersByEvent := map[string][]string{}
|
||||
for e := range g.EdgesByKind(graph.EdgeListensOn) {
|
||||
if e == nil || e.To == "" || e.From == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := emittersByEvent[e.To]; !ok {
|
||||
continue
|
||||
}
|
||||
listenersByEvent[e.To] = append(listenersByEvent[e.To], e.From)
|
||||
}
|
||||
|
||||
var batch []*graph.Edge
|
||||
synthesized := 0
|
||||
// Stable iteration so a re-run produces edges in the same order
|
||||
// (Date/rand are unavailable in this layer anyway; this keeps logs
|
||||
// and any downstream ordering deterministic).
|
||||
eventIDs := make([]string, 0, len(emittersByEvent))
|
||||
for id := range emittersByEvent {
|
||||
eventIDs = append(eventIDs, id)
|
||||
}
|
||||
sort.Strings(eventIDs)
|
||||
|
||||
for _, eventID := range eventIDs {
|
||||
emitters := emittersByEvent[eventID]
|
||||
listeners := listenersByEvent[eventID]
|
||||
if len(listeners) == 0 {
|
||||
continue
|
||||
}
|
||||
// Only pair channels the contracts broker-pairing layer ignores.
|
||||
transport := ""
|
||||
for _, em := range emitters {
|
||||
if em.transport != "" {
|
||||
transport = em.transport
|
||||
break
|
||||
}
|
||||
}
|
||||
if transport == "" {
|
||||
transport = transportFromEventID(eventID)
|
||||
}
|
||||
if !eventChannelInProcess(transport) {
|
||||
continue
|
||||
}
|
||||
fanoutCap := maxEventChannelFanout
|
||||
if transport == emitterLiteralTransport {
|
||||
fanoutCap = emitterLiteralFanoutCap
|
||||
}
|
||||
if len(emitters) > fanoutCap || len(listeners) > fanoutCap {
|
||||
continue
|
||||
}
|
||||
topic := topicFromEventID(eventID, transport)
|
||||
|
||||
// Dedupe (from→to) pairs, keeping the emit site with the lowest
|
||||
// line as the representative so the edge key is stable across runs
|
||||
// even when a function emits the same event from several lines.
|
||||
type pairKey struct{ from, to string }
|
||||
rep := map[pairKey]emitSite{}
|
||||
for _, em := range emitters {
|
||||
for _, to := range listeners {
|
||||
if em.from == "" || to == "" || em.from == to {
|
||||
continue
|
||||
}
|
||||
if !sameDispatchBoundaryIDs(g, em.from, to) {
|
||||
continue
|
||||
}
|
||||
k := pairKey{from: em.from, to: to}
|
||||
if cur, ok := rep[k]; !ok || em.line < cur.line {
|
||||
rep[k] = em
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, em := range rep {
|
||||
batch = append(batch, &graph.Edge{
|
||||
From: k.from,
|
||||
To: k.to,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: em.filePath,
|
||||
Line: em.line,
|
||||
Confidence: 0.5,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, 0.5),
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{
|
||||
"via": eventChannelVia,
|
||||
"event_topic": topic,
|
||||
"event_transport": transport,
|
||||
MetaSynthesizedBy: SynthEventChannel,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
})
|
||||
synthesized++
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return synthesized
|
||||
}
|
||||
|
||||
// isPubsubEventNode reports whether an ID is a pub/sub KindEvent topic
|
||||
// node (event::pubsub::<transport>::<topic>).
|
||||
func isPubsubEventNode(id string) bool {
|
||||
return strings.HasPrefix(id, "event::pubsub::")
|
||||
}
|
||||
|
||||
// isEmitterEventNode reports whether an ID is an emitter-literal KindEvent
|
||||
// topic node (event::emitter::<receiver-scope>::<literal>) — the fallback
|
||||
// channel for the in-process `.on` / `.emit` pairs the import-gated
|
||||
// pub/sub extractor does not recognise.
|
||||
func isEmitterEventNode(id string) bool {
|
||||
return strings.HasPrefix(id, "event::emitter::")
|
||||
}
|
||||
|
||||
// edgeTransport reads the transport label an emit/listen edge carries.
|
||||
func edgeTransport(e *graph.Edge) string {
|
||||
if e == nil || e.Meta == nil {
|
||||
return ""
|
||||
}
|
||||
t, _ := e.Meta["transport"].(string)
|
||||
return t
|
||||
}
|
||||
|
||||
// transportFromEventID recovers the transport segment of an event node
|
||||
// ID when the edge Meta did not carry it. Emitter-literal nodes carry no
|
||||
// transport segment in their ID — the transport is implicitly "emitter".
|
||||
func transportFromEventID(id string) string {
|
||||
if isEmitterEventNode(id) {
|
||||
return emitterLiteralTransport
|
||||
}
|
||||
rest := strings.TrimPrefix(id, "event::pubsub::")
|
||||
if t, _, ok := strings.Cut(rest, "::"); ok {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// topicFromEventID recovers the topic segment of an event node ID. For an
|
||||
// emitter-literal node (event::emitter::<recv>::<literal>) the topic is
|
||||
// the receiver-scoped literal; for a pub/sub node it is the segment after
|
||||
// the transport.
|
||||
func topicFromEventID(id, transport string) string {
|
||||
if isEmitterEventNode(id) {
|
||||
return strings.TrimPrefix(id, "event::emitter::")
|
||||
}
|
||||
return strings.TrimPrefix(id, "event::pubsub::"+transport+"::")
|
||||
}
|
||||
|
||||
// eventChannelInProcess reports whether a transport is an in-process or
|
||||
// native-bridge channel this pass should pair — i.e. one the contracts
|
||||
// broker-pairing layer (Kafka / NATS / RabbitMQ / Redis) does not handle.
|
||||
func eventChannelInProcess(transport string) bool {
|
||||
switch transport {
|
||||
case "eventemitter", "socketio", emitterLiteralTransport:
|
||||
return true
|
||||
}
|
||||
// Native cross-language bridges register their event channel under an
|
||||
// "rn_*" / "native*" transport so a native `sendEvent` pairs with the
|
||||
// JS `addListener` handler.
|
||||
return strings.HasPrefix(transport, "rn_") ||
|
||||
strings.HasPrefix(transport, "native") ||
|
||||
strings.HasPrefix(transport, "rn-")
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// eventChannelTestGraph builds the minimal pub/sub shape the
|
||||
// ResolveEventChannelCalls pass consumes: emitter/listener function
|
||||
// nodes plus EdgeEmits / EdgeListensOn edges to a shared KindEvent topic
|
||||
// node.
|
||||
type eventChannelTestGraph struct{ g graph.Store }
|
||||
|
||||
func newEventChannelTestGraph() *eventChannelTestGraph {
|
||||
return &eventChannelTestGraph{g: graph.New()}
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) fn(id, filePath string) {
|
||||
b.g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: lastSeg(id), FilePath: filePath})
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) eventNode(transport, topic string) string {
|
||||
id := "event::pubsub::" + transport + "::" + topic
|
||||
if b.g.GetNode(id) == nil {
|
||||
b.g.AddNode(&graph.Node{ID: id, Kind: graph.KindEvent, Name: topic, Meta: map[string]any{"transport": transport, "event_kind": "pubsub"}})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) emit(fromID, transport, topic, filePath string, line int) {
|
||||
b.fn(fromID, filePath)
|
||||
to := b.eventNode(transport, topic)
|
||||
b.g.AddEdge(&graph.Edge{From: fromID, To: to, Kind: graph.EdgeEmits, FilePath: filePath, Line: line, Meta: map[string]any{"transport": transport}})
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) listen(fromID, transport, topic, filePath string, line int) {
|
||||
b.fn(fromID, filePath)
|
||||
to := b.eventNode(transport, topic)
|
||||
b.g.AddEdge(&graph.Edge{From: fromID, To: to, Kind: graph.EdgeListensOn, FilePath: filePath, Line: line, Meta: map[string]any{"transport": transport}})
|
||||
}
|
||||
|
||||
// emitterNode / emitEmitter / listenEmitter build the emitter-literal
|
||||
// fallback shape: an event::emitter::<recv>::<topic> KindEvent node with
|
||||
// EdgeEmits / EdgeListensOn edges tagged transport "emitter".
|
||||
func (b *eventChannelTestGraph) emitterNode(recv, topic string) string {
|
||||
id := "event::emitter::" + recv + "::" + topic
|
||||
if b.g.GetNode(id) == nil {
|
||||
b.g.AddNode(&graph.Node{ID: id, Kind: graph.KindEvent, Name: topic, Meta: map[string]any{"transport": "emitter", "event_kind": "emitter", "receiver": recv}})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) emitEmitter(fromID, recv, topic, filePath string, line int) {
|
||||
b.fn(fromID, filePath)
|
||||
to := b.emitterNode(recv, topic)
|
||||
b.g.AddEdge(&graph.Edge{From: fromID, To: to, Kind: graph.EdgeEmits, FilePath: filePath, Line: line, Meta: map[string]any{"transport": "emitter"}})
|
||||
}
|
||||
|
||||
func (b *eventChannelTestGraph) listenEmitter(fromID, recv, topic, filePath string, line int) {
|
||||
b.fn(fromID, filePath)
|
||||
to := b.emitterNode(recv, topic)
|
||||
b.g.AddEdge(&graph.Edge{From: fromID, To: to, Kind: graph.EdgeListensOn, FilePath: filePath, Line: line, Meta: map[string]any{"transport": "emitter"}})
|
||||
}
|
||||
|
||||
// synthEventEdge returns the synthesized event-channel calls edge between
|
||||
// from and to, or nil.
|
||||
func synthEventEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v == eventChannelVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_PairsInProcessEmitterToListener(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("pub/order.go::placeOrder", "eventemitter", "order.created", "pub/order.go", 10)
|
||||
b.listen("sub/mailer.go::onOrder", "eventemitter", "order.created", "sub/mailer.go", 20)
|
||||
|
||||
n := ResolveEventChannelCalls(b.g)
|
||||
assert.Equal(t, 1, n)
|
||||
|
||||
e := synthEventEdge(b.g, "pub/order.go::placeOrder", "sub/mailer.go::onOrder")
|
||||
require.NotNil(t, e, "emitter must reach the listener via a synthesized call edge")
|
||||
assert.Equal(t, graph.OriginASTInferred, e.Origin)
|
||||
assert.Equal(t, "order.created", e.Meta["event_topic"])
|
||||
assert.Equal(t, "eventemitter", e.Meta["event_transport"])
|
||||
assert.Equal(t, SynthEventChannel, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, ProvenanceHeuristic, e.Meta[MetaProvenance])
|
||||
// The listener sees the inbound synthesized edge.
|
||||
require.Len(t, b.g.GetInEdges("sub/mailer.go::onOrder"), 1)
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_FanOutAcrossListeners(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("pub/order.go::placeOrder", "socketio", "order", "pub/order.go", 10)
|
||||
b.listen("a.go::a", "socketio", "order", "a.go", 1)
|
||||
b.listen("b.go::b", "socketio", "order", "b.go", 1)
|
||||
|
||||
n := ResolveEventChannelCalls(b.g)
|
||||
assert.Equal(t, 2, n)
|
||||
assert.NotNil(t, synthEventEdge(b.g, "pub/order.go::placeOrder", "a.go::a"))
|
||||
assert.NotNil(t, synthEventEdge(b.g, "pub/order.go::placeOrder", "b.go::b"))
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_NativeBridgeTransportPaired(t *testing.T) {
|
||||
// A native (Swift/ObjC/Kotlin) sendEvent registered under an rn_*
|
||||
// transport must pair with the JS addListener handler — the
|
||||
// cross-language case.
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("ios/Native.swift::Native.sendBattery", "rn_native_event", "battery", "ios/Native.swift", 30)
|
||||
b.listen("js/app.ts::onBattery", "rn_native_event", "battery", "js/app.ts", 5)
|
||||
|
||||
n := ResolveEventChannelCalls(b.g)
|
||||
assert.Equal(t, 1, n)
|
||||
assert.NotNil(t, synthEventEdge(b.g, "ios/Native.swift::Native.sendBattery", "js/app.ts::onBattery"))
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_SkipsBrokerTransports(t *testing.T) {
|
||||
// Kafka / NATS / RabbitMQ / Redis are paired by the contracts
|
||||
// producer↔consumer layer (EdgeProducesTopic / EdgeConsumesTopic);
|
||||
// this pass must not double-cover them.
|
||||
for _, transport := range []string{"kafka", "nats", "rabbitmq", "redis", "unknown"} {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("p.go::p", transport, "t", "p.go", 1)
|
||||
b.listen("c.go::c", transport, "t", "c.go", 1)
|
||||
assert.Equal(t, 0, ResolveEventChannelCalls(b.g), "transport %q must not be paired here", transport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_NoSelfEdge(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
// Same function both emits and listens on the topic.
|
||||
b.emit("x.go::x", "eventemitter", "tick", "x.go", 1)
|
||||
b.listen("x.go::x", "eventemitter", "tick", "x.go", 2)
|
||||
assert.Equal(t, 0, ResolveEventChannelCalls(b.g), "a function must not call itself via the event channel")
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_Idempotent(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("p.go::p", "eventemitter", "e", "p.go", 1)
|
||||
b.listen("c.go::c", "eventemitter", "e", "c.go", 1)
|
||||
first := ResolveEventChannelCalls(b.g)
|
||||
second := ResolveEventChannelCalls(b.g)
|
||||
assert.Equal(t, first, second, "pass count is stable across runs")
|
||||
// Exactly one synthesized edge survives (AddEdge dedupes by key).
|
||||
count := 0
|
||||
for e := range b.g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e != nil && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == eventChannelVia {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_FanOutCap(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("p.go::p", "eventemitter", "busy", "p.go", 1)
|
||||
for i := range maxEventChannelFanout + 1 {
|
||||
b.listen("l.go::l"+strconv.Itoa(i), "eventemitter", "busy", "l.go", i+1)
|
||||
}
|
||||
assert.Equal(t, 0, ResolveEventChannelCalls(b.g), "a pathological fan-out channel is skipped, not exploded")
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_EmitterLiteralCrossFile(t *testing.T) {
|
||||
// emitter.emit('ready') in one file pairs with emitter.on('ready',
|
||||
// onReady) in another: the synthesized call lands on the named handler
|
||||
// (the listen edge's From), not the .on call's enclosing function.
|
||||
b := newEventChannelTestGraph()
|
||||
b.emitEmitter("pub/app.js::boot", "emitter", "ready", "pub/app.js", 10)
|
||||
b.listenEmitter("sub/h.js::onReady", "emitter", "ready", "sub/h.js", 3)
|
||||
|
||||
n := ResolveEventChannelCalls(b.g)
|
||||
require.Equal(t, 1, n)
|
||||
e := synthEventEdge(b.g, "pub/app.js::boot", "sub/h.js::onReady")
|
||||
require.NotNil(t, e, "emit's enclosing fn should call the handler")
|
||||
assert.Equal(t, "emitter", e.Meta["event_transport"])
|
||||
assert.Equal(t, SynthEventChannel, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, ProvenanceHeuristic, e.Meta[MetaProvenance])
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_EmitterLiteralPerLiteralCap(t *testing.T) {
|
||||
// The emitter-literal channel caps fan-out at 6, tighter than the
|
||||
// pub/sub maxEventChannelFanout of 32, because a bare string is the
|
||||
// only correlation.
|
||||
over := newEventChannelTestGraph()
|
||||
over.emitEmitter("p.js::p", "bus", "data", "p.js", 1)
|
||||
for i := 0; i < 7; i++ {
|
||||
over.listenEmitter("l.js::l"+strconv.Itoa(i), "bus", "data", "l.js", i+1)
|
||||
}
|
||||
assert.Equal(t, 0, ResolveEventChannelCalls(over.g), "7 listeners exceed the per-literal cap of 6")
|
||||
|
||||
atCap := newEventChannelTestGraph()
|
||||
atCap.emitEmitter("p.js::p", "bus", "data", "p.js", 1)
|
||||
for i := 0; i < 6; i++ {
|
||||
atCap.listenEmitter("l.js::l"+strconv.Itoa(i), "bus", "data", "l.js", i+1)
|
||||
}
|
||||
assert.Equal(t, 6, ResolveEventChannelCalls(atCap.g), "6 listeners are within the cap")
|
||||
}
|
||||
|
||||
func TestResolveEventChannelCalls_EmitterReceiverScopeKeepsTopicsDistinct(t *testing.T) {
|
||||
// Two different receivers each fire 'ready'; receiver-scoping keeps
|
||||
// them distinct so a publisher does not fan out to the other's handler.
|
||||
b := newEventChannelTestGraph()
|
||||
b.emitEmitter("a.js::a", "alpha", "ready", "a.js", 1)
|
||||
b.listenEmitter("a.js::onA", "alpha", "ready", "a.js", 2)
|
||||
b.emitEmitter("b.js::b", "beta", "ready", "b.js", 1)
|
||||
b.listenEmitter("b.js::onB", "beta", "ready", "b.js", 2)
|
||||
|
||||
ResolveEventChannelCalls(b.g)
|
||||
assert.NotNil(t, synthEventEdge(b.g, "a.js::a", "a.js::onA"))
|
||||
assert.NotNil(t, synthEventEdge(b.g, "b.js::b", "b.js::onB"))
|
||||
assert.Nil(t, synthEventEdge(b.g, "a.js::a", "b.js::onB"), "different receivers must not cross-pair")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// expoBridgeVia marks a synthesized JS→Expo-native call edge.
|
||||
const expoBridgeVia = "expo.bridge"
|
||||
|
||||
// ResolveExpoModuleBridge is the framework-dispatch synthesizer for Expo
|
||||
// Modules. The Swift/Kotlin extractors stamp expo_module + expo_method on
|
||||
// synthetic method nodes parsed from the Expo DSL (Name / Function /
|
||||
// AsyncFunction). The JS/TS extractor already emits an rn-native
|
||||
// placeholder call edge (Meta["via"]="rn.native", rn_module + rn_method)
|
||||
// for `requireNativeModule('Foo').bar()` — the canonical Expo JS consumer
|
||||
// form. This pass joins them: for each such JS call it synthesizes a calls
|
||||
// edge to every Expo native implementation of that (module, method).
|
||||
//
|
||||
// Full recompute and idempotent: graph.AddEdge dedupes by key,
|
||||
// graph.EvictFile drops the edge in both directions on reindex. Edges
|
||||
// ride at ast_inferred with synthesizer provenance.
|
||||
//
|
||||
// Returns the number of Expo bridge edges synthesized.
|
||||
func ResolveExpoModuleBridge(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
type modKey struct{ module, method string }
|
||||
expoByKey := map[modKey][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod) {
|
||||
if n == nil || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
mod, _ := n.Meta["expo_module"].(string)
|
||||
meth, _ := n.Meta["expo_method"].(string)
|
||||
if mod == "" || meth == "" {
|
||||
continue
|
||||
}
|
||||
expoByKey[modKey{mod, meth}] = append(expoByKey[modKey{mod, meth}], n)
|
||||
}
|
||||
if len(expoByKey) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type pairKey struct{ from, to string }
|
||||
seenPair := map[pairKey]bool{}
|
||||
var batch []*graph.Edge
|
||||
synthesized := 0
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil || e.From == "" {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != rnNativeVia {
|
||||
continue
|
||||
}
|
||||
mod, _ := e.Meta["rn_module"].(string)
|
||||
meth, _ := e.Meta["rn_method"].(string)
|
||||
if mod == "" || meth == "" {
|
||||
continue
|
||||
}
|
||||
for _, native := range expoByKey[modKey{mod, meth}] {
|
||||
if native.ID == "" || native.ID == e.From {
|
||||
continue
|
||||
}
|
||||
pk := pairKey{from: e.From, to: native.ID}
|
||||
if seenPair[pk] {
|
||||
continue
|
||||
}
|
||||
seenPair[pk] = true
|
||||
batch = append(batch, &graph.Edge{
|
||||
From: e.From,
|
||||
To: native.ID,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: e.FilePath,
|
||||
Line: e.Line,
|
||||
Confidence: 0.6,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, 0.6),
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{
|
||||
"via": expoBridgeVia,
|
||||
"expo_module": mod,
|
||||
"expo_method": meth,
|
||||
"native_language": native.Language,
|
||||
MetaSynthesizedBy: SynthExpoModules,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
})
|
||||
synthesized++
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return synthesized
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func expoNativeNode(g graph.Store, id, lang, module, method string) {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: id, Kind: graph.KindMethod, Name: lastSeg(id),
|
||||
FilePath: id, StartLine: 1, Language: lang,
|
||||
Meta: map[string]any{"expo_module": module, "expo_method": method},
|
||||
})
|
||||
}
|
||||
|
||||
// expoJSCall adds a JS caller plus the rn.native placeholder edge a
|
||||
// requireNativeModule('mod').method() call produces.
|
||||
func expoJSCall(g graph.Store, callerID, module, method string) {
|
||||
g.AddNode(&graph.Node{ID: callerID, Kind: graph.KindFunction, Name: lastSeg(callerID), FilePath: "app.ts", Language: "typescript"})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: callerID, To: "unresolved::rn::" + module + "::" + method,
|
||||
Kind: graph.EdgeCalls, FilePath: "app.ts", Line: 4,
|
||||
Meta: map[string]any{"via": rnNativeVia, "rn_module": module, "rn_method": method},
|
||||
})
|
||||
}
|
||||
|
||||
func expoBridgeEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To == to && e.Kind == graph.EdgeCalls && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == expoBridgeVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveExpoModuleBridge_Pairs(t *testing.T) {
|
||||
g := graph.New()
|
||||
expoJSCall(g, "app.ts::useMath", "Math", "add")
|
||||
expoNativeNode(g, "ios/MathModule.swift::expo:Math:add", "swift", "Math", "add")
|
||||
expoNativeNode(g, "android/MathModule.kt::expo:Math:add", "kotlin", "Math", "add")
|
||||
|
||||
n := ResolveExpoModuleBridge(g)
|
||||
assert.Equal(t, 2, n, "JS call bridges to both the Swift and Kotlin Expo impls")
|
||||
|
||||
sw := expoBridgeEdge(g, "app.ts::useMath", "ios/MathModule.swift::expo:Math:add")
|
||||
require.NotNil(t, sw)
|
||||
assert.Equal(t, SynthExpoModules, sw.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, "swift", sw.Meta["native_language"])
|
||||
require.NotNil(t, expoBridgeEdge(g, "app.ts::useMath", "android/MathModule.kt::expo:Math:add"))
|
||||
}
|
||||
|
||||
func TestResolveExpoModuleBridge_NoMatch(t *testing.T) {
|
||||
g := graph.New()
|
||||
expoJSCall(g, "app.ts::useMath", "Math", "add")
|
||||
expoNativeNode(g, "ios/Other.swift::expo:Other:sub", "swift", "Other", "sub")
|
||||
assert.Equal(t, 0, ResolveExpoModuleBridge(g))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Express middleware/controller/service name-resolution. A route's named
|
||||
// handler args — `app.get('/u', authMiddleware, UserController.list)` — are
|
||||
// stamped by the JS/TS extractor as placeholder refs from the route's
|
||||
// handler anchor (Meta["express_handler_ref"]). This pass binds them by the
|
||||
// directory-convention heuristic: a middleware ident → a definition under
|
||||
// /middleware/, an `XController.method` → the method on the XController class
|
||||
// (preferring /controllers/), an `XService.method` → /services/, /helpers/.
|
||||
|
||||
// expressControllerDirs / expressServiceDirs / expressMiddlewareDirs are the
|
||||
// conventional directories each handler shape prefers.
|
||||
var (
|
||||
expressMiddlewareDirs = []string{"/middleware/", "/middlewares/"}
|
||||
expressControllerDirs = []string{"/controllers/", "/controller/"}
|
||||
expressServiceDirs = []string{"/services/", "/service/", "/helpers/", "/utils/"}
|
||||
)
|
||||
|
||||
// ResolveExpressHandlers binds express named-handler placeholder refs to
|
||||
// their definitions by convention. Returns the number bound.
|
||||
func ResolveExpressHandlers(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
classMethods := expressClassMethodIndex(g)
|
||||
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := e.Meta["express_handler_ref"]; !ok {
|
||||
continue
|
||||
}
|
||||
if !graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
fromFile := ""
|
||||
if n := g.GetNode(e.From); n != nil {
|
||||
fromFile = n.FilePath
|
||||
}
|
||||
|
||||
var targetID string
|
||||
if cls, _ := e.Meta["express_ref_class"].(string); cls != "" {
|
||||
method, _ := e.Meta["express_ref_method"].(string)
|
||||
targetID = expressResolveMember(g, cls, method, fromFile, classMethods)
|
||||
} else if name, _ := e.Meta["express_ref_name"].(string); name != "" {
|
||||
id, _ := ResolveByConvention(g, name, "Middleware", expressMiddlewareDirs, fromFile)
|
||||
targetID = id
|
||||
}
|
||||
if targetID == "" {
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = targetID
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0.85
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, 0.85)
|
||||
StampSynthesized(e, SynthExpressResolve)
|
||||
reindex = append(reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
resolved++
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// expressResolveMember binds an XController.method / XService.method handler:
|
||||
// it resolves the class by convention (preferring the dir its suffix implies)
|
||||
// then returns the method node on that class.
|
||||
func expressResolveMember(g graph.Store, cls, method, fromFile string, classMethods map[string]map[string][]*graph.Node) string {
|
||||
if method == "" {
|
||||
return ""
|
||||
}
|
||||
preferDirs := expressServiceDirs
|
||||
switch {
|
||||
case strings.HasSuffix(cls, "Controller"):
|
||||
preferDirs = expressControllerDirs
|
||||
case strings.HasSuffix(cls, "Service"), strings.HasSuffix(cls, "Helper"), strings.HasSuffix(cls, "Utils"):
|
||||
preferDirs = expressServiceDirs
|
||||
}
|
||||
classID, _ := ResolveByConvention(g, cls, "", preferDirs, fromFile)
|
||||
className := cls
|
||||
if classID != "" {
|
||||
className = expressSimpleName(classID)
|
||||
}
|
||||
methods := classMethods[className]
|
||||
if methods == nil {
|
||||
return ""
|
||||
}
|
||||
cands := methods[method]
|
||||
if len(cands) == 1 {
|
||||
return cands[0].ID
|
||||
}
|
||||
// Multiple same-named methods across classes of this name: prefer the one
|
||||
// whose file matches the resolved class.
|
||||
if classID != "" {
|
||||
classFile := ""
|
||||
if cn := g.GetNode(classID); cn != nil {
|
||||
classFile = cn.FilePath
|
||||
}
|
||||
for _, m := range cands {
|
||||
if m.FilePath == classFile {
|
||||
return m.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// expressClassMethodIndex maps class simple-name → method-name → method nodes
|
||||
// via the EdgeMemberOf edges.
|
||||
func expressClassMethodIndex(g graph.Store) map[string]map[string][]*graph.Node {
|
||||
classOf := map[string]string{}
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
if e != nil && e.From != "" && e.To != "" {
|
||||
classOf[e.From] = expressSimpleName(e.To)
|
||||
}
|
||||
}
|
||||
out := map[string]map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod, graph.KindFunction) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
cls := classOf[n.ID]
|
||||
if cls == "" {
|
||||
continue
|
||||
}
|
||||
if out[cls] == nil {
|
||||
out[cls] = map[string][]*graph.Node{}
|
||||
}
|
||||
out[cls][n.Name] = append(out[cls][n.Name], n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expressSimpleName returns the last `::`-delimited segment of a node ID.
|
||||
func expressSimpleName(id string) string {
|
||||
if i := strings.LastIndex(id, "::"); i >= 0 {
|
||||
return id[i+2:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func expressAnchor(g *graph.Graph, id, file string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "route handler", FilePath: file, Language: "javascript",
|
||||
Meta: map[string]any{"express_handler": true}})
|
||||
}
|
||||
|
||||
func expressRef(g *graph.Graph, from, file, to string, meta map[string]any) {
|
||||
meta["express_handler_ref"] = true
|
||||
g.AddEdge(&graph.Edge{From: from, To: to, Kind: graph.EdgeCalls, FilePath: file, Meta: meta})
|
||||
}
|
||||
|
||||
func expressClassMethod(g *graph.Graph, classID, methodID, file, class, method string) {
|
||||
g.AddNode(&graph.Node{ID: classID, Kind: graph.KindType, Name: class, FilePath: file})
|
||||
g.AddNode(&graph.Node{ID: methodID, Kind: graph.KindMethod, Name: method, FilePath: file})
|
||||
g.AddEdge(&graph.Edge{From: methodID, To: classID, Kind: graph.EdgeMemberOf})
|
||||
}
|
||||
|
||||
func synthExpressEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthExpressResolve {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveExpressHandlers_FixtureTree(t *testing.T) {
|
||||
g := graph.New()
|
||||
const anchor = "src/routes/users.js::express-handler@1"
|
||||
expressAnchor(g, anchor, "src/routes/users.js")
|
||||
|
||||
// /middleware/auth.js exports `auth`; a decoy auth lives in /util/.
|
||||
convNode(g, "src/middleware/auth.js::auth", "src/middleware/auth.js", "auth")
|
||||
convNode(g, "src/util/auth.js::auth", "src/util/auth.js", "auth")
|
||||
expressClassMethod(g, "src/controllers/UserController.js::UserController", "src/controllers/UserController.js::UserController.list", "src/controllers/UserController.js", "UserController", "list")
|
||||
expressClassMethod(g, "src/services/UserService.js::UserService", "src/services/UserService.js::UserService.create", "src/services/UserService.js", "UserService", "create")
|
||||
|
||||
expressRef(g, anchor, "src/routes/users.js", "unresolved::authMiddleware", map[string]any{"express_ref_name": "authMiddleware"})
|
||||
expressRef(g, anchor, "src/routes/users.js", "unresolved::list", map[string]any{"express_ref_class": "UserController", "express_ref_method": "list"})
|
||||
expressRef(g, anchor, "src/routes/users.js", "unresolved::create", map[string]any{"express_ref_class": "UserService", "express_ref_method": "create"})
|
||||
|
||||
n := ResolveExpressHandlers(g)
|
||||
require.Equal(t, 3, n)
|
||||
|
||||
// Middleware: suffix-stripped + /middleware/ preference beats the decoy.
|
||||
mw := synthExpressEdge(g, anchor, "src/middleware/auth.js::auth")
|
||||
require.NotNil(t, mw, "authMiddleware binds to /middleware/auth.js")
|
||||
assert.Nil(t, synthExpressEdge(g, anchor, "src/util/auth.js::auth"))
|
||||
// Controller + service methods.
|
||||
assert.NotNil(t, synthExpressEdge(g, anchor, "src/controllers/UserController.js::UserController.list"))
|
||||
assert.NotNil(t, synthExpressEdge(g, anchor, "src/services/UserService.js::UserService.create"))
|
||||
}
|
||||
|
||||
func TestResolveExpressHandlers_UnresolvableLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
const anchor = "r.js::express-handler@1"
|
||||
expressAnchor(g, anchor, "r.js")
|
||||
// No middleware definition anywhere.
|
||||
expressRef(g, anchor, "r.js", "unresolved::ghostMiddleware", map[string]any{"express_ref_name": "ghostMiddleware"})
|
||||
|
||||
assert.Equal(t, 0, ResolveExpressHandlers(g))
|
||||
assert.Nil(t, synthExpressEdge(g, anchor, "unresolved::ghostMiddleware"))
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// attributeGoExternalCalls materialises a KindFunction node for every
|
||||
// unique `stdlib::<importPath>::<symbol>` / `dep::<importPath>::<symbol>`
|
||||
// / `external::<importPath>::<symbol>` edge target, plus a KindModule
|
||||
// parent for each owning import path. Without this pass the targets
|
||||
// are stubs in storage backends that enforce rel-table FK (the on-disk backend)
|
||||
// and invisible nodes in the in-memory backend, so a query like
|
||||
// `find_usages(stdlib::encoding/json::Marshal)`
|
||||
// can't surface "every function in this codebase that calls
|
||||
// json.Marshal" — the destination doesn't exist as a graph node.
|
||||
//
|
||||
// Mirrors the Python / Dart attributeNonGoModuleImports pass for Go.
|
||||
// Runs after resolveExtern (which classifies extern targets into the
|
||||
// three prefix buckets) so we materialise the post-classification
|
||||
// state rather than the pre-classification `unresolved::extern::*`
|
||||
// shape.
|
||||
//
|
||||
// ID conventions:
|
||||
// - Module node: `module::go:<importPath>` — shared across every
|
||||
// repo that imports the same path. Carries
|
||||
// Meta["ecosystem"]="go" and Meta["import_path"]=<path>.
|
||||
// Meta["role"]="stdlib" for stdlib paths.
|
||||
// - Symbol node: the original `stdlib::*` / `dep::*` /
|
||||
// `external::*` ID stays the symbol's ID so existing edges land
|
||||
// on it without rewriting. Carries Meta["external"]=true and
|
||||
// Meta["module_path"]=<importPath>.
|
||||
// - EdgeMemberOf: symbol → module so `get_callers` on the module
|
||||
// surfaces every symbol used from that package.
|
||||
//
|
||||
// All AddNode / AddEdge calls are idempotent on ID, so a second run
|
||||
// of this pass (incremental ResolveFile re-invocation) is a no-op.
|
||||
// extKey identifies a unique external target across the attribution
|
||||
// passes; modKey identifies its owning module. Package-level so the
|
||||
// whole-graph and single-file collectors feed one materialiser.
|
||||
//
|
||||
// repoPrefix is part of the key because stdlib stubs are per-repo (see
|
||||
// internal/graph/stub.go) — two repos on different Go SDK versions emit
|
||||
// semantically distinct `<repoA>::stdlib::fmt::Errorf` and
|
||||
// `<repoB>::stdlib::fmt::Errorf` stubs that MUST round-trip through this
|
||||
// attribution as distinct nodes, not collide into one.
|
||||
type extKey struct {
|
||||
repoPrefix, prefix, importPath, symbol string
|
||||
}
|
||||
|
||||
type modKey struct{ repoPrefix, importPath string }
|
||||
|
||||
// goExternalAttribKinds is every edge kind an extern-prefixed target can
|
||||
// show up on — the same set attributeGoBuiltins scans.
|
||||
var goExternalAttribKinds = []graph.EdgeKind{
|
||||
graph.EdgeCalls,
|
||||
graph.EdgeReferences,
|
||||
graph.EdgeReads,
|
||||
graph.EdgeArgOf,
|
||||
graph.EdgeValueFlow,
|
||||
graph.EdgeReturnsTo,
|
||||
graph.EdgeTypedAs,
|
||||
graph.EdgeReturns,
|
||||
graph.EdgeInstantiates,
|
||||
graph.EdgeCaptures,
|
||||
graph.EdgeThrows,
|
||||
}
|
||||
|
||||
func (r *Resolver) attributeGoExternalCalls() {
|
||||
// Go-only pass: skip the external-prefix edge scan when the graph has
|
||||
// no Go nodes.
|
||||
if !r.graphHasLanguage("go") {
|
||||
return
|
||||
}
|
||||
seen := map[extKey]struct{}{}
|
||||
for _, k := range goExternalAttribKinds {
|
||||
for e := range r.graph.EdgesByKind(k) {
|
||||
collectGoExternalTarget(e, seen)
|
||||
}
|
||||
}
|
||||
r.materializeGoExternalSeen(seen)
|
||||
}
|
||||
|
||||
// attributeGoExternalCallsForFile is the single-file scope of
|
||||
// attributeGoExternalCalls: an extern-prefixed target is referenced from
|
||||
// inside the edited file, so only that file's outgoing edges can
|
||||
// introduce a new one. Produces the same materialisation as the
|
||||
// whole-graph sweep for a per-save resolve.
|
||||
func (r *Resolver) attributeGoExternalCallsForFile(filePath string) {
|
||||
if !r.graphHasLanguage("go") {
|
||||
return
|
||||
}
|
||||
seen := map[extKey]struct{}{}
|
||||
for _, e := range r.fileOutEdges(filePath) {
|
||||
collectGoExternalTarget(e, seen)
|
||||
}
|
||||
r.materializeGoExternalSeen(seen)
|
||||
}
|
||||
|
||||
// collectGoExternalTarget records e's external target (if any) into seen,
|
||||
// deduping by the per-repo (prefix, path, symbol) tuple.
|
||||
func collectGoExternalTarget(e *graph.Edge, seen map[extKey]struct{}) {
|
||||
if e == nil || e.To == "" {
|
||||
return
|
||||
}
|
||||
prefix, importPath, symbol := splitGoExternalTarget(e.To)
|
||||
if prefix == "" {
|
||||
return
|
||||
}
|
||||
seen[extKey{graph.StubRepoPrefix(e.To), prefix, importPath, symbol}] = struct{}{}
|
||||
}
|
||||
|
||||
// materializeGoExternalSeen turns the collected external targets into
|
||||
// KindModule + KindFunction nodes and their EdgeMemberOf links. All
|
||||
// AddNode / AddEdge calls are idempotent on ID, so a second run (e.g. a
|
||||
// re-resolve of the same file) is a no-op.
|
||||
func (r *Resolver) materializeGoExternalSeen(seen map[extKey]struct{}) {
|
||||
if len(seen) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Materialise the parent KindModule for each unique import path,
|
||||
// then the per-symbol KindFunction. Module-side dedupe is via
|
||||
// the `modules` map; the per-symbol nodes are unique by (prefix,
|
||||
// path, symbol) by construction.
|
||||
// Module IDs are also per-repo now — a module node carries the
|
||||
// same SDK-version sensitivity its symbols do. Key includes the
|
||||
// repo prefix so two repos importing the same path get distinct
|
||||
// module nodes.
|
||||
modules := map[modKey]string{}
|
||||
for k := range seen {
|
||||
modKey := modKey{repoPrefix: k.repoPrefix, importPath: k.importPath}
|
||||
moduleID, ok := modules[modKey]
|
||||
if !ok {
|
||||
// Ecosystem + path are ONE stub segment joined by a single
|
||||
// colon (`go:<importPath>`), matching the npm convention
|
||||
// (`module::npm:<pkg>`) and every module-id consumer
|
||||
// (tools_analyze_external_calls). Passing them as two
|
||||
// StubID parts would emit `module::go::<path>` (double
|
||||
// colon) — the form that broke the attribution tests.
|
||||
moduleID = graph.StubID(k.repoPrefix, graph.StubKindModule, "go:"+k.importPath)
|
||||
modules[modKey] = moduleID
|
||||
role := "external"
|
||||
switch k.prefix {
|
||||
case "stdlib::":
|
||||
role = "stdlib"
|
||||
case "dep::":
|
||||
role = "dep"
|
||||
}
|
||||
r.graph.AddNode(&graph.Node{
|
||||
ID: moduleID,
|
||||
Kind: graph.KindModule,
|
||||
Name: lastImportSegment(k.importPath),
|
||||
Language: "go",
|
||||
Meta: map[string]any{
|
||||
"ecosystem": "go",
|
||||
"role": role,
|
||||
"import_path": k.importPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
var symbolID string
|
||||
switch k.prefix {
|
||||
case "stdlib::":
|
||||
symbolID = graph.StubID(k.repoPrefix, graph.StubKindStdlib, k.importPath, k.symbol)
|
||||
default:
|
||||
// dep:: / external:: keep their legacy unprefixed form for
|
||||
// now — they aren't covered by the stub-prefix migration
|
||||
// (different module paths already provide repo-level
|
||||
// distinction; same version pinning is enforced by go.mod
|
||||
// per-repo).
|
||||
symbolID = k.prefix + k.importPath + "::" + k.symbol
|
||||
}
|
||||
r.graph.AddNode(&graph.Node{
|
||||
ID: symbolID,
|
||||
Kind: graph.KindFunction,
|
||||
Name: k.symbol,
|
||||
Language: "go",
|
||||
Meta: map[string]any{
|
||||
"external": true,
|
||||
"module_path": k.importPath,
|
||||
"module_role": map[string]string{
|
||||
"stdlib::": "stdlib",
|
||||
"dep::": "dep",
|
||||
"external::": "external",
|
||||
}[k.prefix],
|
||||
},
|
||||
})
|
||||
// EdgeMemberOf: symbol → module. AddEdge is idempotent on the
|
||||
// edge-key tuple so a re-run doesn't duplicate.
|
||||
r.graph.AddEdge(&graph.Edge{
|
||||
From: symbolID,
|
||||
To: moduleID,
|
||||
Kind: graph.EdgeMemberOf,
|
||||
Origin: graph.OriginASTResolved,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// splitGoExternalTarget recognises the three external-target prefixes
|
||||
// the resolver emits after resolveExtern. Returns the prefix
|
||||
// (`stdlib::` / `dep::` / `external::`), the import path, and the
|
||||
// symbol name. Returns ("", "", "") for any other shape so the pass
|
||||
// can skip it cleanly.
|
||||
//
|
||||
// The stdlib case is matched via graph.IsStdlibStub so both the
|
||||
// legacy `stdlib::fmt::Errorf` shape and the per-repo-prefixed
|
||||
// `<repo>::stdlib::fmt::Errorf` shape (see internal/graph/stub.go)
|
||||
// route the same way. The returned bucket label stays `stdlib::` for
|
||||
// downstream `k.prefix == "stdlib::"` comparisons.
|
||||
func splitGoExternalTarget(target string) (prefix, importPath, symbol string) {
|
||||
var body string
|
||||
switch {
|
||||
case graph.IsStdlibStub(target):
|
||||
prefix = "stdlib::"
|
||||
body = graph.StubRest(target)
|
||||
case strings.HasPrefix(target, "dep::"):
|
||||
prefix = "dep::"
|
||||
body = strings.TrimPrefix(target, prefix)
|
||||
case strings.HasPrefix(target, "external::"):
|
||||
prefix = "external::"
|
||||
body = strings.TrimPrefix(target, prefix)
|
||||
default:
|
||||
return "", "", ""
|
||||
}
|
||||
// The body shape produced by resolveExtern is
|
||||
// `<importPath>::<symbol>`. Split on the LAST `::` because import
|
||||
// paths can include slashes but not `::`, so the rightmost
|
||||
// separator is always between path and symbol.
|
||||
sep := strings.LastIndex(body, "::")
|
||||
if sep < 0 {
|
||||
// `external::os` style (just the package, no symbol —
|
||||
// the resolveImport path). Treat the whole body as the path
|
||||
// and leave symbol empty so we still materialise the module
|
||||
// node but skip the symbol.
|
||||
return prefix, body, ""
|
||||
}
|
||||
return prefix, body[:sep], body[sep+2:]
|
||||
}
|
||||
|
||||
// lastImportSegment returns the rightmost path component, used as
|
||||
// the human-readable Name on the KindModule node. For
|
||||
// `github.com/stretchr/testify/assert` the segment is `assert`; for
|
||||
// `encoding/json` it's `json`; for `fmt` it's `fmt`.
|
||||
func lastImportSegment(importPath string) string {
|
||||
if importPath == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Base(importPath)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestAttributeGoExternalCalls_StdlibFunctionMaterialised(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
// Post-resolveExtern shape: an edge directly to stdlib::fmt::Sprintf.
|
||||
edge := &graph.Edge{From: owner, To: "stdlib::fmt::Sprintf", Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoExternalCalls()
|
||||
|
||||
// The symbol becomes a KindFunction with the right metadata.
|
||||
sym := g.GetNode("stdlib::fmt::Sprintf")
|
||||
require.NotNil(t, sym, "stdlib symbol must be materialised as a node")
|
||||
assert.Equal(t, graph.KindFunction, sym.Kind)
|
||||
assert.Equal(t, "Sprintf", sym.Name)
|
||||
assert.Equal(t, "go", sym.Language)
|
||||
assert.Equal(t, true, sym.Meta["external"])
|
||||
assert.Equal(t, "fmt", sym.Meta["module_path"])
|
||||
assert.Equal(t, "stdlib", sym.Meta["module_role"])
|
||||
|
||||
// And a KindModule parent under module::go:fmt with role=stdlib.
|
||||
mod := g.GetNode("module::go:fmt")
|
||||
require.NotNil(t, mod, "module parent must be materialised")
|
||||
assert.Equal(t, graph.KindModule, mod.Kind)
|
||||
assert.Equal(t, "fmt", mod.Name)
|
||||
assert.Equal(t, "stdlib", mod.Meta["role"])
|
||||
assert.Equal(t, "go", mod.Meta["ecosystem"])
|
||||
|
||||
// EdgeMemberOf: symbol -> module.
|
||||
var foundLink bool
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
if e.From == "stdlib::fmt::Sprintf" && e.To == "module::go:fmt" {
|
||||
foundLink = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundLink, "symbol must be linked to its module via EdgeMemberOf")
|
||||
}
|
||||
|
||||
func TestAttributeGoExternalCalls_DepUsesFullImportPath(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: owner, To: "dep::github.com/stretchr/testify/assert::True", Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 7})
|
||||
|
||||
New(g).attributeGoExternalCalls()
|
||||
|
||||
sym := g.GetNode("dep::github.com/stretchr/testify/assert::True")
|
||||
require.NotNil(t, sym)
|
||||
assert.Equal(t, "True", sym.Name)
|
||||
assert.Equal(t, "github.com/stretchr/testify/assert", sym.Meta["module_path"])
|
||||
assert.Equal(t, "dep", sym.Meta["module_role"])
|
||||
|
||||
mod := g.GetNode("module::go:github.com/stretchr/testify/assert")
|
||||
require.NotNil(t, mod)
|
||||
assert.Equal(t, "assert", mod.Name, "module name must be the last path segment, not the full import path")
|
||||
assert.Equal(t, "dep", mod.Meta["role"])
|
||||
}
|
||||
|
||||
func TestAttributeGoExternalCalls_ModuleNodeSharedAcrossSymbols(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
// Three different functions from the same stdlib package — all
|
||||
// should attach to ONE module node, not three.
|
||||
for _, sym := range []string{"Marshal", "Unmarshal", "RawMessage"} {
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: owner, To: "stdlib::encoding/json::" + sym,
|
||||
Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 1,
|
||||
})
|
||||
}
|
||||
|
||||
New(g).attributeGoExternalCalls()
|
||||
|
||||
count := 0
|
||||
for n := range g.NodesByKind(graph.KindModule) {
|
||||
if n.ID == "module::go:encoding/json" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "exactly one KindModule per import path")
|
||||
}
|
||||
|
||||
func TestAttributeGoExternalCalls_IdempotentOnRerun(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: owner, To: "stdlib::os::Open", Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 1})
|
||||
|
||||
r := New(g)
|
||||
r.attributeGoExternalCalls()
|
||||
r.attributeGoExternalCalls() // second run must not duplicate
|
||||
|
||||
syms := 0
|
||||
for n := range g.NodesByKind(graph.KindFunction) {
|
||||
if n.ID == "stdlib::os::Open" {
|
||||
syms++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, syms, "second pass must not duplicate the symbol node")
|
||||
|
||||
memberEdges := 0
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
if e.From == "stdlib::os::Open" && e.To == "module::go:os" {
|
||||
memberEdges++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, memberEdges, "second pass must not duplicate the membership edge")
|
||||
}
|
||||
|
||||
func TestAttributeGoExternalCalls_NonExternEdgesIgnored(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
// Real intra-repo call — must not be touched.
|
||||
g.AddNode(&graph.Node{ID: "pkg/bar.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "pkg/bar.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: owner, To: "pkg/bar.go::Helper", Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 1})
|
||||
// And an unresolved bare name — also not in scope for this pass.
|
||||
g.AddEdge(&graph.Edge{From: owner, To: "unresolved::doSomething", Kind: graph.EdgeCalls, FilePath: "pkg/foo.go", Line: 2})
|
||||
|
||||
before := []string{}
|
||||
for n := range g.NodesByKind(graph.KindModule) {
|
||||
before = append(before, n.ID)
|
||||
}
|
||||
New(g).attributeGoExternalCalls()
|
||||
after := []string{}
|
||||
for n := range g.NodesByKind(graph.KindModule) {
|
||||
after = append(after, n.ID)
|
||||
}
|
||||
assert.Equal(t, before, after, "no module nodes should be created when there are no extern-prefixed targets")
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// External-call placeholder synthesis.
|
||||
//
|
||||
// When code calls into an un-indexed third party (an npm / pip / cargo
|
||||
// package not in the index) or a sibling microservice's client SDK, the
|
||||
// call target can't be resolved to a real graph node. The main resolver
|
||||
// lands such a call on a bookkeeping-string terminal — `dep::<path>::<sym>`,
|
||||
// `stdlib::<path>::<sym>`, or `external::<path>` — that names no node. A
|
||||
// call-chain walk treats those exactly like an `unresolved::` placeholder:
|
||||
// graph.Engine.bfs drops any edge whose target node is missing, so
|
||||
// `get_call_chain` / `get_callers` silently lose the fact that the
|
||||
// function reaches out to an external system at all.
|
||||
//
|
||||
// This pass closes that gap. For each such edge it synthesises a single
|
||||
// shared graph node — one per (ecosystem, import path) — marked clearly
|
||||
// as external + synthetic, and retargets the call edge to it. The
|
||||
// call-chain then terminates on an explicit "external" node instead of
|
||||
// vanishing.
|
||||
//
|
||||
// Gated, and noise-filtered:
|
||||
//
|
||||
// - The whole pass is opt-in (`.gortex.yaml::index::synthesize_external_calls`).
|
||||
// Default-off, so behaviour is unchanged unless a project asks for it.
|
||||
//
|
||||
// - Even when on, synthesis is restricted to *genuine* external
|
||||
// packages. A call into a language built-in or standard library is
|
||||
// noise — every Go file calls `fmt`, every Node file requires `path`
|
||||
// — and materialising a node for each would bury the real
|
||||
// cross-system edges. isLanguageStdlib drops those. The decision is
|
||||
// language-aware: the same un-dotted name (`crypto`) is the Go stdlib
|
||||
// but, in a TS file, a real npm package, so the caller's language
|
||||
// selects the rule.
|
||||
//
|
||||
// The pass is a full recompute and idempotent: a synthetic node has a
|
||||
// deterministic ID, so re-running rewrites each edge to the same target
|
||||
// and graph.AddNode dedupes. Runs after the main resolution pass and the
|
||||
// cross-package guard — by then every edge that was going to land on a
|
||||
// real node already has, and the cross_pkg_guard has reverted its weak
|
||||
// name-only guesses back to bare `unresolved::` placeholders. Those bare
|
||||
// placeholders carry no import path and are deliberately left alone here:
|
||||
// without import evidence we cannot tell a genuine external from an
|
||||
// un-indexed in-repo symbol.
|
||||
//
|
||||
// Returns the number of call edges retargeted onto a synthetic node.
|
||||
|
||||
// externalCallPrefix is the placeholder namespace for a synthesised
|
||||
// external-call node. Deliberately distinct from the `ext::` namespace
|
||||
// the goanalysis externals pass uses (those are type-checker-grounded
|
||||
// symbols with a module attribution) and from `external::` / `dep::` /
|
||||
// `stdlib::` (bookkeeping strings that name no node) — so the
|
||||
// `analyze kind=external_calls` surface, which keys on `ext::` + the
|
||||
// `external` Meta flag, never mistakes a synthetic node for one of its
|
||||
// own attributed symbols.
|
||||
const externalCallPrefix = "external-call::"
|
||||
|
||||
// SynthesizeExternalCalls materialises a synthetic placeholder node for
|
||||
// every call edge that lands on an un-indexed external package / sibling
|
||||
// service and retargets the edge onto it, so call-chain traversals keep
|
||||
// the external hop visible. Enabled is the opt-in gate
|
||||
// (`.gortex.yaml::index::synthesize_external_calls`); when false the
|
||||
// pass is a no-op and the graph is untouched.
|
||||
func SynthesizeExternalCalls(g graph.Store, enabled bool) int {
|
||||
if g == nil || !enabled {
|
||||
return 0
|
||||
}
|
||||
return synthesizeExternalCalls(g, func() []*graph.Edge { return externalCallCandidateEdges(g) })
|
||||
}
|
||||
|
||||
// SynthesizeExternalCallsForFiles is the incremental counterpart of
|
||||
// SynthesizeExternalCalls: it materialises external-call nodes for only
|
||||
// the out-edges of the given changed files, so a single-file reindex does
|
||||
// O(edited-file) work instead of the full-graph recompute. The synthetic
|
||||
// per-package nodes are shared (deterministic ID), so a file that adds a
|
||||
// caller for an already-materialised package just dedups onto the existing
|
||||
// node; graph.EvictFile drops a removed file's synthesised edges before
|
||||
// reindex, so no orphan-cleanup pass is needed. A no-op when disabled or
|
||||
// when no files changed.
|
||||
func SynthesizeExternalCallsForFiles(g graph.Store, enabled bool, files []string) int {
|
||||
if g == nil || !enabled || len(files) == 0 {
|
||||
return 0
|
||||
}
|
||||
return synthesizeExternalCalls(g, func() []*graph.Edge { return externalCallCandidateEdgesForFiles(g, files) })
|
||||
}
|
||||
|
||||
// SynthesizeExternalCallsForRepos is the repo-scoped counterpart used by the
|
||||
// end-of-batch global passes when only some repos re-indexed: it materialises
|
||||
// external-call nodes for the out-edges of the changed repos' symbols only, so
|
||||
// the janitor pays O(changed-repo edges) instead of a whole-graph recompute. An
|
||||
// external terminal always originates in the repo that made the call, so an
|
||||
// unchanged repo's synthesised edges (already on disk, never dropped) need no
|
||||
// re-work. The shared per-package nodes are deterministic, so a call into an
|
||||
// already-materialised package dedups onto the existing node. A no-op when
|
||||
// disabled or when no repo is in scope.
|
||||
func SynthesizeExternalCallsForRepos(g graph.Store, enabled bool, prefixes map[string]bool) int {
|
||||
if g == nil || !enabled || len(prefixes) == 0 {
|
||||
return 0
|
||||
}
|
||||
return synthesizeExternalCalls(g, func() []*graph.Edge { return externalCallCandidateEdgesForRepos(g, prefixes) })
|
||||
}
|
||||
|
||||
// synthesizeExternalCalls is the shared materialisation core. collect runs
|
||||
// under the resolve lock and returns the candidate call / reference edges
|
||||
// (external-package terminals plus any already-synthesised external-call::
|
||||
// edges, so the returned count stays "edges terminating on a synthetic
|
||||
// node after the pass"). Each genuine external terminal gets a shared
|
||||
// per-(ecosystem, import path) node and the edge is retargeted onto it.
|
||||
func synthesizeExternalCalls(g graph.Store, collect func() []*graph.Edge) int {
|
||||
// Serialise against the other graph-wide passes that mutate the
|
||||
// graph (markTestSymbolsAndEmitEdges, ResolveTemporalCalls,
|
||||
// reach.BuildIndex). This pass calls AddNode and ReindexEdge; a
|
||||
// concurrent reader walking AllNodes / AllEdges would otherwise
|
||||
// trip the runtime's concurrent map access check.
|
||||
mu := g.ResolveMutex()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
synthesized := 0
|
||||
var reindexBatch []graph.EdgeReindex
|
||||
type candidate struct {
|
||||
edge *graph.Edge
|
||||
ecosystem, importPath string
|
||||
}
|
||||
var candidates []candidate
|
||||
fromIDSet := map[string]struct{}{}
|
||||
for _, e := range collect() {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
// Already pointing at a synthetic node — a prior run landed it.
|
||||
// Count it (the return value is "call edges terminating on a
|
||||
// synthetic node after the pass") and leave it untouched, so
|
||||
// re-running is a stable no-op.
|
||||
if strings.HasPrefix(e.To, externalCallPrefix) {
|
||||
synthesized++
|
||||
continue
|
||||
}
|
||||
ecosystem, importPath, ok := parseExternalCallTarget(e.To)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{edge: e, ecosystem: ecosystem, importPath: importPath})
|
||||
if e.From != "" {
|
||||
fromIDSet[e.From] = struct{}{}
|
||||
}
|
||||
}
|
||||
fromList := make([]string, 0, len(fromIDSet))
|
||||
for id := range fromIDSet {
|
||||
fromList = append(fromList, id)
|
||||
}
|
||||
callerNodes := g.GetNodesByIDs(fromList)
|
||||
|
||||
for _, c := range candidates {
|
||||
e := c.edge
|
||||
callerLang := ""
|
||||
if from := callerNodes[e.From]; from != nil && from.Language != "" {
|
||||
callerLang = from.Language
|
||||
} else {
|
||||
callerLang = langFamilyFromExt(e.FilePath)
|
||||
}
|
||||
if isLanguageStdlib(callerLang, c.importPath) {
|
||||
// Language built-in / standard library — noise. Leave the
|
||||
// edge on its bookkeeping-string terminal; a stdlib hop is
|
||||
// not a cross-system call worth a call-chain node.
|
||||
continue
|
||||
}
|
||||
|
||||
nodeID := externalCallNodeID(c.ecosystem, c.importPath)
|
||||
if g.GetNode(nodeID) == nil {
|
||||
g.AddNode(newExternalCallNode(nodeID, c.ecosystem, c.importPath, callerLang))
|
||||
}
|
||||
|
||||
oldTo := e.To
|
||||
e.To = nodeID
|
||||
// The edge now lands on a real (synthetic) node. It is an
|
||||
// inferred, name-only-grade binding — the import path tells us
|
||||
// which package, never the specific callee symbol, and the
|
||||
// synthetic node is per-package — so it rides at the weakest
|
||||
// tier.
|
||||
e.Origin = graph.OriginTextMatched
|
||||
e.Confidence = 0.5
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(e.Kind, e.Confidence)
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta["external_call"] = true
|
||||
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
synthesized++
|
||||
}
|
||||
if len(reindexBatch) > 0 {
|
||||
g.ReindexEdges(reindexBatch)
|
||||
}
|
||||
return synthesized
|
||||
}
|
||||
|
||||
// externalCallCandidateEdges returns the call / reference edges whose
|
||||
// target is an un-indexed external-package terminal (dep:: / stdlib:: /
|
||||
// external::, including the per-repo-prefixed stdlib form) or an
|
||||
// already-synthesised external-call:: node. It uses the
|
||||
// ExternalCallCandidates pushdown capability when the backend implements
|
||||
// it — the disk backend then selects exactly these rows instead of
|
||||
// marshaling every call edge in the graph and filtering Go-side — and
|
||||
// falls back to the EdgesByKinds scan + prefix filter otherwise.
|
||||
func externalCallCandidateEdges(g graph.Store) []*graph.Edge {
|
||||
if cap, ok := g.(graph.ExternalCallCandidates); ok {
|
||||
return cap.ExternalCallCandidateEdges()
|
||||
}
|
||||
var out []*graph.Edge
|
||||
for e := range edgesByKinds(g, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}) {
|
||||
if e != nil && isExternalCandidateTarget(e.To) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// externalCallCandidateEdgesForFiles returns the external-terminal call /
|
||||
// reference out-edges originating in the given files only — the O(edited
|
||||
// files) input for incremental synthesis. Edges are gathered from the
|
||||
// out-edges of every symbol the files define.
|
||||
func externalCallCandidateEdgesForFiles(g graph.Store, files []string) []*graph.Edge {
|
||||
idSet := make(map[string]struct{})
|
||||
var ids []string
|
||||
for _, f := range files {
|
||||
for _, n := range g.GetFileNodes(f) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if _, seen := idSet[n.ID]; seen {
|
||||
continue
|
||||
}
|
||||
idSet[n.ID] = struct{}{}
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []*graph.Edge
|
||||
for _, edges := range g.GetOutEdgesByNodeIDs(ids) {
|
||||
for _, e := range edges {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
||||
continue
|
||||
}
|
||||
if isExternalCandidateTarget(e.To) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// externalCallCandidateEdgesForRepos returns the external-terminal call /
|
||||
// reference out-edges originating in the given changed repos — the O(changed
|
||||
// repo) input for the end-of-batch scoped synthesis. GetRepoEdges is one
|
||||
// backend query per repo (the out-edges of every symbol the repo defines), so
|
||||
// this never materialises the whole graph's call edges.
|
||||
func externalCallCandidateEdgesForRepos(g graph.Store, prefixes map[string]bool) []*graph.Edge {
|
||||
var out []*graph.Edge
|
||||
for prefix := range prefixes {
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range g.GetRepoEdges(prefix) {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
||||
continue
|
||||
}
|
||||
if isExternalCandidateTarget(e.To) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isExternalCandidateTarget reports whether a target string is one that
|
||||
// synthesizeExternalCalls considers: an external-package terminal or an
|
||||
// already-materialised external-call:: node (kept so the pass's return
|
||||
// count stays stable across re-runs).
|
||||
func isExternalCandidateTarget(to string) bool {
|
||||
if strings.HasPrefix(to, externalCallPrefix) {
|
||||
return true
|
||||
}
|
||||
_, _, ok := parseExternalCallTarget(to)
|
||||
return ok
|
||||
}
|
||||
|
||||
// parseExternalCallTarget recognises the three bookkeeping-string
|
||||
// terminals the main resolver lands an un-indexed external call on and
|
||||
// extracts (ecosystem, importPath) from each. Returns ok=false for
|
||||
// anything else — a real node ID, a bare `unresolved::` placeholder, a
|
||||
// `builtin::` terminal, or an already-synthesised `external-call::`
|
||||
// node.
|
||||
//
|
||||
// dep::<importPath>::<symbol> — resolveExtern, dotted import path
|
||||
// stdlib::<importPath>::<symbol> — resolveExtern, stdlib-shaped path
|
||||
// external::<importPath> — resolveImport (no symbol component)
|
||||
//
|
||||
// The `dep::` / `stdlib::` forms carry a trailing `::<symbol>`; it is
|
||||
// dropped — the synthetic node is per-package, so the specific callee
|
||||
// symbol is not retained. `dep` / `stdlib` here are the resolver's
|
||||
// Go-centric labels; the real stdlib-vs-third-party decision is re-made
|
||||
// language-aware by the caller via isLanguageStdlib, so both prefixes
|
||||
// feed the same path.
|
||||
func parseExternalCallTarget(target string) (ecosystem, importPath string, ok bool) {
|
||||
switch {
|
||||
case strings.HasPrefix(target, "dep::"):
|
||||
path := importPathOfExtern(strings.TrimPrefix(target, "dep::"))
|
||||
if path == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return "dep", path, true
|
||||
case graph.IsStdlibStub(target):
|
||||
// Handles both legacy `stdlib::<path>::<sym>` and the
|
||||
// per-repo-prefixed `<repo>::stdlib::<path>::<sym>` shape
|
||||
// (see internal/graph/stub.go).
|
||||
path := importPathOfExtern(graph.StubRest(target))
|
||||
if path == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return "stdlib", path, true
|
||||
case strings.HasPrefix(target, "external::"):
|
||||
path := strings.TrimPrefix(target, "external::")
|
||||
if path == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return "external", path, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// importPathOfExtern strips the trailing `::<symbol>` from a
|
||||
// `<importPath>::<symbol>` resolver terminal, returning just the import
|
||||
// path. Splitting at the final `::` keeps the path intact even in the
|
||||
// pathological case of a path that itself contains `::`. Returns "" when
|
||||
// the string carries no `::` separator at all.
|
||||
func importPathOfExtern(s string) string {
|
||||
i := strings.LastIndex(s, "::")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[:i]
|
||||
}
|
||||
|
||||
// externalCallNodeID is the deterministic ID of the synthetic node for
|
||||
// one (ecosystem, importPath) pair. Deterministic so a re-run of the
|
||||
// pass retargets onto the same node and graph.AddNode dedupes — the
|
||||
// node is shared by every call into that package.
|
||||
func externalCallNodeID(ecosystem, importPath string) string {
|
||||
return externalCallPrefix + ecosystem + "::" + importPath
|
||||
}
|
||||
|
||||
// newExternalCallNode builds the synthetic placeholder node for an
|
||||
// un-indexed external package. It is marked unmistakably as both
|
||||
// synthetic and external so analyzers can filter it: `synthetic: true`
|
||||
// keeps it out of dead-code / hotspot / coverage rollups that only mean
|
||||
// to score real source symbols, and `external_call: true` lets a query
|
||||
// pick out exactly the cross-system terminals this pass created.
|
||||
func newExternalCallNode(nodeID, ecosystem, importPath, callerLang string) *graph.Node {
|
||||
return &graph.Node{
|
||||
ID: nodeID,
|
||||
Kind: graph.KindModule,
|
||||
Name: importPath,
|
||||
QualName: importPath,
|
||||
// A synthetic FilePath that can never collide with a real
|
||||
// source file, mirroring the goanalysis externals pass's
|
||||
// `external::go:<path>` convention. Keeps byFile buckets clean.
|
||||
FilePath: externalCallPrefix + ecosystem + ":" + importPath,
|
||||
Language: callerLang,
|
||||
Meta: map[string]any{
|
||||
"synthetic": true,
|
||||
"external_call": true,
|
||||
"import_path": importPath,
|
||||
"ecosystem": ecosystem,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// langFamilyFromExt maps a file extension to the coarse language label
|
||||
// stored on graph nodes. Distinct from builtins.go::langFromFilePath,
|
||||
// which collapses ts→ts/js→js for the built-in method tables; here we
|
||||
// want the node-level Language value ("typescript", "go", …) so the
|
||||
// stdlib rule below can be keyed the same way for caller-node hits and
|
||||
// file-extension fallbacks.
|
||||
func langFamilyFromExt(p string) string {
|
||||
switch filepath.Ext(p) {
|
||||
case ".go":
|
||||
return "go"
|
||||
case ".js", ".jsx", ".mjs", ".cjs":
|
||||
return "javascript"
|
||||
case ".ts", ".tsx", ".mts", ".cts":
|
||||
return "typescript"
|
||||
case ".py":
|
||||
return "python"
|
||||
case ".rs":
|
||||
return "rust"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isLanguageStdlib reports whether importPath addresses the language's
|
||||
// built-in standard library rather than a genuine third-party package.
|
||||
// This is the noise filter: a stdlib hop (`fmt`, `os`, `node:path`) is
|
||||
// not a cross-system call and gets no synthetic node.
|
||||
//
|
||||
// The decision is language-specific because the same path shape means
|
||||
// different things per ecosystem — an un-dotted single segment is the
|
||||
// Go stdlib but, for npm / pip, an ordinary package name. When the
|
||||
// caller's language is unknown the import path is treated as external
|
||||
// (return false): a missed-filter false positive is one extra node,
|
||||
// while a wrong-filter false negative would drop a real external edge.
|
||||
func isLanguageStdlib(lang, importPath string) bool {
|
||||
if importPath == "" {
|
||||
return false
|
||||
}
|
||||
switch lang {
|
||||
case "go":
|
||||
// Go stdlib import paths have no dot in their first segment
|
||||
// (`fmt`, `net/http`, `encoding/json`); third-party modules
|
||||
// always lead with a domain (`github.com/...`). Same heuristic
|
||||
// the resolver's stdlib/dep split already uses.
|
||||
return isStdlibLike(importPath)
|
||||
case "python":
|
||||
return isPythonStdlib(pyTopLevelModule(importPath))
|
||||
case "javascript", "typescript":
|
||||
return isNodeCoreModule(importPath)
|
||||
case "rust":
|
||||
// The Rust standard distribution: std / core / alloc / proc_macro.
|
||||
// `test` is also distribution-shipped. Everything else is a crate.
|
||||
root := importPath
|
||||
if i := strings.IndexAny(root, ":/"); i >= 0 {
|
||||
root = root[:i]
|
||||
}
|
||||
switch root {
|
||||
case "std", "core", "alloc", "proc_macro", "test":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "java", "kotlin", "scala":
|
||||
// JVM platform packages: the JDK (java.* / javax.*), the Jakarta
|
||||
// EE successor (jakarta.*), and the JDK-internal trees (jdk.* /
|
||||
// sun.* / com.sun.*). Everything else — including Kotlin/Scala
|
||||
// stdlibs, which ship as ordinary Maven artifacts — is treated as
|
||||
// a genuine dependency.
|
||||
return hasDottedPrefix(importPath, "java", "javax", "jakarta", "jdk", "sun") ||
|
||||
strings.HasPrefix(importPath, "com.sun.")
|
||||
case "csharp", "fsharp":
|
||||
// The .NET base class library: System.* and Microsoft.* (the
|
||||
// framework-shipped namespaces) plus the legacy mscorlib. Third
|
||||
// party NuGet packages live under their own vendor namespaces.
|
||||
return hasDottedPrefix(importPath, "System", "Microsoft", "mscorlib", "netstandard")
|
||||
case "c", "cpp", "objc":
|
||||
// C / C++ / Objective-C: the curated standard, C++, and common
|
||||
// POSIX header set. importPath is the include path with the angle
|
||||
// brackets already stripped (`vector`, `stdio.h`, `sys/types.h`).
|
||||
return IsCppStdlibHeader(importPath)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasDottedPrefix reports whether importPath equals one of roots or has
|
||||
// it as a dotted-namespace prefix (`java` matches `java` and `java.util`
|
||||
// but not `javafx`). Used by the JVM / .NET stdlib filters where the
|
||||
// platform namespace is the first dotted component.
|
||||
func hasDottedPrefix(importPath string, roots ...string) bool {
|
||||
for _, r := range roots {
|
||||
if importPath == r || strings.HasPrefix(importPath, r+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pyTopLevelModule returns the first dotted component of a Python import
|
||||
// path — `os.path` → `os`, `xml.etree.ElementTree` → `xml`. The stdlib
|
||||
// membership test keys on the top-level package.
|
||||
func pyTopLevelModule(importPath string) string {
|
||||
if i := strings.IndexByte(importPath, '.'); i >= 0 {
|
||||
return importPath[:i]
|
||||
}
|
||||
return importPath
|
||||
}
|
||||
|
||||
// isNodeCoreModule reports whether spec names a Node.js built-in module.
|
||||
// Accepts both the bare form (`fs`) and the `node:` protocol form
|
||||
// (`node:fs`) — modern Node code uses the prefixed spelling. A subpath
|
||||
// like `stream/promises` is matched on its first segment.
|
||||
func isNodeCoreModule(spec string) bool {
|
||||
s := strings.TrimPrefix(spec, "node:")
|
||||
if i := strings.IndexByte(s, '/'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
_, ok := nodeCoreModules[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// nodeCoreModules is the set of Node.js standard-library module names.
|
||||
// Calls into these are runtime built-ins, not third-party dependencies,
|
||||
// so they are filtered out of external-call synthesis.
|
||||
var nodeCoreModules = map[string]struct{}{
|
||||
"assert": {}, "async_hooks": {}, "buffer": {}, "child_process": {},
|
||||
"cluster": {}, "console": {}, "constants": {}, "crypto": {},
|
||||
"dgram": {}, "diagnostics_channel": {}, "dns": {}, "domain": {},
|
||||
"events": {}, "fs": {}, "http": {}, "http2": {}, "https": {},
|
||||
"inspector": {}, "module": {}, "net": {}, "os": {}, "path": {},
|
||||
"perf_hooks": {}, "process": {}, "punycode": {}, "querystring": {},
|
||||
"readline": {}, "repl": {}, "stream": {}, "string_decoder": {},
|
||||
"sys": {}, "timers": {}, "tls": {}, "trace_events": {}, "tty": {},
|
||||
"url": {}, "util": {}, "v8": {}, "vm": {}, "wasi": {},
|
||||
"worker_threads": {}, "zlib": {},
|
||||
}
|
||||
|
||||
// isPythonStdlib reports whether a top-level module name belongs to the
|
||||
// Python standard library. The set covers the modules that realistically
|
||||
// surface in extracted call edges; an unlisted stdlib module is treated
|
||||
// as external (one extra synthetic node) rather than risk filtering a
|
||||
// real package.
|
||||
func isPythonStdlib(top string) bool {
|
||||
_, ok := pythonStdlibModules[top]
|
||||
return ok
|
||||
}
|
||||
|
||||
// pythonStdlibModules is the set of Python standard-library top-level
|
||||
// package names. Calls into these are interpreter built-ins, not pip
|
||||
// dependencies, and are filtered out of external-call synthesis.
|
||||
var pythonStdlibModules = map[string]struct{}{
|
||||
"abc": {}, "argparse": {}, "array": {}, "ast": {}, "asyncio": {},
|
||||
"base64": {}, "bisect": {}, "builtins": {}, "calendar": {},
|
||||
"collections": {}, "concurrent": {}, "contextlib": {}, "copy": {},
|
||||
"csv": {}, "ctypes": {}, "dataclasses": {}, "datetime": {},
|
||||
"decimal": {}, "difflib": {}, "dis": {}, "enum": {}, "errno": {},
|
||||
"functools": {}, "gc": {}, "getpass": {}, "glob": {}, "gzip": {},
|
||||
"hashlib": {}, "heapq": {}, "hmac": {}, "html": {}, "http": {},
|
||||
"importlib": {}, "inspect": {}, "io": {}, "ipaddress": {},
|
||||
"itertools": {}, "json": {}, "logging": {}, "math": {}, "mmap": {},
|
||||
"multiprocessing": {}, "operator": {}, "os": {}, "pathlib": {},
|
||||
"pickle": {}, "platform": {}, "pprint": {}, "queue": {}, "random": {},
|
||||
"re": {}, "secrets": {}, "select": {}, "shlex": {}, "shutil": {},
|
||||
"signal": {}, "socket": {}, "sqlite3": {}, "ssl": {}, "stat": {},
|
||||
"string": {}, "struct": {}, "subprocess": {}, "sys": {},
|
||||
"tempfile": {}, "textwrap": {}, "threading": {}, "time": {},
|
||||
"timeit": {}, "traceback": {}, "types": {}, "typing": {},
|
||||
"unittest": {}, "urllib": {}, "uuid": {}, "warnings": {},
|
||||
"weakref": {}, "xml": {}, "zipfile": {}, "zlib": {},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// outCallTarget returns the To of the single call/reference out-edge of
|
||||
// nodeID (the tests below give each caller exactly one).
|
||||
func outCallTarget(g graph.Store, nodeID string) string {
|
||||
for _, e := range g.GetOutEdges(nodeID) {
|
||||
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
|
||||
return e.To
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestSynthesizeExternalCallsForFiles(t *testing.T) {
|
||||
g := graph.New()
|
||||
// app.go calls an external dependency.
|
||||
g.AddNode(&graph.Node{ID: "app.go::run", Kind: graph.KindFunction, Name: "run", FilePath: "app.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "app.go::run", To: "dep::github.com/stripe/stripe-go::Charge", Kind: graph.EdgeCalls, FilePath: "app.go", Line: 10})
|
||||
// other.go also calls an external dependency — it must be untouched
|
||||
// by a file-scoped pass over app.go alone.
|
||||
g.AddNode(&graph.Node{ID: "other.go::f", Kind: graph.KindFunction, Name: "f", FilePath: "other.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "other.go::f", To: "dep::github.com/aws/aws-sdk-go::New", Kind: graph.EdgeCalls, FilePath: "other.go", Line: 5})
|
||||
|
||||
n := SynthesizeExternalCallsForFiles(g, true, []string{"app.go"})
|
||||
assert.Equal(t, 1, n, "only app.go's external call is synthesized")
|
||||
|
||||
want := externalCallNodeID("dep", "github.com/stripe/stripe-go")
|
||||
assert.Equal(t, want, outCallTarget(g, "app.go::run"), "app.go's edge retargeted onto the synthetic node")
|
||||
require.NotNil(t, g.GetNode(want), "synthetic external node materialised")
|
||||
|
||||
assert.Equal(t, "dep::github.com/aws/aws-sdk-go::New", outCallTarget(g, "other.go::f"),
|
||||
"a file outside the scope keeps its raw external terminal")
|
||||
}
|
||||
|
||||
func TestSynthesizeExternalCallsForFiles_StdlibFiltered(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "app.go::run", Kind: graph.KindFunction, Name: "run", FilePath: "app.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "app.go::run", To: "stdlib::fmt::Sprintf", Kind: graph.EdgeCalls, FilePath: "app.go", Line: 3})
|
||||
// A stdlib hop is noise — not synthesized.
|
||||
assert.Equal(t, 0, SynthesizeExternalCallsForFiles(g, true, []string{"app.go"}))
|
||||
assert.Equal(t, "stdlib::fmt::Sprintf", outCallTarget(g, "app.go::run"))
|
||||
}
|
||||
|
||||
func TestSynthesizeExternalCallsForFiles_GatedAndEmpty(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "app.go::run", Kind: graph.KindFunction, Name: "run", FilePath: "app.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "app.go::run", To: "dep::github.com/x/y::Z", Kind: graph.EdgeCalls, FilePath: "app.go", Line: 1})
|
||||
|
||||
assert.Equal(t, 0, SynthesizeExternalCallsForFiles(g, false, []string{"app.go"}), "disabled is a no-op")
|
||||
assert.Equal(t, 0, SynthesizeExternalCallsForFiles(g, true, nil), "no files is a no-op")
|
||||
// Untouched in both cases.
|
||||
assert.Equal(t, "dep::github.com/x/y::Z", outCallTarget(g, "app.go::run"))
|
||||
}
|
||||
|
||||
// TestSynthesizeExternalCalls_Equivalence pins that the file-scoped pass
|
||||
// over every file produces the same result as the full pass on a graph
|
||||
// where all external calls live in known files.
|
||||
func TestSynthesizeExternalCalls_Equivalence(t *testing.T) {
|
||||
build := func() graph.Store {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.go::f", Kind: graph.KindFunction, Name: "f", FilePath: "a.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "a.go::f", To: "dep::github.com/x/y::Z", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1})
|
||||
g.AddNode(&graph.Node{ID: "b.go::g", Kind: graph.KindFunction, Name: "g", FilePath: "b.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "b.go::g", To: "external::svc.internal/api", Kind: graph.EdgeReferences, FilePath: "b.go", Line: 2})
|
||||
return g
|
||||
}
|
||||
full := build()
|
||||
scoped := build()
|
||||
nf := SynthesizeExternalCalls(full, true)
|
||||
ns := SynthesizeExternalCallsForFiles(scoped, true, []string{"a.go", "b.go"})
|
||||
assert.Equal(t, nf, ns)
|
||||
assert.Equal(t, outCallTarget(full, "a.go::f"), outCallTarget(scoped, "a.go::f"))
|
||||
assert.Equal(t, outCallTarget(full, "b.go::g"), outCallTarget(scoped, "b.go::g"))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package resolver
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsLanguageStdlib_PerLanguage pins the language-aware stdlib filter
|
||||
// across every ecosystem external-call qualification now covers. The JVM
|
||||
// and .NET rows are the ones added so default-on synthesis doesn't
|
||||
// materialise a node for every JDK / BCL call.
|
||||
func TestIsLanguageStdlib_PerLanguage(t *testing.T) {
|
||||
cases := []struct {
|
||||
lang, path string
|
||||
want bool
|
||||
}{
|
||||
// Go: dotless first segment is stdlib; a domain-led path is a dep.
|
||||
{"go", "fmt", true},
|
||||
{"go", "net/http", true},
|
||||
{"go", "github.com/foo/bar", false},
|
||||
// Python: stdlib top-level packages vs pip packages.
|
||||
{"python", "os.path", true},
|
||||
{"python", "requests", false},
|
||||
// Node: core modules (bare + node: form) vs npm packages.
|
||||
{"javascript", "node:fs", true},
|
||||
{"typescript", "fs", true},
|
||||
{"typescript", "react", false},
|
||||
// Rust: std distribution vs crates.
|
||||
{"rust", "std::collections", true},
|
||||
{"rust", "tokio::sync", false},
|
||||
// JVM: JDK / Jakarta / internal trees are platform; everything
|
||||
// else (incl. Kotlin/Scala stdlibs, which ship as Maven jars) is
|
||||
// a dependency.
|
||||
{"java", "java.util.List", true},
|
||||
{"java", "javax.servlet.http", true},
|
||||
{"java", "jakarta.persistence", true},
|
||||
{"java", "sun.misc.Unsafe", true},
|
||||
{"java", "com.sun.net.httpserver", true},
|
||||
{"java", "com.google.common.collect", false},
|
||||
{"kotlin", "java.io", true},
|
||||
{"kotlin", "org.jetbrains.exposed", false},
|
||||
// `javafx` must not be swallowed by the `java` prefix rule.
|
||||
{"java", "javafx.scene", false},
|
||||
// .NET: System.* / Microsoft.* are the BCL; vendor namespaces are
|
||||
// NuGet packages.
|
||||
{"csharp", "System.Collections.Generic", true},
|
||||
{"csharp", "Microsoft.Extensions.Logging", true},
|
||||
{"csharp", "mscorlib", true},
|
||||
{"csharp", "Newtonsoft.Json", false},
|
||||
// Unknown language: treat as external (one extra node beats
|
||||
// dropping a real edge).
|
||||
{"", "anything", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isLanguageStdlib(c.lang, c.path); got != c.want {
|
||||
t.Errorf("isLanguageStdlib(%q, %q) = %v, want %v", c.lang, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalCallNodeID_StableCrossRepo documents the cross-repo
|
||||
// identity property: the synthetic node ID is a pure function of
|
||||
// (ecosystem, import path), so a call into the same package from two
|
||||
// different repositories lands on one shared node — the basis for
|
||||
// aggregating a service's external surface across repos.
|
||||
func TestExternalCallNodeID_StableCrossRepo(t *testing.T) {
|
||||
repoA := externalCallNodeID("dep", "github.com/stripe/stripe-go")
|
||||
repoB := externalCallNodeID("dep", "github.com/stripe/stripe-go")
|
||||
if repoA != repoB {
|
||||
t.Fatalf("same package must share one node ID: %q != %q", repoA, repoB)
|
||||
}
|
||||
other := externalCallNodeID("dep", "github.com/aws/aws-sdk-go")
|
||||
if repoA == other {
|
||||
t.Fatalf("distinct packages must get distinct node IDs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// buildMultiLangGraph extracts each fixture file with the extractor
|
||||
// matching its extension (.go / .py / .ts / .js) and loads the result
|
||||
// into a fresh graph. Unlike buildGraphFromSources (TS/JS only) this
|
||||
// builder spans every ecosystem the external-call synthesis pass
|
||||
// classifies, so one table can exercise Go modules, pip packages, and
|
||||
// npm packages through the same real extract → resolve pipeline.
|
||||
func buildMultiLangGraph(t *testing.T, files map[string]string) graph.Store {
|
||||
t.Helper()
|
||||
g := graph.New()
|
||||
for path, src := range files {
|
||||
var (
|
||||
nodes []*graph.Node
|
||||
edges []*graph.Edge
|
||||
)
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".go"):
|
||||
r, err := languages.NewGoExtractor().Extract(path, []byte(src))
|
||||
require.NoError(t, err, "go extract %s", path)
|
||||
nodes, edges = r.Nodes, r.Edges
|
||||
case strings.HasSuffix(path, ".py"):
|
||||
r, err := languages.NewPythonExtractor().Extract(path, []byte(src))
|
||||
require.NoError(t, err, "py extract %s", path)
|
||||
nodes, edges = r.Nodes, r.Edges
|
||||
case strings.HasSuffix(path, ".ts"), strings.HasSuffix(path, ".tsx"):
|
||||
r, err := languages.NewTypeScriptExtractor().Extract(path, []byte(src))
|
||||
require.NoError(t, err, "ts extract %s", path)
|
||||
nodes, edges = r.Nodes, r.Edges
|
||||
default:
|
||||
r, err := languages.NewJavaScriptExtractor().Extract(path, []byte(src))
|
||||
require.NoError(t, err, "js extract %s", path)
|
||||
nodes, edges = r.Nodes, r.Edges
|
||||
}
|
||||
for _, n := range nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, e := range edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// resolveAndSynthesize runs the production resolution pipeline against g
|
||||
// — the per-edge ResolveAll pass plus the cross-package guard it ends
|
||||
// with — and then the opt-in external-call synthesis pass. It mirrors
|
||||
// the indexer settle point: synthesis runs strictly after resolution +
|
||||
// guard, so the test exercises the same ordering the daemon uses.
|
||||
func resolveAndSynthesize(g graph.Store, enabled bool) int {
|
||||
New(g).ResolveAll()
|
||||
return SynthesizeExternalCalls(g, enabled)
|
||||
}
|
||||
|
||||
// callTargetsFrom collects the To-end of every call/reference edge
|
||||
// leaving fromID, so a test can assert on the post-resolution shape of
|
||||
// a caller's outbound calls.
|
||||
func callTargetsFrom(g graph.Store, fromID string) []string {
|
||||
var out []string
|
||||
for _, e := range g.GetOutEdges(fromID) {
|
||||
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
|
||||
out = append(out, e.To)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSynthesizeExternalCalls drives the pass through a real
|
||||
// extract → resolve → synthesize pipeline for each ecosystem. Every row
|
||||
// fixes one caller's call into an un-indexed external target and
|
||||
// asserts, with the option both on and off, what the call edge lands
|
||||
// on — and that language built-ins / standard-library calls are
|
||||
// filtered out as noise.
|
||||
func TestSynthesizeExternalCalls(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
// callerID identifies the function whose outbound call is
|
||||
// under test.
|
||||
callerID string
|
||||
// wantSyntheticID, when set with the option ON, is the
|
||||
// synthetic node ID the call edge must retarget onto.
|
||||
wantSyntheticID string
|
||||
// wantEcosystem / wantImportPath are asserted on the
|
||||
// synthetic node's Meta when wantSyntheticID is set.
|
||||
wantEcosystem string
|
||||
wantImportPath string
|
||||
// noiseOnly marks a fixture whose only external call is a
|
||||
// language built-in / stdlib hop: the filter must synthesize
|
||||
// nothing even with the option ON.
|
||||
noiseOnly bool
|
||||
}{
|
||||
{
|
||||
// An un-indexed npm package: `axios` is imported and
|
||||
// called, but no axios source is in the graph. With the
|
||||
// option on, the call must terminate on a synthetic node.
|
||||
name: "un-indexed npm package call",
|
||||
files: map[string]string{
|
||||
"web/api.ts": `import axios from "axios";
|
||||
export function fetchUser(): void {
|
||||
axios.get("/user");
|
||||
}`,
|
||||
},
|
||||
callerID: "web/api.ts::fetchUser",
|
||||
wantSyntheticID: externalCallNodeID("stdlib", "axios"),
|
||||
wantEcosystem: "stdlib",
|
||||
wantImportPath: "axios",
|
||||
},
|
||||
{
|
||||
// A sibling microservice's client SDK — a scoped package
|
||||
// that is not part of this repo's index. The call into it
|
||||
// must be preserved as an explicit external terminal.
|
||||
name: "sibling-service client SDK call",
|
||||
files: map[string]string{
|
||||
"web/orders.ts": `import billing from "@acme/billing-service-client";
|
||||
export function charge(): void {
|
||||
billing.createInvoice();
|
||||
}`,
|
||||
},
|
||||
callerID: "web/orders.ts::charge",
|
||||
wantSyntheticID: externalCallNodeID("stdlib", "@acme/billing-service-client"),
|
||||
wantEcosystem: "stdlib",
|
||||
wantImportPath: "@acme/billing-service-client",
|
||||
},
|
||||
{
|
||||
// An un-indexed Go third-party module — a domain-qualified
|
||||
// import path, so the resolver lands it on a `dep::`
|
||||
// terminal. The synthetic node must carry it through.
|
||||
name: "un-indexed go module call",
|
||||
files: map[string]string{
|
||||
"svc/main.go": `package main
|
||||
|
||||
import "github.com/acme/stripe"
|
||||
|
||||
func Pay() {
|
||||
stripe.New("key")
|
||||
}`,
|
||||
},
|
||||
callerID: "svc/main.go::Pay",
|
||||
wantSyntheticID: externalCallNodeID("dep", "github.com/acme/stripe"),
|
||||
wantEcosystem: "dep",
|
||||
wantImportPath: "github.com/acme/stripe",
|
||||
},
|
||||
{
|
||||
// An un-indexed pip package: `requests` is imported and
|
||||
// called. Not a stdlib module, so it must be synthesized.
|
||||
name: "un-indexed pip package call",
|
||||
files: map[string]string{
|
||||
"app/client.py": `import requests
|
||||
|
||||
def fetch():
|
||||
requests.get("/health")
|
||||
`,
|
||||
},
|
||||
callerID: "app/client.py::fetch",
|
||||
wantSyntheticID: externalCallNodeID("stdlib", "requests"),
|
||||
wantEcosystem: "stdlib",
|
||||
wantImportPath: "requests",
|
||||
},
|
||||
{
|
||||
// Noise filter — Go standard library. Every Go file calls
|
||||
// `fmt`; materialising a node for it would bury the real
|
||||
// cross-system edges. Nothing must be synthesized.
|
||||
name: "go stdlib call is filtered as noise",
|
||||
files: map[string]string{
|
||||
"svc/log.go": `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func Log() {
|
||||
fmt.Println("hello")
|
||||
}`,
|
||||
},
|
||||
callerID: "svc/log.go::Log",
|
||||
noiseOnly: true,
|
||||
},
|
||||
{
|
||||
// Noise filter — Python standard library. `os.getenv` is
|
||||
// an interpreter built-in, not a pip dependency.
|
||||
name: "python stdlib call is filtered as noise",
|
||||
files: map[string]string{
|
||||
"app/env.py": `import os
|
||||
|
||||
def home():
|
||||
os.getenv("HOME")
|
||||
`,
|
||||
},
|
||||
callerID: "app/env.py::home",
|
||||
noiseOnly: true,
|
||||
},
|
||||
{
|
||||
// Noise filter — a Node.js core module. `node:fs` is part
|
||||
// of the runtime, not an npm package.
|
||||
name: "node core module call is filtered as noise",
|
||||
files: map[string]string{
|
||||
"web/disk.ts": `import fs from "node:fs";
|
||||
export function read(): void {
|
||||
fs.readFileSync("/etc/hosts");
|
||||
}`,
|
||||
},
|
||||
callerID: "web/disk.ts::read",
|
||||
noiseOnly: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Option OFF (the default): the synthesis pass is a pure
|
||||
// no-op — the resolved graph is left exactly as resolution
|
||||
// produced it, with no synthetic node and no retargeted
|
||||
// edge. Snapshot the counts after ResolveAll (which itself
|
||||
// mutates the graph) so the assertion isolates the pass.
|
||||
gOff := buildMultiLangGraph(t, tc.files)
|
||||
New(gOff).ResolveAll()
|
||||
nodesAfterResolve := gOff.NodeCount()
|
||||
edgesAfterResolve := gOff.EdgeCount()
|
||||
synthesizedOff := SynthesizeExternalCalls(gOff, false)
|
||||
assert.Zero(t, synthesizedOff, "option OFF must synthesize nothing")
|
||||
assert.Equal(t, nodesAfterResolve, gOff.NodeCount(),
|
||||
"option OFF must not add any node")
|
||||
assert.Equal(t, edgesAfterResolve, gOff.EdgeCount(),
|
||||
"option OFF must not add any edge")
|
||||
for _, to := range callTargetsFrom(gOff, tc.callerID) {
|
||||
assert.NotContains(t, to, externalCallPrefix,
|
||||
"option OFF must leave call edges off synthetic nodes")
|
||||
}
|
||||
|
||||
// Option ON: genuine externals get a synthetic terminal;
|
||||
// noise (builtin / stdlib) is still filtered out.
|
||||
gOn := buildMultiLangGraph(t, tc.files)
|
||||
synthesizedOn := resolveAndSynthesize(gOn, true)
|
||||
|
||||
if tc.noiseOnly {
|
||||
assert.Zero(t, synthesizedOn,
|
||||
"noise filter must exclude builtin/stdlib calls")
|
||||
for _, to := range callTargetsFrom(gOn, tc.callerID) {
|
||||
assert.NotContains(t, to, externalCallPrefix,
|
||||
"a builtin/stdlib call must not gain a synthetic node")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.Positive(t, synthesizedOn,
|
||||
"a genuine external call must be synthesized with the option ON")
|
||||
|
||||
// The synthetic node exists and is marked unmistakably as
|
||||
// both synthetic and external so analyzers can filter it.
|
||||
node := gOn.GetNode(tc.wantSyntheticID)
|
||||
require.NotNil(t, node, "synthetic external node must be added")
|
||||
assert.Equal(t, graph.KindModule, node.Kind)
|
||||
assert.Equal(t, tc.wantImportPath, node.Name)
|
||||
assert.Equal(t, true, node.Meta["synthetic"])
|
||||
assert.Equal(t, true, node.Meta["external_call"])
|
||||
assert.Equal(t, tc.wantEcosystem, node.Meta["ecosystem"])
|
||||
assert.Equal(t, tc.wantImportPath, node.Meta["import_path"])
|
||||
|
||||
// The call edge was retargeted onto the synthetic node and
|
||||
// the synthetic node sees the inbound call edge — so a
|
||||
// call-chain walk reaches the external terminal.
|
||||
targets := callTargetsFrom(gOn, tc.callerID)
|
||||
assert.Contains(t, targets, tc.wantSyntheticID,
|
||||
"the call edge must retarget onto the synthetic node")
|
||||
require.NotEmpty(t, gOn.GetInEdges(tc.wantSyntheticID),
|
||||
"synthetic node must see the inbound call edge")
|
||||
|
||||
// The retargeted edge is marked so a query can pick out the
|
||||
// cross-system terminals this pass created.
|
||||
edge := firstOutEdgeByKind(gOn, tc.callerID, graph.EdgeCalls)
|
||||
require.NotNil(t, edge)
|
||||
assert.Equal(t, tc.wantSyntheticID, edge.To)
|
||||
assert.Equal(t, true, edge.Meta["external_call"])
|
||||
assert.Equal(t, graph.OriginTextMatched, edge.Origin)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthesizeExternalCalls_Idempotent pins the full-recompute
|
||||
// contract: re-running the pass rewrites every edge onto the same
|
||||
// deterministic synthetic node and accretes no duplicate node or edge.
|
||||
func TestSynthesizeExternalCalls_Idempotent(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"web/api.ts": `import axios from "axios";
|
||||
export function fetchUser(): void {
|
||||
axios.get("/user");
|
||||
}`,
|
||||
}
|
||||
g := buildMultiLangGraph(t, files)
|
||||
New(g).ResolveAll()
|
||||
|
||||
first := SynthesizeExternalCalls(g, true)
|
||||
nodesAfterFirst := g.NodeCount()
|
||||
second := SynthesizeExternalCalls(g, true)
|
||||
third := SynthesizeExternalCalls(g, true)
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, first, second, "re-run must report the same count")
|
||||
assert.Equal(t, first, third)
|
||||
assert.Equal(t, nodesAfterFirst, g.NodeCount(),
|
||||
"re-run must not add a duplicate synthetic node")
|
||||
|
||||
syntheticID := externalCallNodeID("stdlib", "axios")
|
||||
require.Len(t, g.GetInEdges(syntheticID), 1,
|
||||
"re-run must not accrete a duplicate inbound edge")
|
||||
}
|
||||
|
||||
// TestSynthesizeExternalCalls_DisabledByDefault guards the
|
||||
// zero-config contract: a graph carrying an un-indexed external call
|
||||
// is left completely untouched when the option is off — the default.
|
||||
func TestSynthesizeExternalCalls_DisabledByDefault(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"app/client.py": `import requests
|
||||
|
||||
def fetch():
|
||||
requests.get("/health")
|
||||
`,
|
||||
}
|
||||
g := buildMultiLangGraph(t, files)
|
||||
New(g).ResolveAll()
|
||||
|
||||
nodesBefore := g.NodeCount()
|
||||
edgesBefore := g.EdgeCount()
|
||||
synthesized := SynthesizeExternalCalls(g, false)
|
||||
|
||||
assert.Zero(t, synthesized)
|
||||
assert.Equal(t, nodesBefore, g.NodeCount(), "default-off must add no node")
|
||||
assert.Equal(t, edgesBefore, g.EdgeCount(), "default-off must add no edge")
|
||||
}
|
||||
|
||||
// TestParseExternalCallTarget unit-tests the terminal classifier: the
|
||||
// three external bookkeeping-string shapes yield the right ecosystem +
|
||||
// import path, while real node IDs, bare placeholders, `builtin::`
|
||||
// terminals, and already-synthesised nodes are rejected.
|
||||
func TestParseExternalCallTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
target string
|
||||
wantOK bool
|
||||
wantEcosystem string
|
||||
wantPath string
|
||||
}{
|
||||
{"dep::github.com/foo/bar::Baz", true, "dep", "github.com/foo/bar"},
|
||||
{"stdlib::axios::get", true, "stdlib", "axios"},
|
||||
{"stdlib::@acme/svc-client::call", true, "stdlib", "@acme/svc-client"},
|
||||
{"external::lodash", true, "external", "lodash"},
|
||||
// Rejected: real node IDs and non-external placeholders.
|
||||
{"pkg/foo.go::Bar", false, "", ""},
|
||||
{"unresolved::Foo", false, "", ""},
|
||||
{"unresolved::*.foo", false, "", ""},
|
||||
{"builtin::js::array::push", false, "", ""},
|
||||
{externalCallPrefix + "dep::x", false, "", ""},
|
||||
// `dep::` / `stdlib::` with no `::<symbol>` separator is malformed.
|
||||
{"dep::", false, "", ""},
|
||||
{"stdlib::axios", false, "", ""},
|
||||
{"external::", false, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.target, func(t *testing.T) {
|
||||
eco, path, ok := parseExternalCallTarget(tc.target)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
assert.Equal(t, tc.wantEcosystem, eco)
|
||||
assert.Equal(t, tc.wantPath, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLanguageStdlib unit-tests the language-aware noise filter: the
|
||||
// same import-path shape is stdlib or third-party depending on the
|
||||
// caller's language.
|
||||
func TestIsLanguageStdlib(t *testing.T) {
|
||||
cases := []struct {
|
||||
lang string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Go: un-dotted first segment is stdlib; domain-led is a module.
|
||||
{"go", "fmt", true},
|
||||
{"go", "net/http", true},
|
||||
{"go", "encoding/json", true},
|
||||
{"go", "github.com/stripe/stripe-go", false},
|
||||
{"go", "gopkg.in/yaml.v3", false},
|
||||
// Python: interpreter modules vs pip packages.
|
||||
{"python", "os", true},
|
||||
{"python", "os.path", true},
|
||||
{"python", "collections", true},
|
||||
{"python", "requests", false},
|
||||
{"python", "numpy", false},
|
||||
// Node: core modules (bare and node:-prefixed) vs npm packages.
|
||||
{"javascript", "fs", true},
|
||||
{"typescript", "node:crypto", true},
|
||||
{"typescript", "stream/promises", true},
|
||||
{"typescript", "axios", false},
|
||||
{"typescript", "@acme/billing-client", false},
|
||||
// Rust: the std distribution vs crates.
|
||||
{"rust", "std", true},
|
||||
{"rust", "core::mem", true},
|
||||
{"rust", "tokio", false},
|
||||
// Unknown language: treat the path as external so a real edge
|
||||
// is never dropped by a missing rule.
|
||||
{"", "anything", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.lang+":"+tc.path, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isLanguageStdlib(tc.lang, tc.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fabricBridgeVia marks a synthesized Fabric spec↔native-view-manager edge.
|
||||
const fabricBridgeVia = "fabric.component"
|
||||
|
||||
// ResolveFabricComponents is the framework-dispatch synthesizer for React
|
||||
// Native Fabric / Codegen view components. The TS extractor emits a node
|
||||
// per `codegenNativeComponent<Props>('Name')` spec (Meta
|
||||
// fabric_component, plus fabric_events from DirectEventHandler props); the
|
||||
// Objective-C and Java extractors emit a node per native view manager
|
||||
// (Meta fabric_component derived from the manager class name, plus
|
||||
// fabric_props). This pass binds the spec to its native implementation(s)
|
||||
// by component name with bidirectional EdgeReferences bridge edges, so a
|
||||
// Fabric component spec resolves to the native code that renders it.
|
||||
//
|
||||
// Full recompute and idempotent (graph.AddEdge dedupes; graph.EvictFile
|
||||
// drops the bridge on reindex). Edges ride at ast_inferred with
|
||||
// synthesizer provenance.
|
||||
//
|
||||
// Returns the number of Fabric specs bound to at least one native view
|
||||
// manager.
|
||||
func ResolveFabricComponents(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var specs []*graph.Node
|
||||
nativeByComponent := map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindType) {
|
||||
if n == nil || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
comp, _ := n.Meta["fabric_component"].(string)
|
||||
if comp == "" {
|
||||
continue
|
||||
}
|
||||
if _, isNative := n.Meta["fabric_native"]; isNative {
|
||||
nativeByComponent[fabricNormalize(comp)] = append(nativeByComponent[fabricNormalize(comp)], n)
|
||||
} else {
|
||||
specs = append(specs, n)
|
||||
}
|
||||
}
|
||||
if len(specs) == 0 || len(nativeByComponent) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var batch []*graph.Edge
|
||||
bound := 0
|
||||
for _, spec := range specs {
|
||||
comp, _ := spec.Meta["fabric_component"].(string)
|
||||
matches := nativeByComponent[fabricNormalize(comp)]
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
linked := false
|
||||
for _, native := range matches {
|
||||
if native.ID == spec.ID {
|
||||
continue
|
||||
}
|
||||
batch = append(batch,
|
||||
fabricBridgeEdge(spec, native, comp),
|
||||
fabricBridgeEdge(native, spec, comp),
|
||||
)
|
||||
linked = true
|
||||
}
|
||||
if linked {
|
||||
bound++
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// fabricNormalize folds a component name for cross-language matching: the
|
||||
// native side often carries platform prefixes/suffixes the JS spec drops
|
||||
// (RCTWebView ↔ WebView). Lower-cases and strips a leading "rct"/"rn".
|
||||
func fabricNormalize(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
n = strings.TrimPrefix(n, "rct")
|
||||
n = strings.TrimPrefix(n, "rn")
|
||||
return n
|
||||
}
|
||||
|
||||
func fabricBridgeEdge(from, to *graph.Node, component string) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from.ID,
|
||||
To: to.ID,
|
||||
Kind: graph.EdgeReferences,
|
||||
FilePath: from.FilePath,
|
||||
Line: from.StartLine,
|
||||
Confidence: 0.6,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeReferences, 0.6),
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{
|
||||
"via": fabricBridgeVia,
|
||||
"fabric_component": component,
|
||||
"to_language": to.Language,
|
||||
MetaSynthesizedBy: SynthFabric,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func fabricSpecNode(g graph.Store, id, component string) {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: id, Kind: graph.KindType, Name: component, FilePath: id, StartLine: 1,
|
||||
Language: "typescript", Meta: map[string]any{"fabric_component": component},
|
||||
})
|
||||
}
|
||||
|
||||
func fabricNativeNode(g graph.Store, id, lang, component string) {
|
||||
g.AddNode(&graph.Node{
|
||||
ID: id, Kind: graph.KindType, Name: component, FilePath: id, StartLine: 1,
|
||||
Language: lang, Meta: map[string]any{"fabric_component": component, "fabric_native": lang},
|
||||
})
|
||||
}
|
||||
|
||||
func fabricBridge(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To == to && e.Kind == graph.EdgeReferences && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == fabricBridgeVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveFabricComponents_BindsSpecToNative(t *testing.T) {
|
||||
g := graph.New()
|
||||
// TS spec uses RCTColorView; native managers normalize to the same.
|
||||
fabricSpecNode(g, "ColorViewNativeComponent.ts::fabric:RCTColorView", "RCTColorView")
|
||||
fabricNativeNode(g, "RCTColorViewManager.m::fabric:RCTColorView", "objc", "RCTColorView")
|
||||
fabricNativeNode(g, "ColorViewManager.java::fabric:RCTColorView", "java", "RCTColorView")
|
||||
|
||||
n := ResolveFabricComponents(g)
|
||||
assert.Equal(t, 1, n, "one spec bound (to two native managers)")
|
||||
|
||||
objc := fabricBridge(g, "ColorViewNativeComponent.ts::fabric:RCTColorView", "RCTColorViewManager.m::fabric:RCTColorView")
|
||||
require.NotNil(t, objc)
|
||||
assert.Equal(t, SynthFabric, objc.Meta[MetaSynthesizedBy])
|
||||
require.NotNil(t, fabricBridge(g, "RCTColorViewManager.m::fabric:RCTColorView", "ColorViewNativeComponent.ts::fabric:RCTColorView"), "bidirectional")
|
||||
require.NotNil(t, fabricBridge(g, "ColorViewNativeComponent.ts::fabric:RCTColorView", "ColorViewManager.java::fabric:RCTColorView"))
|
||||
}
|
||||
|
||||
func TestResolveFabricComponents_NormalizedMatch(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Spec drops the RCT prefix; native keeps it — normalization bridges.
|
||||
fabricSpecNode(g, "spec.ts::fabric:ColorView", "ColorView")
|
||||
fabricNativeNode(g, "mgr.m::fabric:RCTColorView", "objc", "RCTColorView")
|
||||
assert.Equal(t, 1, ResolveFabricComponents(g))
|
||||
assert.NotNil(t, fabricBridge(g, "spec.ts::fabric:ColorView", "mgr.m::fabric:RCTColorView"))
|
||||
}
|
||||
|
||||
func TestResolveFabricComponents_NoMatch(t *testing.T) {
|
||||
g := graph.New()
|
||||
fabricSpecNode(g, "spec.ts::fabric:ColorView", "ColorView")
|
||||
fabricNativeNode(g, "mgr.m::fabric:Slider", "objc", "Slider")
|
||||
assert.Equal(t, 0, ResolveFabricComponents(g))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// FastAPI dependency / router directory-convention fallback. The Python
|
||||
// extractor stamps a `Depends(get_db)` argument as a `via=fastapi.Depends`
|
||||
// call placeholder and an `include_router(api_router)` argument as a
|
||||
// `via=fastapi.router` reference placeholder. When the standard import/
|
||||
// reference resolver already bound the target (the precise path) those
|
||||
// placeholders are no longer unresolved and this pass leaves them alone. Only
|
||||
// the residual unresolved ones are bound by directory convention —
|
||||
// dependencies under /dependencies/ /deps/ /core/, routers under /routers/
|
||||
// /api/ /routes/ /endpoints/ — so recall improves without regressing the
|
||||
// precise path and without double-binding.
|
||||
|
||||
var (
|
||||
fastapiDepDirs = []string{"/dependencies/", "/deps/", "/core/"}
|
||||
fastapiRouterDirs = []string{"/routers/", "/api/", "/routes/", "/endpoints/"}
|
||||
)
|
||||
|
||||
// ResolveFastAPIDeps binds residual unresolved FastAPI dependency / router
|
||||
// references to their definitions by directory convention. Returns the count
|
||||
// bound.
|
||||
func ResolveFastAPIDeps(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e == nil || e.Meta == nil || !graph.IsUnresolvedTarget(e.To) {
|
||||
continue
|
||||
}
|
||||
var preferDirs []string
|
||||
switch via, _ := e.Meta["via"].(string); via {
|
||||
case "fastapi.Depends":
|
||||
preferDirs = fastapiDepDirs
|
||||
case "fastapi.router":
|
||||
preferDirs = fastapiRouterDirs
|
||||
default:
|
||||
continue
|
||||
}
|
||||
name := graph.UnresolvedName(e.To)
|
||||
if strings.ContainsRune(name, '.') {
|
||||
continue // member-expr target — left to the import resolver
|
||||
}
|
||||
fromFile := ""
|
||||
if n := g.GetNode(e.From); n != nil {
|
||||
fromFile = n.FilePath
|
||||
}
|
||||
if !strings.HasSuffix(fromFile, ".py") {
|
||||
continue
|
||||
}
|
||||
targetID, conf := ResolveByConvention(g, name, "", preferDirs, fromFile)
|
||||
if targetID == "" {
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = targetID
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = conf
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(e.Kind, conf)
|
||||
StampSynthesized(e, SynthFastAPIResolve)
|
||||
reindex = append(reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func fastapiCaller(g *graph.Graph, id, file string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: "handler", FilePath: file, Language: "python"})
|
||||
}
|
||||
|
||||
func fastapiEdge(g *graph.Graph, from, file, to string, kind graph.EdgeKind, via string) {
|
||||
g.AddEdge(&graph.Edge{From: from, To: to, Kind: kind, FilePath: file, Meta: map[string]any{"via": via}})
|
||||
}
|
||||
|
||||
func synthFastAPIEdge(g graph.Store, kind graph.EdgeKind, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthFastAPIResolve {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveFastAPIDeps_DependencyByConvention(t *testing.T) {
|
||||
g := graph.New()
|
||||
const handler = "app/routers/users.py::list_users"
|
||||
fastapiCaller(g, handler, "app/routers/users.py")
|
||||
// get_db provider lives only under /dependencies/ — reachable by
|
||||
// convention, not by a resolvable import.
|
||||
convNode(g, "app/dependencies/db.py::get_db", "app/dependencies/db.py", "get_db")
|
||||
fastapiEdge(g, handler, "app/routers/users.py", "unresolved::get_db", graph.EdgeCalls, "fastapi.Depends")
|
||||
|
||||
require.Equal(t, 1, ResolveFastAPIDeps(g))
|
||||
assert.NotNil(t, synthFastAPIEdge(g, graph.EdgeCalls, handler, "app/dependencies/db.py::get_db"),
|
||||
"Depends(get_db) binds to /dependencies/db.py")
|
||||
}
|
||||
|
||||
func TestResolveFastAPIDeps_RouterByConvention(t *testing.T) {
|
||||
g := graph.New()
|
||||
const main = "app/main.py"
|
||||
g.AddNode(&graph.Node{ID: main, Kind: graph.KindFile, Name: "main.py", FilePath: main, Language: "python"})
|
||||
convNode(g, "app/routers/api.py::api_router", "app/routers/api.py", "api_router")
|
||||
fastapiEdge(g, main, main, "unresolved::api_router", graph.EdgeReferences, "fastapi.router")
|
||||
|
||||
require.Equal(t, 1, ResolveFastAPIDeps(g))
|
||||
assert.NotNil(t, synthFastAPIEdge(g, graph.EdgeReferences, main, "app/routers/api.py::api_router"),
|
||||
"include_router(api_router) binds to /routers/api.py")
|
||||
}
|
||||
|
||||
func TestResolveFastAPIDeps_AlreadyResolvedUnchanged(t *testing.T) {
|
||||
g := graph.New()
|
||||
const handler = "app/routers/users.py::list_users"
|
||||
fastapiCaller(g, handler, "app/routers/users.py")
|
||||
// A Depends edge that the reference resolver already bound (To is a real
|
||||
// node, not unresolved) must not be touched — no double-binding.
|
||||
convNode(g, "app/dependencies/db.py::get_db", "app/dependencies/db.py", "get_db")
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: handler, To: "app/services/db.py::get_db", Kind: graph.EdgeCalls,
|
||||
FilePath: "app/routers/users.py", Meta: map[string]any{"via": "fastapi.Depends"},
|
||||
})
|
||||
g.AddNode(&graph.Node{ID: "app/services/db.py::get_db", Kind: graph.KindFunction, Name: "get_db", FilePath: "app/services/db.py"})
|
||||
|
||||
require.Equal(t, 0, ResolveFastAPIDeps(g))
|
||||
// The already-resolved edge still points where it did, unstamped.
|
||||
assert.Nil(t, synthFastAPIEdge(g, graph.EdgeCalls, handler, "app/dependencies/db.py::get_db"))
|
||||
}
|
||||
|
||||
func TestResolveFastAPIDeps_NonPythonLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
const goFn = "pkg/svc.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: goFn, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/svc.go", Language: "go"})
|
||||
convNode(g, "app/dependencies/db.py::get_db", "app/dependencies/db.py", "get_db")
|
||||
fastapiEdge(g, goFn, "pkg/svc.go", "unresolved::get_db", graph.EdgeCalls, "fastapi.Depends")
|
||||
|
||||
require.Equal(t, 0, ResolveFastAPIDeps(g))
|
||||
assert.Nil(t, synthFastAPIEdge(g, graph.EdgeCalls, goFn, "app/dependencies/db.py::get_db"))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// RemoteDeclarationProber is satisfied by the daemon's Federator. It asks
|
||||
// each enabled remote whether it owns a declaration matching name,
|
||||
// passing the caller's import/module hint so a bare-name probe is never
|
||||
// issued. Returns the first positive hit or ok=false. The
|
||||
// implementation lives in internal/daemon (keeps internal/resolver pure —
|
||||
// no HTTP here); it bounds its own per-remote deadline (ProxyToolCtx).
|
||||
type RemoteDeclarationProber interface {
|
||||
ProbeDeclaration(ctx context.Context, name, importHint string) (RemoteDecl, bool)
|
||||
}
|
||||
|
||||
// RemoteDecl is a remote daemon's confirmed declaration of a symbol.
|
||||
type RemoteDecl struct {
|
||||
Slug string // owning remote roster slug
|
||||
RemoteID string // <prefix>/<file>::<sym> on the remote
|
||||
Kind graph.NodeKind
|
||||
RepoPrefix string
|
||||
WorkspaceID string
|
||||
File string
|
||||
Line int
|
||||
}
|
||||
|
||||
// EnableRemoteStitch wires the proxy-edge mint path: a prober and the
|
||||
// proxy-node heap bound. Called by the daemon entry point only when
|
||||
// federation.edges.enabled. A nil prober (or budget <= 0 with no prober)
|
||||
// leaves the resolver in its default read-only fan-out behaviour.
|
||||
func (cr *CrossRepoResolver) EnableRemoteStitch(prober RemoteDeclarationProber, proxyBudget int) {
|
||||
cr.prober = prober
|
||||
cr.proxyBudget = proxyBudget
|
||||
cr.edgesEnabled = prober != nil
|
||||
}
|
||||
|
||||
// tryRemoteStitch is the gated proxy-edge mint. It runs only after local
|
||||
// resolution fails (the caller checks e.To == oldTo). On a confirmed
|
||||
// remote declaration it mints an origin-namespaced proxy node and
|
||||
// rewrites the edge to it with honest provenance (text_matched, never
|
||||
// lsp_resolved). Returns true when it stitched.
|
||||
func (cr *CrossRepoResolver) tryRemoteStitch(e *graph.Edge, name string, stats *CrossRepoStats) bool {
|
||||
// 1. EVIDENCE GATE: never mint on a bare name. The caller
|
||||
// file must import something for a remote target to be plausible.
|
||||
importHint := cr.importHintFor(e)
|
||||
if importHint == "" {
|
||||
return false
|
||||
}
|
||||
decl, ok := cr.prober.ProbeDeclaration(context.Background(), name, importHint)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. MINT the proxy node (origin-namespaced so it can never alias a
|
||||
// local id), bounded by the heap budget.
|
||||
pid := graph.ProxyNodeID(decl.Slug, decl.RemoteID)
|
||||
if cr.graph.GetNode(pid) == nil {
|
||||
if cr.proxyBudgetExceeded() {
|
||||
if cr.logger != nil {
|
||||
cr.logger.Warn("federation: proxy-node budget exceeded; mint refused",
|
||||
zap.Int("budget", cr.proxyBudget), zap.String("name", name))
|
||||
}
|
||||
return false
|
||||
}
|
||||
cr.graph.AddNode(&graph.Node{
|
||||
ID: pid,
|
||||
Kind: decl.Kind,
|
||||
Name: name,
|
||||
FilePath: decl.File,
|
||||
StartLine: decl.Line,
|
||||
RepoPrefix: decl.RepoPrefix,
|
||||
WorkspaceID: decl.WorkspaceID,
|
||||
Origin: "remote:" + decl.Slug,
|
||||
Stub: true,
|
||||
FetchedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// 3. REWRITE the edge to the proxy with honest provenance.
|
||||
e.To = pid
|
||||
e.CrossRepo = true
|
||||
e.Origin = graph.OriginTextMatched
|
||||
// resolveFunctionCall counted this edge as Unresolved; it is now a
|
||||
// cross-repo edge to the proxy.
|
||||
if stats.Unresolved > 0 {
|
||||
stats.Unresolved--
|
||||
}
|
||||
stats.CrossRepoEdges++
|
||||
return true
|
||||
}
|
||||
|
||||
// importHintFor returns a comma-joined list of the caller file's import
|
||||
// targets, or "" when the file imports nothing. A non-empty hint is the
|
||||
// positive evidence the mint path requires; "" disables the probe.
|
||||
func (cr *CrossRepoResolver) importHintFor(e *graph.Edge) string {
|
||||
fileID := cr.callerFileID(e)
|
||||
if fileID == "" {
|
||||
return ""
|
||||
}
|
||||
var hints []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, ie := range cr.graph.GetOutEdges(fileID) {
|
||||
if ie.Kind != graph.EdgeImports {
|
||||
continue
|
||||
}
|
||||
h := importHintName(ie.To)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[h]; dup {
|
||||
continue
|
||||
}
|
||||
seen[h] = struct{}{}
|
||||
hints = append(hints, h)
|
||||
}
|
||||
if len(hints) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(hints)
|
||||
return strings.Join(hints, ",")
|
||||
}
|
||||
|
||||
// importHintName normalises an import edge's target into a module/path
|
||||
// hint, stripping the unresolved + import:: markers.
|
||||
func importHintName(to string) string {
|
||||
n := graph.UnresolvedName(to)
|
||||
if n == "" {
|
||||
n = to
|
||||
}
|
||||
return strings.TrimPrefix(n, "import::")
|
||||
}
|
||||
|
||||
// proxyBudgetExceeded reports whether the graph already holds the maximum
|
||||
// number of proxy nodes. Counts on demand; mints are rare
|
||||
// (gated behind the evidence rule + the off-by-default flag).
|
||||
func (cr *CrossRepoResolver) proxyBudgetExceeded() bool {
|
||||
if cr.proxyBudget <= 0 {
|
||||
return false
|
||||
}
|
||||
count := 0
|
||||
for _, n := range cr.graph.AllNodes() {
|
||||
if graph.IsProxyNode(n) {
|
||||
count++
|
||||
if count >= cr.proxyBudget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// flutterSetStateVia marks a synthesized Flutter setState→build reachability
|
||||
// edge.
|
||||
const flutterSetStateVia = "flutter.setstate"
|
||||
|
||||
// ResolveFlutterSetStateCalls is the framework-dispatch synthesizer for the
|
||||
// Flutter widget re-build hop. `setState(() { … })` schedules the State's
|
||||
// `build(...)` to re-run, but that hop is framework-internal — no static edge —
|
||||
// so a flow dead-ends at setState even though everything `build` reaches is
|
||||
// call-connected. This pass bridges it: for each State class that has a `build`
|
||||
// method, it links every sibling method whose body calls `setState(` to that
|
||||
// `build`. The setState call is the gate that keeps this to Flutter State
|
||||
// classes — a plain class with a `build` method that never calls `setState`
|
||||
// produces no edge.
|
||||
//
|
||||
// Over-approximation by design, full recompute and idempotent; edges ride at
|
||||
// ast_inferred and carry synthesizer provenance. Returns the number of
|
||||
// setState→build edges synthesized.
|
||||
func ResolveFlutterSetStateCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
classByMethod := map[string]string{}
|
||||
buildByClass := map[string]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range g.GetOutEdges(n.ID) {
|
||||
if e == nil || e.Kind != graph.EdgeMemberOf {
|
||||
continue
|
||||
}
|
||||
classByMethod[n.ID] = e.To
|
||||
if n.Name == "build" {
|
||||
buildByClass[e.To] = n
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(buildByClass) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var setStateMethods []*graph.Node
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
build := buildByClass[classByMethod[n.ID]]
|
||||
if build == nil || build.ID == n.ID {
|
||||
continue
|
||||
}
|
||||
if !methodCallsSetState(g, n.ID) {
|
||||
continue
|
||||
}
|
||||
setStateMethods = append(setStateMethods, n)
|
||||
}
|
||||
sort.Slice(setStateMethods, func(i, j int) bool {
|
||||
return setStateMethods[i].ID < setStateMethods[j].ID
|
||||
})
|
||||
|
||||
var batch []*graph.Edge
|
||||
synthesized := 0
|
||||
for _, m := range setStateMethods {
|
||||
build := buildByClass[classByMethod[m.ID]]
|
||||
batch = append(batch, flutterSetStateEdge(m, build, classByMethod[m.ID]))
|
||||
synthesized++
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return synthesized
|
||||
}
|
||||
|
||||
// flutterSetStateEdge builds one setState-method → build synthesized edge.
|
||||
func flutterSetStateEdge(from, build *graph.Node, class string) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from.ID,
|
||||
To: build.ID,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: from.FilePath,
|
||||
Line: from.StartLine,
|
||||
Confidence: 0.6,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, 0.6),
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{
|
||||
"via": flutterSetStateVia,
|
||||
"state_class": class,
|
||||
MetaSynthesizedBy: SynthFlutterSetState,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func flutterSetStateEdgeBetween(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To == to && e.Kind == graph.EdgeCalls && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == flutterSetStateVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flutterState wires a State class with the given methods and setState callers.
|
||||
func flutterState(g graph.Store, file, class string, methods []string, setStateCallers map[string]bool) {
|
||||
g.AddNode(&graph.Node{ID: file + "::" + class, Kind: graph.KindType, Name: class, FilePath: file})
|
||||
for i, m := range methods {
|
||||
id := file + "::" + class + "." + m
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: m, FilePath: file, StartLine: 5 + i})
|
||||
g.AddEdge(&graph.Edge{From: id, To: file + "::" + class, Kind: graph.EdgeMemberOf})
|
||||
if setStateCallers[m] {
|
||||
g.AddEdge(&graph.Edge{From: id, To: "unresolved::*.setState", Kind: graph.EdgeCalls, FilePath: file, Line: 6 + i})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFlutterSetState_LinksSetterToBuild(t *testing.T) {
|
||||
g := graph.New()
|
||||
flutterState(g, "counter.dart", "_CounterState",
|
||||
[]string{"increment", "build", "noop"},
|
||||
map[string]bool{"increment": true})
|
||||
|
||||
n := ResolveFlutterSetStateCalls(g)
|
||||
assert.Equal(t, 1, n)
|
||||
|
||||
e := flutterSetStateEdgeBetween(g, "counter.dart::_CounterState.increment", "counter.dart::_CounterState.build")
|
||||
require.NotNil(t, e, "increment (calls setState) should reach build")
|
||||
assert.Equal(t, "counter.dart::_CounterState", e.Meta["state_class"])
|
||||
assert.Equal(t, SynthFlutterSetState, e.Meta[MetaSynthesizedBy])
|
||||
|
||||
assert.Nil(t, flutterSetStateEdgeBetween(g, "counter.dart::_CounterState.noop", "counter.dart::_CounterState.build"))
|
||||
}
|
||||
|
||||
func TestResolveFlutterSetState_NoBuildNoEdge(t *testing.T) {
|
||||
g := graph.New()
|
||||
flutterState(g, "svc.dart", "Svc", []string{"update"}, map[string]bool{"update": true})
|
||||
assert.Equal(t, 0, ResolveFlutterSetStateCalls(g))
|
||||
}
|
||||
|
||||
func TestResolveFlutterSetState_Idempotent(t *testing.T) {
|
||||
g := graph.New()
|
||||
flutterState(g, "counter.dart", "_CounterState", []string{"increment", "build"}, map[string]bool{"increment": true})
|
||||
first := ResolveFlutterSetStateCalls(g)
|
||||
second := ResolveFlutterSetStateCalls(g)
|
||||
assert.Equal(t, first, second)
|
||||
|
||||
count := 0
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e != nil && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == flutterSetStateVia {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fnPtrDispatchVia marks a C/C++ indirect-dispatch placeholder
|
||||
// (`cmds[i].fn(...)`); fnPtrRegVia marks a registration carrier (a concrete
|
||||
// function bound to a struct's fn-pointer field). Both must match the
|
||||
// extractor's constants.
|
||||
const (
|
||||
fnPtrDispatchVia = "fn-pointer-dispatch"
|
||||
fnPtrRegVia = "fn-pointer-reg"
|
||||
)
|
||||
|
||||
// fnPointerFanoutCap bounds the functions a single dispatch slot may fan out
|
||||
// to. fnPointerConfidence is the struct+field-keyed confidence — higher than
|
||||
// a pure-name guess, lower than a typed binding.
|
||||
const (
|
||||
fnPointerFanoutCap = 64
|
||||
fnPointerConfidence = 0.7
|
||||
)
|
||||
|
||||
// ResolveFnPointerDispatch binds C/C++ function-pointer dispatch: a function
|
||||
// registered into a struct's fn-pointer field
|
||||
// (`struct cmd cmds[] = {{"add", cmd_add}}`) is linked to the indirect call
|
||||
// `cmds[i].fn(...)` whose receiver resolves to that struct type. Registrations
|
||||
// (positional + designated initializers, `x.field = fn` assignments, and
|
||||
// `a.field = b.field` copies propagated to fixpoint) build a (struct, field)
|
||||
// → {functions} index; each dispatch site fans out to every function in its
|
||||
// slot.
|
||||
//
|
||||
// Returns the number of dispatcher → function edges synthesized.
|
||||
func ResolveFnPointerDispatch(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
slotFns := map[string]map[string]*graph.Node{}
|
||||
addFn := func(key string, n *graph.Node) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
if slotFns[key] == nil {
|
||||
slotFns[key] = map[string]*graph.Node{}
|
||||
}
|
||||
slotFns[key][n.ID] = n
|
||||
}
|
||||
type copyEdge struct{ to, from string }
|
||||
var copies []copyEdge
|
||||
|
||||
var regReindex []graph.EdgeReindex
|
||||
for e := range g.EdgesByKind(graph.EdgeReferences) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != fnPtrRegVia {
|
||||
continue
|
||||
}
|
||||
st, _ := e.Meta["fnptr_struct"].(string)
|
||||
field, _ := e.Meta["fnptr_field"].(string)
|
||||
if st == "" || field == "" {
|
||||
continue
|
||||
}
|
||||
key := st + "\x00" + field
|
||||
if cs, _ := e.Meta["fnptr_copy_struct"].(string); cs != "" {
|
||||
cf, _ := e.Meta["fnptr_copy_field"].(string)
|
||||
copies = append(copies, copyEdge{to: key, from: cs + "\x00" + cf})
|
||||
continue
|
||||
}
|
||||
fn, _ := e.Meta["fnptr_fn"].(string)
|
||||
if fn == "" {
|
||||
continue
|
||||
}
|
||||
target := fnPtrFunctionByName(g, e, fn)
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
addFn(key, target)
|
||||
if e.To != target.ID {
|
||||
oldTo := e.To
|
||||
e.To = target.ID
|
||||
e.Origin = graph.OriginASTInferred
|
||||
regReindex = append(regReindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
}
|
||||
}
|
||||
if len(regReindex) > 0 {
|
||||
g.ReindexEdges(regReindex)
|
||||
}
|
||||
if len(slotFns) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Field←field propagation fixpoint.
|
||||
for iter := 0; iter < 8; iter++ {
|
||||
changed := false
|
||||
for _, c := range copies {
|
||||
for id, n := range slotFns[c.from] {
|
||||
if slotFns[c.to] == nil {
|
||||
slotFns[c.to] = map[string]*graph.Node{}
|
||||
}
|
||||
if _, ok := slotFns[c.to][id]; !ok {
|
||||
slotFns[c.to][id] = n
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
var batch []*graph.Edge
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != fnPtrDispatchVia {
|
||||
continue
|
||||
}
|
||||
st, _ := e.Meta["fnptr_struct"].(string)
|
||||
field, _ := e.Meta["fnptr_field"].(string)
|
||||
if st == "" || field == "" {
|
||||
continue
|
||||
}
|
||||
fns := fnPtrSortedSlot(slotFns[st+"\x00"+field])
|
||||
if len(fns) > fnPointerFanoutCap {
|
||||
fns = fns[:fnPointerFanoutCap]
|
||||
}
|
||||
if len(fns) == 0 {
|
||||
resolved += fnPtrRebind(e, nil, &reindex)
|
||||
continue
|
||||
}
|
||||
resolved += fnPtrRebind(e, fns[0], &reindex)
|
||||
for _, n := range fns[1:] {
|
||||
batch = append(batch, fnPtrFanoutEdge(e, n, st, field))
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
for _, ne := range batch {
|
||||
g.AddEdge(ne)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func fnPtrRebind(e *graph.Edge, target *graph.Node, reindex *[]graph.EdgeReindex) int {
|
||||
field, _ := e.Meta["fnptr_field"].(string)
|
||||
want := "unresolved::*." + field
|
||||
if target != nil {
|
||||
want = target.ID
|
||||
}
|
||||
if e.To == want {
|
||||
if target != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = want
|
||||
hit := 0
|
||||
if target != nil {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = fnPointerConfidence
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, fnPointerConfidence)
|
||||
StampSynthesized(e, SynthFnPointerDispatch)
|
||||
hit = 1
|
||||
} else {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0
|
||||
e.ConfidenceLabel = ""
|
||||
UnstampSynthesized(e)
|
||||
}
|
||||
*reindex = append(*reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
return hit
|
||||
}
|
||||
|
||||
func fnPtrFanoutEdge(e *graph.Edge, target *graph.Node, st, field string) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: e.From, To: target.ID, Kind: graph.EdgeCalls,
|
||||
FilePath: e.FilePath, Line: e.Line,
|
||||
Origin: graph.OriginASTInferred,
|
||||
Confidence: fnPointerConfidence,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, fnPointerConfidence),
|
||||
Meta: map[string]any{
|
||||
"via": fnPtrDispatchVia,
|
||||
"fnptr_struct": st,
|
||||
"fnptr_field": field,
|
||||
MetaSynthesizedBy: SynthFnPointerDispatch,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// fnPtrFunctionByName resolves a registered function name to its node,
|
||||
// preferring the same file as the registration, then a unique match.
|
||||
func fnPtrFunctionByName(g graph.Store, reg *graph.Edge, name string) *graph.Node {
|
||||
var cands []*graph.Node
|
||||
for _, n := range g.FindNodesByName(name) {
|
||||
if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) {
|
||||
continue
|
||||
}
|
||||
if graph.IsStub(n.ID) || graph.IsUnresolvedTarget(n.ID) {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, n)
|
||||
}
|
||||
return pickStoreAction(g, reg, sameBoundaryCandidates(g, reg.From, cands))
|
||||
}
|
||||
|
||||
func fnPtrSortedSlot(m map[string]*graph.Node) []*graph.Node {
|
||||
out := make([]*graph.Node, 0, len(m))
|
||||
for _, n := range m {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func fnDef(g *graph.Graph, id, file, name string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: file, Language: "c"})
|
||||
}
|
||||
|
||||
func fnPtrReg(g *graph.Graph, file, st, field, fn string) {
|
||||
g.AddEdge(&graph.Edge{From: file, To: "unresolved::*." + fn, Kind: graph.EdgeReferences, FilePath: file,
|
||||
Meta: map[string]any{"via": fnPtrRegVia, "fnptr_struct": st, "fnptr_field": field, "fnptr_fn": fn}})
|
||||
}
|
||||
|
||||
func fnPtrCopy(g *graph.Graph, file, toSt, toField, fromSt, fromField string) {
|
||||
g.AddEdge(&graph.Edge{From: file, To: "unresolved::*." + fromField, Kind: graph.EdgeReferences, FilePath: file,
|
||||
Meta: map[string]any{"via": fnPtrRegVia, "fnptr_struct": toSt, "fnptr_field": toField, "fnptr_copy_struct": fromSt, "fnptr_copy_field": fromField}})
|
||||
}
|
||||
|
||||
func fnPtrDispatch(g *graph.Graph, fromID, file, st, field string) {
|
||||
if g.GetNode(fromID) == nil {
|
||||
g.AddNode(&graph.Node{ID: fromID, Kind: graph.KindFunction, Name: lastSeg(fromID), FilePath: file, Language: "c"})
|
||||
}
|
||||
g.AddEdge(&graph.Edge{From: fromID, To: "unresolved::*." + field, Kind: graph.EdgeCalls, FilePath: file,
|
||||
Meta: map[string]any{"via": fnPtrDispatchVia, "fnptr_struct": st, "fnptr_field": field}})
|
||||
}
|
||||
|
||||
func synthFnPtrEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthFnPointerDispatch {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveFnPointerDispatch_CommandTableFanOut(t *testing.T) {
|
||||
g := graph.New()
|
||||
fnDef(g, "cmds.c::cmd_add", "cmds.c", "cmd_add")
|
||||
fnDef(g, "cmds.c::cmd_rm", "cmds.c", "cmd_rm")
|
||||
fnDef(g, "cmds.c::run", "cmds.c", "run")
|
||||
fnPtrReg(g, "cmds.c", "cmd", "fn", "cmd_add")
|
||||
fnPtrReg(g, "cmds.c", "cmd", "fn", "cmd_rm")
|
||||
fnPtrDispatch(g, "cmds.c::run", "cmds.c", "cmd", "fn")
|
||||
|
||||
n := ResolveFnPointerDispatch(g)
|
||||
require.Equal(t, 2, n, "the dispatch fans out to both registered commands")
|
||||
a := synthFnPtrEdge(g, "cmds.c::run", "cmds.c::cmd_add")
|
||||
require.NotNil(t, a)
|
||||
assert.Equal(t, fnPointerConfidence, a.Confidence)
|
||||
assert.Equal(t, ProvenanceHeuristic, a.Meta[MetaProvenance])
|
||||
assert.NotNil(t, synthFnPtrEdge(g, "cmds.c::run", "cmds.c::cmd_rm"))
|
||||
}
|
||||
|
||||
func TestResolveFnPointerDispatch_FieldCopyFixpoint(t *testing.T) {
|
||||
// op_x is registered to (B, h); A.h is copied from B.h; a dispatch on A
|
||||
// must reach op_x through the fixpoint.
|
||||
g := graph.New()
|
||||
fnDef(g, "ops.c::op_x", "ops.c", "op_x")
|
||||
fnDef(g, "ops.c::run_a", "ops.c", "run_a")
|
||||
fnPtrReg(g, "ops.c", "B", "h", "op_x")
|
||||
fnPtrCopy(g, "ops.c", "A", "h", "B", "h")
|
||||
fnPtrDispatch(g, "ops.c::run_a", "ops.c", "A", "h")
|
||||
|
||||
n := ResolveFnPointerDispatch(g)
|
||||
require.Equal(t, 1, n)
|
||||
assert.NotNil(t, synthFnPtrEdge(g, "ops.c::run_a", "ops.c::op_x"),
|
||||
"the field-copy fixpoint propagates B.h's function to A.h")
|
||||
}
|
||||
|
||||
func TestResolveFnPointerDispatch_UnregisteredSlotStaysPlaceholder(t *testing.T) {
|
||||
g := graph.New()
|
||||
fnDef(g, "x.c::handler", "x.c", "handler")
|
||||
fnPtrReg(g, "x.c", "S", "f", "handler")
|
||||
// dispatch on a different slot with no registrations.
|
||||
fnPtrDispatch(g, "x.c::run", "x.c", "S", "other")
|
||||
|
||||
assert.Equal(t, 0, ResolveFnPointerDispatch(g))
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fnValueCandidateEdge mirrors what the per-language capture emits: a
|
||||
// placeholder reference into the fn-value namespace, carrying the captured name
|
||||
// in Meta for the gate to bind.
|
||||
func fnValueCandidateEdge(from, name, file string, line int) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from,
|
||||
To: fnValueUnresolvedPrefix + name,
|
||||
Kind: graph.EdgeReferences,
|
||||
FilePath: file,
|
||||
Line: line,
|
||||
Origin: graph.OriginSpeculative,
|
||||
Meta: map[string]any{
|
||||
"via": fnValueCandidateVia,
|
||||
"fn_value_name": name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fnValueUnresolvedPrefix = "unresolved::fnvalue::"
|
||||
|
||||
func boundCallbackEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v == fnValueRegistrationVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCallbackGateRejectsUnboundIdentifiers is the A3 named test: the gate binds
|
||||
// a captured value-position identifier that names a same-file function and
|
||||
// drops one that resolves to nothing, and the bound edge rides a filterable
|
||||
// provenance tier rather than a flat heuristic flag.
|
||||
func TestCallbackGateRejectsUnboundIdentifiers(t *testing.T) {
|
||||
g := graph.New()
|
||||
// A real same-file function the registration can bind to.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "router.go::handler", Kind: graph.KindFunction, Name: "handler",
|
||||
FilePath: "router.go", StartLine: 10, Language: "go",
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "router.go::register", Kind: graph.KindFunction, Name: "register",
|
||||
FilePath: "router.go", StartLine: 3, Language: "go",
|
||||
})
|
||||
// One bindable candidate (handler exists) and one unbound (ghost is a
|
||||
// local / undefined name — never a function node in this file).
|
||||
g.AddEdge(fnValueCandidateEdge("router.go::register", "handler", "router.go", 4))
|
||||
g.AddEdge(fnValueCandidateEdge("router.go::register", "ghost", "router.go", 5))
|
||||
// A builtin-shaped candidate must also be skipped before any lookup.
|
||||
g.AddEdge(fnValueCandidateEdge("router.go::register", "nil", "router.go", 6))
|
||||
|
||||
landed := ResolveFnValueCallbacks(g)
|
||||
assert.Equal(t, 1, landed, "only the bound candidate should land")
|
||||
|
||||
bound := boundCallbackEdge(g, "router.go::register", "router.go::handler")
|
||||
require.NotNil(t, bound, "the bound handler should produce a callback-registration edge")
|
||||
assert.Equal(t, graph.EdgeReferences, bound.Kind)
|
||||
assert.Equal(t, graph.OriginASTInferred, bound.Origin, "callback edge must ride a filterable tier")
|
||||
assert.Equal(t, SynthFnValueCallback, bound.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, "handler", bound.Meta["fn_value_name"])
|
||||
|
||||
// The unbound and builtin candidates must not have produced any real edge.
|
||||
for _, e := range g.GetOutEdges("router.go::register") {
|
||||
if e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v == fnValueRegistrationVia {
|
||||
assert.Equal(t, "router.go::handler", e.To, "no registration edge should bind ghost/nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackGateIdempotent confirms a second pass lands nothing new — the
|
||||
// synthesizer is a safe full-recompute.
|
||||
func TestCallbackGateIdempotent(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "h.go::onClick", Kind: graph.KindFunction, Name: "onClick",
|
||||
FilePath: "h.go", StartLine: 8, Language: "go",
|
||||
})
|
||||
g.AddEdge(fnValueCandidateEdge("h.go::wire", "onClick", "h.go", 2))
|
||||
|
||||
first := ResolveFnValueCallbacks(g)
|
||||
second := ResolveFnValueCallbacks(g)
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second, "the bound edge re-derives identically; AddEdge dedupes")
|
||||
}
|
||||
|
||||
// ungatedFnValueCandidateEdge is a qualified-path candidate the gate may resolve
|
||||
// cross-module.
|
||||
func ungatedFnValueCandidateEdge(from, name, file string, line int) *graph.Edge {
|
||||
e := fnValueCandidateEdge(from, name, file, line)
|
||||
e.Meta["fn_value_ungated"] = true
|
||||
return e
|
||||
}
|
||||
|
||||
// TestCallbackGateSameFileTier pins the high-confidence tier for a same-file
|
||||
// binding.
|
||||
func TestCallbackGateSameFileTier(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.go::handler", Kind: graph.KindFunction, Name: "handler", FilePath: "a.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "a.go::register", Kind: graph.KindFunction, Name: "register", FilePath: "a.go", Language: "go"})
|
||||
g.AddEdge(fnValueCandidateEdge("a.go::register", "handler", "a.go", 4))
|
||||
|
||||
assert.Equal(t, 1, ResolveFnValueCallbacks(g))
|
||||
e := boundCallbackEdge(g, "a.go::register", "a.go::handler")
|
||||
require.NotNil(t, e)
|
||||
assert.Equal(t, 0.6, e.Confidence, "same-file binding rides the high-confidence tier")
|
||||
}
|
||||
|
||||
// TestCallbackGateCrossModuleUngated pins that a qualified-path (ungated)
|
||||
// candidate binds to a uniquely-named function cross-module at a lower tier.
|
||||
func TestCallbackGateCrossModuleUngated(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "lib.rs::process", Kind: graph.KindFunction, Name: "process", FilePath: "lib.rs", Language: "rust"})
|
||||
g.AddNode(&graph.Node{ID: "main.rs::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.rs", Language: "rust"})
|
||||
g.AddEdge(ungatedFnValueCandidateEdge("main.rs::run", "process", "main.rs", 3))
|
||||
|
||||
assert.Equal(t, 1, ResolveFnValueCallbacks(g))
|
||||
e := boundCallbackEdge(g, "main.rs::run", "lib.rs::process")
|
||||
require.NotNil(t, e, "cross-module ungated candidate binds")
|
||||
assert.Equal(t, 0.45, e.Confidence, "cross-module binding rides the lower tier")
|
||||
}
|
||||
|
||||
// TestCallbackGateCrossModuleAmbiguousDropped pins that an ungated candidate
|
||||
// matching more than one function anywhere is refused.
|
||||
func TestCallbackGateCrossModuleAmbiguousDropped(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.rs::process", Kind: graph.KindFunction, Name: "process", FilePath: "a.rs", Language: "rust"})
|
||||
g.AddNode(&graph.Node{ID: "b.rs::process", Kind: graph.KindFunction, Name: "process", FilePath: "b.rs", Language: "rust"})
|
||||
g.AddNode(&graph.Node{ID: "main.rs::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.rs", Language: "rust"})
|
||||
g.AddEdge(ungatedFnValueCandidateEdge("main.rs::run", "process", "main.rs", 3))
|
||||
|
||||
assert.Equal(t, 0, ResolveFnValueCallbacks(g), "ambiguous cross-module candidate dropped")
|
||||
}
|
||||
|
||||
// TestCallbackGateNonUngatedStaysSameFile pins that a non-ungated candidate is
|
||||
// never resolved cross-module even when a unique match exists elsewhere.
|
||||
func TestCallbackGateNonUngatedStaysSameFile(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "lib.go::process", Kind: graph.KindFunction, Name: "process", FilePath: "lib.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "main.go::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.go", Language: "go"})
|
||||
g.AddEdge(fnValueCandidateEdge("main.go::run", "process", "main.go", 3)) // not ungated
|
||||
|
||||
assert.Equal(t, 0, ResolveFnValueCallbacks(g), "non-ungated candidate never binds cross-module")
|
||||
}
|
||||
|
||||
func specialFnValueEdge(from, name, file string, line int, recvHint string) *graph.Edge {
|
||||
e := fnValueCandidateEdge(from, name, file, line)
|
||||
e.Meta["fn_ref_form"] = "special"
|
||||
if recvHint != "" {
|
||||
e.Meta["fn_ref_recv_hint"] = recvHint
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// TestCallbackGateSpecialSelfMember pins that a `this.m` reference binds to the
|
||||
// enclosing type's method, never a coincidentally-named top-level function.
|
||||
func TestCallbackGateSpecialSelfMember(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "c.ts::C.handle", Kind: graph.KindMethod, Name: "handle", FilePath: "c.ts", Meta: map[string]any{"receiver": "C"}})
|
||||
g.AddNode(&graph.Node{ID: "c.ts::C.wire", Kind: graph.KindMethod, Name: "wire", FilePath: "c.ts", Meta: map[string]any{"receiver": "C"}})
|
||||
g.AddNode(&graph.Node{ID: "other.ts::handle", Kind: graph.KindFunction, Name: "handle", FilePath: "other.ts"})
|
||||
g.AddEdge(specialFnValueEdge("c.ts::C.wire", "handle", "c.ts", 5, "<self>"))
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
bound := boundCallbackEdge(g, "c.ts::C.wire", "c.ts::C.handle")
|
||||
require.NotNil(t, bound, "this.handle binds to the enclosing class's handle")
|
||||
assert.Equal(t, "special", bound.Meta["fn_ref_form"])
|
||||
assert.Equal(t, graph.OriginASTResolved, bound.Origin)
|
||||
assert.Nil(t, boundCallbackEdge(g, "c.ts::C.wire", "other.ts::handle"), "must not bind the top-level handle")
|
||||
}
|
||||
|
||||
// TestCallbackGateSpecialTypeQualified pins that `Foo::bar` binds to type Foo's
|
||||
// bar method, not a same-named free function.
|
||||
func TestCallbackGateSpecialTypeQualified(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "f.java::Foo.bar", Kind: graph.KindMethod, Name: "bar", FilePath: "f.java", Meta: map[string]any{"receiver": "Foo"}})
|
||||
g.AddNode(&graph.Node{ID: "g.java::bar", Kind: graph.KindFunction, Name: "bar", FilePath: "g.java"})
|
||||
g.AddNode(&graph.Node{ID: "m.java::M.run", Kind: graph.KindMethod, Name: "run", FilePath: "m.java", Meta: map[string]any{"receiver": "M"}})
|
||||
e := specialFnValueEdge("m.java::M.run", "bar", "m.java", 3, "Foo")
|
||||
e.Meta["fn_value_ungated"] = true
|
||||
g.AddEdge(e)
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
bound := boundCallbackEdge(g, "m.java::M.run", "f.java::Foo.bar")
|
||||
require.NotNil(t, bound, "Foo::bar binds to Foo's bar method")
|
||||
assert.Equal(t, graph.OriginASTResolved, bound.Origin)
|
||||
assert.Equal(t, "special", bound.Meta["fn_ref_form"])
|
||||
assert.Nil(t, boundCallbackEdge(g, "m.java::M.run", "g.java::bar"), "must not bind the free function")
|
||||
}
|
||||
|
||||
// TestCallbackGateSkipGateUnique pins a curated-HOF string callable binding to
|
||||
// the sole repo-wide function of that name at the gate-skip tier.
|
||||
func TestCallbackGateSkipGateUnique(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "lib.php::helper", Kind: graph.KindFunction, Name: "helper", FilePath: "lib.php"})
|
||||
g.AddNode(&graph.Node{ID: "main.php::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.php"})
|
||||
e := fnValueCandidateEdge("main.php::run", "helper", "main.php", 3)
|
||||
e.Meta["skip_gate"] = true
|
||||
e.Meta["fn_ref_form"] = "php_string_callable"
|
||||
g.AddEdge(e)
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
bound := boundCallbackEdge(g, "main.php::run", "lib.php::helper")
|
||||
require.NotNil(t, bound, "unique string callable binds cross-file")
|
||||
assert.Equal(t, 0.5, bound.Confidence)
|
||||
assert.Equal(t, "php_string_callable", bound.Meta["fn_ref_form"])
|
||||
}
|
||||
|
||||
// TestCallbackGateSkipGateAmbiguousDropped pins that a string callable whose
|
||||
// name has two definitions is dropped.
|
||||
func TestCallbackGateSkipGateAmbiguousDropped(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.php::helper", Kind: graph.KindFunction, Name: "helper", FilePath: "a.php"})
|
||||
g.AddNode(&graph.Node{ID: "b.php::helper", Kind: graph.KindFunction, Name: "helper", FilePath: "b.php"})
|
||||
g.AddNode(&graph.Node{ID: "main.php::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.php"})
|
||||
e := fnValueCandidateEdge("main.php::run", "helper", "main.php", 3)
|
||||
e.Meta["skip_gate"] = true
|
||||
g.AddEdge(e)
|
||||
|
||||
assert.Equal(t, 0, ResolveFnValueCallbacks(g), "ambiguous string callable dropped")
|
||||
}
|
||||
|
||||
// TestCallbackGateSkipGateStaticString pins that a `'Foo::bar'` string callable
|
||||
// binds to Foo's bar method, not a same-named free function.
|
||||
func TestCallbackGateSkipGateStaticString(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "f.php::Foo.bar", Kind: graph.KindMethod, Name: "bar", FilePath: "f.php", Meta: map[string]any{"receiver": "Foo"}})
|
||||
g.AddNode(&graph.Node{ID: "g.php::bar", Kind: graph.KindFunction, Name: "bar", FilePath: "g.php"})
|
||||
g.AddNode(&graph.Node{ID: "main.php::run", Kind: graph.KindFunction, Name: "run", FilePath: "main.php"})
|
||||
e := fnValueCandidateEdge("main.php::run", "bar", "main.php", 3)
|
||||
e.Meta["skip_gate"] = true
|
||||
e.Meta["fn_ref_recv_hint"] = "Foo"
|
||||
g.AddEdge(e)
|
||||
|
||||
ResolveFnValueCallbacks(g)
|
||||
require.NotNil(t, boundCallbackEdge(g, "main.php::run", "f.php::Foo.bar"), "Foo::bar binds to Foo.bar")
|
||||
assert.Nil(t, boundCallbackEdge(g, "main.php::run", "g.php::bar"), "must not bind the free function")
|
||||
}
|
||||
|
||||
func TestResolveMemberByType(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a::Foo.m", Kind: graph.KindMethod, Name: "m", Meta: map[string]any{"receiver": "Foo"}})
|
||||
g.AddNode(&graph.Node{ID: "a::Bar.m", Kind: graph.KindMethod, Name: "m", Meta: map[string]any{"receiver": "Bar"}})
|
||||
assert.Equal(t, "a::Foo.m", resolveMemberByType(g, "Foo", "m"))
|
||||
assert.Equal(t, "a::Bar.m", resolveMemberByType(g, "Bar", "m"))
|
||||
assert.Equal(t, "", resolveMemberByType(g, "Baz", "m"), "unknown type does not bind")
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Framework-dispatch synthesizer engine.
|
||||
//
|
||||
// Direct AST/LSP resolution lands the calls a compiler can see. A large
|
||||
// class of real call edges, though, is wired by a *framework* at runtime
|
||||
// and is invisible to static resolution: a gRPC client stub dispatched
|
||||
// to its server handler, a Temporal workflow proxy to its activity, an
|
||||
// event published on one side of an in-process channel and handled on
|
||||
// the other, a JS bridge method routed to its native implementation.
|
||||
//
|
||||
// FrameworkSynthesizer is the plugin contract for a pass that
|
||||
// materialises one such family of edges. Every synthesizer is a
|
||||
// full-recompute, idempotent pass: it derives each edge it owns from
|
||||
// durable graph state (placeholder edges plus their Meta markers, shared
|
||||
// topic nodes, registration call edges) so a reindex of any endpoint
|
||||
// re-lands or un-lands the edge without leaving a stale one behind —
|
||||
// graph.AddEdge dedupes by edge key and graph.EvictFile drops a node's
|
||||
// edges in both directions. Every edge a synthesizer lands is stamped
|
||||
// with provenance (StampSynthesized) so its origin is auditable and the
|
||||
// `analyze kind=synthesizers` roll-up can attribute it.
|
||||
//
|
||||
// The engine is the single orchestration point: the indexers call
|
||||
// RunFrameworkSynthesizers at every settle point (full index, watcher
|
||||
// reindex, incremental reindex) in place of invoking each pass directly,
|
||||
// so adding a synthesizer (a native-bridge resolver, an event-channel
|
||||
// pass) is one line in defaultFrameworkSynthesizers rather than an edit
|
||||
// at six call sites.
|
||||
type FrameworkSynthesizer interface {
|
||||
// Name is the stable provenance tag stamped on every edge the
|
||||
// synthesizer lands (lower-kebab, e.g. "grpc-stub", "event-channel").
|
||||
Name() string
|
||||
// Synthesize runs the pass over g and returns the number of edges the
|
||||
// synthesizer owns (landed on a real target) after this run.
|
||||
Synthesize(g graph.Store) int
|
||||
}
|
||||
|
||||
// Edge.Meta keys stamped by StampSynthesized.
|
||||
const (
|
||||
// MetaSynthesizedBy names the synthesizer that produced an edge.
|
||||
MetaSynthesizedBy = "synthesized_by"
|
||||
// MetaProvenance records that an edge is a heuristic materialisation
|
||||
// rather than a compiler-verified fact.
|
||||
MetaProvenance = "provenance"
|
||||
// ProvenanceHeuristic is the MetaProvenance value the string- and
|
||||
// name-keyed framework synthesizers stamp — these edges are
|
||||
// framework-dispatch inferences correlated by a literal (an event
|
||||
// name, a dispatch string, a registry key) with no type evidence.
|
||||
ProvenanceHeuristic = "heuristic"
|
||||
// ProvenanceFramework is the MetaProvenance value the typed,
|
||||
// decorator-, base-list- or type-keyed synthesizers stamp — the
|
||||
// framework's own contract (a decorator, a generic base, a typed
|
||||
// listener parameter) names the target, so the edge carries more
|
||||
// confidence than a string-correlated guess. analyze kind=synthesizers
|
||||
// reports the two tiers separately from the same MetaProvenance read.
|
||||
ProvenanceFramework = "framework"
|
||||
)
|
||||
|
||||
// Confidence tiers the framework synthesizers stamp on a landed edge.
|
||||
// Typed/decorator/base-list/type-keyed passes (RTK Query, Celery, Spring,
|
||||
// MediatR, Sidekiq, Laravel, GoFrame) use ConfidenceTyped; the string-
|
||||
// and name-keyed passes (Vuex, Redux-thunk, object-registry, fn-pointer,
|
||||
// Django) use ConfidenceHeuristic.
|
||||
const (
|
||||
// ConfidenceTyped is the confidence for a type-/decorator-/base-list-
|
||||
// keyed dispatch edge — the framework contract names the target.
|
||||
ConfidenceTyped = 0.85
|
||||
// ConfidenceHeuristic is the confidence for a string-/name-keyed
|
||||
// dispatch edge — correlated by a literal, not by a type.
|
||||
ConfidenceHeuristic = 0.6
|
||||
)
|
||||
|
||||
// Stable per-synthesizer provenance names. Used both as the registry
|
||||
// label (for the report grouping) and as the value stamped on each
|
||||
// landed edge, so the two never drift.
|
||||
const (
|
||||
SynthGRPCStub = "grpc-stub"
|
||||
SynthTemporalStub = "temporal-stub"
|
||||
SynthEventChannel = "event-channel"
|
||||
SynthSwiftObjC = "swift-objc-bridge"
|
||||
SynthReactNative = "react-native-bridge"
|
||||
SynthReactNativePair = "react-native-native-pair"
|
||||
SynthObserverChannel = "observer-channel"
|
||||
SynthClosureCollection = "closure-collection"
|
||||
SynthReactSetState = "react-setstate"
|
||||
SynthFlutterSetState = "flutter-setstate"
|
||||
SynthKMPExpectActual = "kmp-expect-actual"
|
||||
SynthExpoModules = "expo-modules-bridge"
|
||||
SynthFabric = "fabric-codegen"
|
||||
SynthMyBatis = "mybatis"
|
||||
SynthRustScope = "rust-scope"
|
||||
SynthFactoryChain = "factory-chain"
|
||||
SynthSQLCallsite = "sql-callsite"
|
||||
SynthStoreFactory = "store-factory"
|
||||
SynthReduxThunk = "redux-thunk"
|
||||
SynthNgRxEffect = "ngrx-effect"
|
||||
SynthObjectRegistry = "object-registry"
|
||||
SynthRTKQuery = "rtk-query"
|
||||
SynthVuexDispatch = "vuex-dispatch"
|
||||
SynthCelery = "celery-dispatch"
|
||||
SynthSpringEvent = "spring-event"
|
||||
SynthMediatR = "mediatr-dispatch"
|
||||
SynthCSharpIfaceDispatch = "csharp-iface-dispatch"
|
||||
SynthSidekiq = "sidekiq-dispatch"
|
||||
SynthLaravelEvent = "laravel-event"
|
||||
SynthFnPointerDispatch = "fn-pointer-dispatch"
|
||||
SynthMacroExpansion = "macro-expansion"
|
||||
SynthGoFrameRoute = "goframe-route"
|
||||
SynthDjangoDescriptor = "django-descriptor"
|
||||
SynthExpressResolve = "express-resolve"
|
||||
SynthReactResolve = "react-resolve"
|
||||
SynthFastAPIResolve = "fastapi-resolve"
|
||||
SynthRailsResolve = "rails-resolve"
|
||||
SynthSwiftUIResolve = "swiftui-resolve"
|
||||
SynthUIKitResolve = "uikit-resolve"
|
||||
SynthVaporResolve = "vapor-resolve"
|
||||
SynthGinMiddleware = "gin-middleware"
|
||||
SynthSvelteKitLoad = "sveltekit-load"
|
||||
SynthSpeculative = "speculative-dispatch"
|
||||
SynthFnValue = SynthFnValueCallback
|
||||
SynthPascalFormName = SynthPascalForm
|
||||
SynthValueRefName = SynthValueRef
|
||||
)
|
||||
|
||||
// StampSynthesized marks an edge as the product of a framework
|
||||
// synthesizer: which synthesizer produced it (name) and that it is a
|
||||
// heuristic materialisation. Safe on an edge with a nil Meta map.
|
||||
func StampSynthesized(e *graph.Edge, name string) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta[MetaSynthesizedBy] = name
|
||||
if _, ok := e.Meta[MetaProvenance]; !ok {
|
||||
e.Meta[MetaProvenance] = ProvenanceHeuristic
|
||||
}
|
||||
}
|
||||
|
||||
// StampSynthesizedTyped marks an edge as the product of a typed-tier
|
||||
// framework synthesizer: like StampSynthesized, but records
|
||||
// ProvenanceFramework instead of ProvenanceHeuristic so the
|
||||
// type-/decorator-/base-list-keyed passes (RTK Query, Celery, Spring,
|
||||
// MediatR, Sidekiq, Laravel, GoFrame) separate from the string-keyed
|
||||
// ones in analyze kind=synthesizers. Safe on an edge with a nil Meta map.
|
||||
func StampSynthesizedTyped(e *graph.Edge, name string) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta[MetaProvenance] = ProvenanceFramework
|
||||
StampSynthesized(e, name)
|
||||
}
|
||||
|
||||
// UnstampSynthesized clears the provenance markers an edge picked up from
|
||||
// a synthesizer. Called when a pass re-orphans an edge (its target
|
||||
// disappeared) so the edge reads as a plain placeholder again.
|
||||
func UnstampSynthesized(e *graph.Edge) {
|
||||
if e == nil || e.Meta == nil {
|
||||
return
|
||||
}
|
||||
delete(e.Meta, MetaSynthesizedBy)
|
||||
delete(e.Meta, MetaProvenance)
|
||||
}
|
||||
|
||||
// synthFunc adapts a plain pass function into a FrameworkSynthesizer so
|
||||
// the existing passes (ResolveGRPCStubCalls, …) register without a
|
||||
// wrapper type each.
|
||||
//
|
||||
// scopedFn is optional: when set, the end-of-batch driver calls it with the
|
||||
// changed-repo prefix set so the synthesizer can restrict its CANDIDATE scan to
|
||||
// those repos (resolution stays whole-graph). A synthesizer without a scopedFn
|
||||
// always runs whole-graph — correct, just not narrowed — so scoping any one
|
||||
// pass is opt-in and additive.
|
||||
type synthFunc struct {
|
||||
name string
|
||||
fn func(graph.Store) int
|
||||
scopedFn func(graph.Store, map[string]bool) int
|
||||
}
|
||||
|
||||
func (s synthFunc) Name() string { return s.name }
|
||||
func (s synthFunc) Synthesize(g graph.Store) int { return s.fn(g) }
|
||||
|
||||
// synthesizeScoped runs the scoped variant when one is registered and a scope
|
||||
// is armed; otherwise it falls back to the whole-graph pass. A nil scope always
|
||||
// means whole-graph, so the fresh-index path is byte-identical to before.
|
||||
func (s synthFunc) synthesizeScoped(g graph.Store, scope map[string]bool) int {
|
||||
if scope != nil && s.scopedFn != nil {
|
||||
return s.scopedFn(g, scope)
|
||||
}
|
||||
return s.fn(g)
|
||||
}
|
||||
|
||||
// defaultFrameworkSynthesizers returns the registered framework
|
||||
// synthesizers in run order. Order is load-bearing: every synthesizer
|
||||
// here runs after InferImplements/InferOverrides (some depend on the
|
||||
// EdgeImplements edges they produce) and before DetectCrossRepoEdges (so
|
||||
// a cross-repo synthesized call gets its parallel cross_repo_calls edge).
|
||||
// Native-bridge resolvers append to this slice.
|
||||
func defaultFrameworkSynthesizers() []FrameworkSynthesizer {
|
||||
return []FrameworkSynthesizer{
|
||||
synthFunc{name: SynthGRPCStub, fn: ResolveGRPCStubCalls},
|
||||
synthFunc{name: SynthTemporalStub, fn: ResolveTemporalCalls},
|
||||
synthFunc{name: SynthEventChannel, fn: ResolveEventChannelCalls},
|
||||
synthFunc{name: SynthSwiftObjC, fn: ResolveSwiftObjCBridge},
|
||||
synthFunc{name: SynthReactNative, fn: ResolveReactNativeBridge},
|
||||
synthFunc{name: SynthReactNativePair, fn: ResolveReactNativeNativePairing},
|
||||
synthFunc{name: SynthObserverChannel, fn: ResolveObserverChannelCalls},
|
||||
synthFunc{name: SynthClosureCollection, fn: ResolveClosureCollectionCalls},
|
||||
synthFunc{name: SynthReactSetState, fn: ResolveReactSetStateCalls},
|
||||
synthFunc{name: SynthFlutterSetState, fn: ResolveFlutterSetStateCalls},
|
||||
synthFunc{name: SynthKMPExpectActual, fn: ResolveKMPExpectActual},
|
||||
synthFunc{name: SynthExpoModules, fn: ResolveExpoModuleBridge},
|
||||
synthFunc{name: SynthFabric, fn: ResolveFabricComponents},
|
||||
synthFunc{name: SynthMyBatis, fn: ResolveMyBatisCalls},
|
||||
synthFunc{name: SynthSQLCallsite, fn: ResolveSQLCallsites},
|
||||
// Store-factory (Zustand/Redux/Pinia/MobX) indirect action calls —
|
||||
// binds getState()-chain and destructured calls to the action node.
|
||||
synthFunc{name: SynthStoreFactory, fn: ResolveStoreFactoryCalls},
|
||||
// Redux Toolkit createAsyncThunk dispatch chains: a thunk →
|
||||
// each action/thunk it dispatches from its payload-creator body.
|
||||
// After store-factory so its action nodes are indexed for the
|
||||
// thunk → reducer cross-link.
|
||||
synthFunc{name: SynthReduxThunk, fn: ResolveReduxThunkCalls},
|
||||
// NgRx effects: a createEffect(() => actions$.pipe(ofType(X))) effect ->
|
||||
// the action X it reacts to. After the store/thunk passes so action
|
||||
// creator nodes are indexed.
|
||||
synthFunc{name: SynthNgRxEffect, fn: ResolveNgRxEffects},
|
||||
// Object-literal command/handler registry dispatch →
|
||||
// `new registry[key]().execute()`. Runs before the speculative
|
||||
// pass so a claimed dispatch site suppresses the hidden best-guess.
|
||||
synthFunc{name: SynthObjectRegistry, fn: ResolveObjectRegistryCalls},
|
||||
// RTK Query generated-hook → createApi endpoint, and component →
|
||||
// generated hook. Typed tier: the hook naming is RTK-contractual.
|
||||
synthFunc{name: SynthRTKQuery, fn: ResolveRTKQueryCalls},
|
||||
// Vuex string-keyed dispatch/commit → action/mutation, with
|
||||
// module-namespace disambiguation.
|
||||
synthFunc{name: SynthVuexDispatch, fn: ResolveVuexDispatchCalls},
|
||||
// Celery task dispatch: `task.delay()` / `send_task("name")` →
|
||||
// the decorator-gated task function. Typed tier.
|
||||
synthFunc{name: SynthCelery, fn: ResolveCeleryCalls},
|
||||
// Spring application events: publishEvent(new X()) → every
|
||||
// @EventListener / ApplicationListener<X>, type-keyed fan-out.
|
||||
synthFunc{name: SynthSpringEvent, fn: ResolveSpringEventCalls},
|
||||
// MediatR CQRS dispatch: Send(new X()) → the IRequestHandler<X>
|
||||
// Handle, Publish(new X()) → every INotificationHandler<X>.
|
||||
synthFunc{name: SynthMediatR, fn: ResolveMediatRCalls},
|
||||
// C# member-level interface dispatch: a call bound to an interface
|
||||
// member fans out to the same-named member on each in-repo
|
||||
// implementation, at the ast_inferred tier so it rides in the default
|
||||
// find_usages / get_callers result. After the implements-producing
|
||||
// passes so the impl fan-out is complete.
|
||||
synthFunc{name: SynthCSharpIfaceDispatch, fn: ResolveCSharpInterfaceDispatch},
|
||||
// Sidekiq job dispatch: Worker.perform_async(...) → the worker's
|
||||
// perform, namespace-aware. Include-gated, typed tier.
|
||||
synthFunc{name: SynthSidekiq, fn: ResolveSidekiqCalls},
|
||||
// Laravel events: event(new X()) / X::dispatch() → every listener
|
||||
// handle(X), from the Listeners convention and the $listen map.
|
||||
synthFunc{name: SynthLaravelEvent, fn: ResolveLaravelEventCalls},
|
||||
// C/C++ function-pointer dispatch: a fn registered into a struct's
|
||||
// fn-pointer field → the indirect recv->field() call, keyed by
|
||||
// (struct type, field) with a field-copy fixpoint.
|
||||
synthFunc{name: SynthFnPointerDispatch, fn: ResolveFnPointerDispatch},
|
||||
// C/C++ function-like macro expansion: a macro invocation
|
||||
// `CALL_M(o)` → each call hidden in the macro's replacement list,
|
||||
// attributed to the use-site line so a forward call walk shows the
|
||||
// call where the macro is invoked, not at its `#define`.
|
||||
synthFunc{name: SynthMacroExpansion, fn: ResolveMacroExpansionCalls},
|
||||
// Gin middleware-chain dispatcher → registered handlers. Bridges the
|
||||
// `c.handlers[idx](c)` indirection so ServeHTTP→handler reachability
|
||||
// flows; repo-scoped, gated on a dispatcher existing.
|
||||
synthFunc{name: SynthGinMiddleware, fn: ResolveGinMiddlewareCalls},
|
||||
// Express named-handler resolution: middleware idents and
|
||||
// XController.method args bound by directory convention.
|
||||
synthFunc{name: SynthExpressResolve, fn: ResolveExpressHandlers},
|
||||
// React custom-hook / context resolution: a `useAuth()` call binds to
|
||||
// its /hooks/ definition; a `*Context`/`*Provider` reference binds to
|
||||
// /context/ or /providers/, with the suffix-strip fallback.
|
||||
synthFunc{name: SynthReactResolve, fn: ResolveReactHooksContext},
|
||||
// FastAPI dependency / router fallback: a residual `Depends(get_db)`
|
||||
// binds to a /dependencies/ provider, an `include_router(api_router)`
|
||||
// to a /routers/ definition — only when reference resolution left the
|
||||
// target unresolved.
|
||||
synthFunc{name: SynthFastAPIResolve, fn: ResolveFastAPIDeps},
|
||||
// Rails receiver-constant resolution: a `UserService.perform` /
|
||||
// `User.find` / `ApplicationHelper.fmt` call binds to the directory-
|
||||
// located service / model / helper definition named by its receiver.
|
||||
synthFunc{name: SynthRailsResolve, fn: ResolveRailsRefs},
|
||||
// SwiftUI directory-convention fallback: a residual `*View` /
|
||||
// `*ViewModel` / `*Store` / `*Manager` / PascalCase-model reference
|
||||
// binds to its /Views/ /ViewModels/ /Stores/ /Models/ definition.
|
||||
synthFunc{name: SynthSwiftUIResolve, fn: ResolveSwiftUIRefs},
|
||||
// UIKit directory-convention fallback: a residual `*ViewController` /
|
||||
// `*Cell` / `*Delegate` / `*DataSource` reference binds to its
|
||||
// /ViewControllers/ /Cells/ /Delegates/ definition.
|
||||
synthFunc{name: SynthUIKitResolve, fn: ResolveUIKitRefs},
|
||||
// Vapor directory-convention fallback: a residual `*Controller` /
|
||||
// `*Middleware` reference binds to its /Controllers/ /Middleware/
|
||||
// definition. After UIKit so `*ViewController` binds there first.
|
||||
synthFunc{name: SynthVaporResolve, fn: ResolveVaporRefs},
|
||||
// GoFrame reflective route → controller method, joined by the
|
||||
// method's request-struct type rather than its name.
|
||||
synthFunc{name: SynthGoFrameRoute, fn: ResolveGoFrameRoutes},
|
||||
// SvelteKit +page ↔ +page.server load pairing: a route's page component
|
||||
// reaches its server data loader so a trace flows page→load. Repo-scoped.
|
||||
synthFunc{name: SynthSvelteKitLoad, fn: ResolveSvelteKitLoad},
|
||||
// Rust impl-block / self-receiver / module-path resolution
|
||||
// completion. Runs in the same settle window so residual
|
||||
// unresolved Rust calls land before external-call synthesis
|
||||
// classifies the rest as external.
|
||||
synthFunc{name: SynthRustScope, fn: ResolveRustScopeCalls},
|
||||
// After rust-scope and the implements/extends-producing passes so the
|
||||
// cross-file factory-chain walk + conformance hop see settled edges.
|
||||
synthFunc{name: SynthFactoryChain, fn: ResolveFactoryChains},
|
||||
// Function-as-value callback registration — binds each captured
|
||||
// value-position function identifier to its same-file definition and
|
||||
// drops unbound candidates. The per-language capture feeds it via
|
||||
// placeholder edges; the pass is inert until those land.
|
||||
synthFunc{name: SynthFnValue, fn: ResolveFnValueCallbacks, scopedFn: ResolveFnValueCallbacksScoped},
|
||||
// Pascal unit ↔ form (.pas/.dfm) pairing by same-dir basename.
|
||||
synthFunc{name: SynthPascalFormName, fn: ResolvePascalForms},
|
||||
// Same-file distinctive value references → EdgeReads to the constant,
|
||||
// so a config constant's blast radius reaches every reader.
|
||||
synthFunc{name: SynthValueRefName, fn: ResolveValueRefs, scopedFn: ResolveValueRefsScoped},
|
||||
}
|
||||
}
|
||||
|
||||
// SynthCount is the per-synthesizer result row in a FrameworkSynthReport.
|
||||
type SynthCount struct {
|
||||
Name string `json:"name"`
|
||||
Edges int `json:"edges"`
|
||||
// Millis is how long this synthesizer's Synthesize call took. Named
|
||||
// passes that land 0 edges are not free — many scan a shared edge/node
|
||||
// kind across the whole graph before concluding there is nothing to
|
||||
// bind — so this rides on every row, not just the ones with edges.
|
||||
Millis int64 `json:"ms,omitempty"`
|
||||
}
|
||||
|
||||
// FrameworkSynthReport is the aggregate result of one
|
||||
// RunFrameworkSynthesizers invocation.
|
||||
type FrameworkSynthReport struct {
|
||||
Total int `json:"total"`
|
||||
Per []SynthCount `json:"per_synthesizer"`
|
||||
// Gated counts synthesized reference/import edges dropped by the
|
||||
// cross-language-family gate (coincidental PascalCase collisions across
|
||||
// two known, different families; bridge synthesizers are exempt).
|
||||
Gated int `json:"gated_cross_family,omitempty"`
|
||||
// ReceiverGated counts C# member-call edges demoted to the speculative
|
||||
// tier because they attach to a same-named member of a type unrelated to
|
||||
// the edge's receiver_type.
|
||||
ReceiverGated int `json:"receiver_type_gated,omitempty"`
|
||||
// GateMillis/ClaimMillis/DemoteMillis time the three tail passes that
|
||||
// run once (not per-synthesizer) after the main loop, so a slow one
|
||||
// doesn't hide behind the loop's aggregate elapsed.
|
||||
GateMillis int64 `json:"gate_ms,omitempty"`
|
||||
ClaimMillis int64 `json:"claim_ms,omitempty"`
|
||||
DemoteMillis int64 `json:"demote_ms,omitempty"`
|
||||
}
|
||||
|
||||
// scopedSynthesizer is the optional capability a FrameworkSynthesizer exposes
|
||||
// when it can restrict its candidate scan to a changed-repo prefix set. The
|
||||
// driver consults it only when a scope is armed; a synthesizer that does not
|
||||
// implement it runs whole-graph, which is always correct.
|
||||
type scopedSynthesizer interface {
|
||||
synthesizeScoped(g graph.Store, scope map[string]bool) int
|
||||
}
|
||||
|
||||
// RunFrameworkSynthesizers runs every registered framework synthesizer
|
||||
// over g, in registration order, and returns the per-synthesizer and
|
||||
// total landed-edge counts. A nil graph is a no-op.
|
||||
func RunFrameworkSynthesizers(g graph.Store) FrameworkSynthReport {
|
||||
return RunFrameworkSynthesizersScoped(g, nil)
|
||||
}
|
||||
|
||||
// RunFrameworkSynthesizersScoped is RunFrameworkSynthesizers with an armed
|
||||
// changed-repo scope: each synthesizer that implements scopedSynthesizer
|
||||
// narrows its candidate scan to those repos, the rest run whole-graph. A nil
|
||||
// scope runs every pass whole-graph, so the fresh-index / single-repo path is
|
||||
// byte-identical to the pre-scoping behaviour. The claiming-resolver, family-
|
||||
// gate and receiver-gate tail passes always run whole-graph — they reconcile
|
||||
// the settled cross-repo call graph, not a per-repo candidate set.
|
||||
func RunFrameworkSynthesizersScoped(g graph.Store, scope map[string]bool) FrameworkSynthReport {
|
||||
rep := FrameworkSynthReport{}
|
||||
if g == nil {
|
||||
return rep
|
||||
}
|
||||
for _, s := range defaultFrameworkSynthesizers() {
|
||||
start := time.Now()
|
||||
var n int
|
||||
if ss, ok := s.(scopedSynthesizer); ok {
|
||||
n = ss.synthesizeScoped(g, scope)
|
||||
} else {
|
||||
n = s.Synthesize(g)
|
||||
}
|
||||
rep.Per = append(rep.Per, SynthCount{Name: s.Name(), Edges: n, Millis: time.Since(start).Milliseconds()})
|
||||
rep.Total += n
|
||||
}
|
||||
// Drop coincidental cross-language-family reference/import results before
|
||||
// the claiming resolvers run, so a gated edge cannot be mistaken for a
|
||||
// resolved placeholder downstream. Bridge synthesizers are exempt.
|
||||
gateStart := time.Now()
|
||||
rep.Gated = applyFrameworkFamilyGate(g)
|
||||
rep.GateMillis = time.Since(gateStart).Milliseconds()
|
||||
// Claiming resolvers run last — after every framework synthesizer has
|
||||
// had its chance to consume a pre-stamped placeholder, but before
|
||||
// external-call synthesis classifies the residual unresolved refs as
|
||||
// external. Reported in registration order for determinism.
|
||||
claimStart := time.Now()
|
||||
claimed := RunClaimingResolvers(g)
|
||||
rep.ClaimMillis = time.Since(claimStart).Milliseconds()
|
||||
for _, r := range defaultClaimingResolvers() {
|
||||
n := claimed[r.Name()]
|
||||
rep.Per = append(rep.Per, SynthCount{Name: r.Name(), Edges: n})
|
||||
rep.Total += n
|
||||
}
|
||||
// Receiver-type gate runs last: it corrects (demotes) already-bound C#
|
||||
// member calls, so it must see the settled call graph.
|
||||
demoteStart := time.Now()
|
||||
rep.ReceiverGated = demoteCSharpMisattributedMemberCalls(g)
|
||||
rep.DemoteMillis = time.Since(demoteStart).Milliseconds()
|
||||
return rep
|
||||
}
|
||||
|
||||
// ClaimingResolver retroactively claims a residual unresolved reference —
|
||||
// one naming no declared symbol — that the extractor could not pre-tag, and
|
||||
// rewrites it to a framework-known target. This is the generic
|
||||
// claimsReference hook: a resolver offers a cheap name-vocabulary pre-filter
|
||||
// (Claims) and, when it wins, rebinds the edge (Resolve). It runs before
|
||||
// external-call synthesis would otherwise discard the reference as external.
|
||||
type ClaimingResolver interface {
|
||||
// Name is the stable provenance label stamped on the rebound edge.
|
||||
Name() string
|
||||
// Claims reports whether this resolver wants the unresolved edge — a
|
||||
// cheap pre-filter on the reference's vocabulary, no graph work.
|
||||
Claims(e *graph.Edge) bool
|
||||
// Resolve rebinds e.To to a concrete target, returning true on a hit.
|
||||
Resolve(g graph.Store, e *graph.Edge) bool
|
||||
}
|
||||
|
||||
// defaultClaimingResolvers returns the registered claiming resolvers, in
|
||||
// offer order.
|
||||
func defaultClaimingResolvers() []ClaimingResolver {
|
||||
return []ClaimingResolver{
|
||||
DjangoDescriptorResolver{},
|
||||
}
|
||||
}
|
||||
|
||||
// RunClaimingResolvers offers every residual unresolved EdgeCalls /
|
||||
// EdgeReferences to the claiming resolvers; the first whose Claims pre-filter
|
||||
// passes and whose Resolve lands a target wins. Returns the per-resolver
|
||||
// count of claimed edges. Unresolved edges are collected before resolving so
|
||||
// a resolver's ReindexEdges does not mutate a live iteration.
|
||||
func RunClaimingResolvers(g graph.Store) map[string]int {
|
||||
out := map[string]int{}
|
||||
if g == nil {
|
||||
return out
|
||||
}
|
||||
resolvers := defaultClaimingResolvers()
|
||||
if len(resolvers) == 0 {
|
||||
return out
|
||||
}
|
||||
var pending []*graph.Edge
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e != nil && e.To != "" && graph.IsUnresolvedTarget(e.To) {
|
||||
pending = append(pending, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range pending {
|
||||
for _, r := range resolvers {
|
||||
if r.Claims(e) && r.Resolve(g, e) {
|
||||
out[r.Name()]++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestStampSynthesized(t *testing.T) {
|
||||
e := &graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls}
|
||||
StampSynthesized(e, SynthEventChannel)
|
||||
assert.Equal(t, SynthEventChannel, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, ProvenanceHeuristic, e.Meta[MetaProvenance])
|
||||
|
||||
// Does not clobber an explicit provenance already set.
|
||||
e2 := &graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls, Meta: map[string]any{MetaProvenance: "verified"}}
|
||||
StampSynthesized(e2, SynthGRPCStub)
|
||||
assert.Equal(t, "verified", e2.Meta[MetaProvenance])
|
||||
assert.Equal(t, SynthGRPCStub, e2.Meta[MetaSynthesizedBy])
|
||||
|
||||
UnstampSynthesized(e)
|
||||
_, hasBy := e.Meta[MetaSynthesizedBy]
|
||||
_, hasProv := e.Meta[MetaProvenance]
|
||||
assert.False(t, hasBy)
|
||||
assert.False(t, hasProv)
|
||||
|
||||
// nil-safe.
|
||||
StampSynthesized(nil, SynthGRPCStub)
|
||||
UnstampSynthesized(nil)
|
||||
}
|
||||
|
||||
func TestStampSynthesizedTyped(t *testing.T) {
|
||||
// The typed-tier stamp records ProvenanceFramework, not Heuristic.
|
||||
e := &graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls}
|
||||
StampSynthesizedTyped(e, SynthEventChannel)
|
||||
assert.Equal(t, SynthEventChannel, e.Meta[MetaSynthesizedBy])
|
||||
assert.Equal(t, ProvenanceFramework, e.Meta[MetaProvenance])
|
||||
|
||||
// The two provenance tiers are distinct values so analyze
|
||||
// kind=synthesizers can separate them from one MetaProvenance read.
|
||||
assert.NotEqual(t, ProvenanceHeuristic, ProvenanceFramework)
|
||||
|
||||
// Confidence tiers carry the documented values.
|
||||
assert.Equal(t, 0.85, ConfidenceTyped)
|
||||
assert.Equal(t, 0.6, ConfidenceHeuristic)
|
||||
|
||||
// UnstampSynthesized clears the typed provenance too.
|
||||
UnstampSynthesized(e)
|
||||
_, hasBy := e.Meta[MetaSynthesizedBy]
|
||||
_, hasProv := e.Meta[MetaProvenance]
|
||||
assert.False(t, hasBy)
|
||||
assert.False(t, hasProv)
|
||||
|
||||
// nil-safe.
|
||||
StampSynthesizedTyped(nil, SynthGRPCStub)
|
||||
}
|
||||
|
||||
func TestRunFrameworkSynthesizers_Report(t *testing.T) {
|
||||
b := newEventChannelTestGraph()
|
||||
b.emit("p.go::p", "eventemitter", "e", "p.go", 1)
|
||||
b.listen("c.go::c", "eventemitter", "e", "c.go", 1)
|
||||
|
||||
rep := RunFrameworkSynthesizers(b.g)
|
||||
assert.Equal(t, 1, rep.Total, "the one event-channel pair is the only synthesized edge")
|
||||
|
||||
byName := map[string]int{}
|
||||
for _, p := range rep.Per {
|
||||
byName[p.Name] = p.Edges
|
||||
}
|
||||
// Every registered synthesizer reports a row, even at zero.
|
||||
require.Contains(t, byName, SynthGRPCStub)
|
||||
require.Contains(t, byName, SynthTemporalStub)
|
||||
require.Contains(t, byName, SynthEventChannel)
|
||||
require.Contains(t, byName, SynthStoreFactory)
|
||||
require.Contains(t, byName, SynthReduxThunk)
|
||||
require.Contains(t, byName, SynthObjectRegistry)
|
||||
require.Contains(t, byName, SynthRTKQuery)
|
||||
require.Contains(t, byName, SynthVuexDispatch)
|
||||
require.Contains(t, byName, SynthCelery)
|
||||
require.Contains(t, byName, SynthSpringEvent)
|
||||
require.Contains(t, byName, SynthMediatR)
|
||||
require.Contains(t, byName, SynthSidekiq)
|
||||
require.Contains(t, byName, SynthLaravelEvent)
|
||||
require.Contains(t, byName, SynthFnPointerDispatch)
|
||||
require.Contains(t, byName, SynthGoFrameRoute)
|
||||
require.Contains(t, byName, SynthExpressResolve)
|
||||
assert.Equal(t, 0, byName[SynthGRPCStub])
|
||||
assert.Equal(t, 0, byName[SynthTemporalStub])
|
||||
assert.Equal(t, 1, byName[SynthEventChannel])
|
||||
}
|
||||
|
||||
func TestRunFrameworkSynthesizers_NilGraph(t *testing.T) {
|
||||
rep := RunFrameworkSynthesizers(nil)
|
||||
assert.Equal(t, 0, rep.Total)
|
||||
assert.Nil(t, rep.Per)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// bindGenericParamRefs rewrites `unresolved::<name>` edges where the
|
||||
// name is a generic type parameter declared by the source's
|
||||
// enclosing function. The Go extractor already materialises
|
||||
// KindGenericParam nodes with IDs `<func>#tparam:<name>` and an
|
||||
// EdgeMemberOf back to the owner — the resolver just hasn't been
|
||||
// consulting them when an in-body reference (`var x T`, return type
|
||||
// `T`, etc.) lands as `unresolved::T`.
|
||||
//
|
||||
// Side benefit beyond stub reduction: `find_usages` on a generic
|
||||
// type parameter starts working — *"where in this generic function
|
||||
// is T used?"* — which is a real refactoring query.
|
||||
//
|
||||
// Scope is per-function: a function's tparams are visible only
|
||||
// inside its body. The owner-keyed index built here lets each edge
|
||||
// resolve in O(1) without re-walking the graph.
|
||||
func (r *Resolver) bindGenericParamRefs() {
|
||||
// owner-function ID → set of tparam-name → tparam-node-id.
|
||||
owned := map[string]map[string]string{}
|
||||
for n := range r.graph.NodesByKind(graph.KindGenericParam) {
|
||||
if n.Language != "go" || n.Name == "" {
|
||||
continue
|
||||
}
|
||||
owner := enclosingFunctionForBinding(n.ID)
|
||||
if owner == "" || owner == n.ID {
|
||||
continue
|
||||
}
|
||||
set, ok := owned[owner]
|
||||
if !ok {
|
||||
set = map[string]string{}
|
||||
owned[owner] = set
|
||||
}
|
||||
// Don't overwrite — two tparams with the same name in the
|
||||
// same function shouldn't happen in valid Go, but be defensive.
|
||||
if _, dup := set[n.Name]; dup {
|
||||
set[n.Name] = ""
|
||||
continue
|
||||
}
|
||||
set[n.Name] = n.ID
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var batch []graph.EdgeReindex
|
||||
// We don't know up front which edge kinds carry type-param refs:
|
||||
// EdgeReferences for `var x T`, EdgeTypedAs for parameters typed
|
||||
// as T, EdgeReturns for return signature, EdgeInstantiates for
|
||||
// generic instantiation expressions. Walk the union.
|
||||
for _, k := range []graph.EdgeKind{
|
||||
graph.EdgeReferences,
|
||||
graph.EdgeTypedAs,
|
||||
graph.EdgeReturns,
|
||||
graph.EdgeInstantiates,
|
||||
} {
|
||||
for e := range r.graph.EdgesByKind(k) {
|
||||
if old := r.tryBindGenericParam(e, owned); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// bindGenericParamRefsForFile is the single-file-resolve form of
|
||||
// bindGenericParamRefs. A type parameter is visible only inside its own
|
||||
// function's body, which lives in this file, so both the owner index and the
|
||||
// edges to rewrite are scoped to the file — no whole-graph EdgesByKind sweep
|
||||
// (the dominant cost of an incremental edit on a large graph).
|
||||
func (r *Resolver) bindGenericParamRefsForFile(filePath string) {
|
||||
owned := map[string]map[string]string{}
|
||||
for _, n := range r.graph.GetFileNodes(filePath) {
|
||||
if n == nil || n.Kind != graph.KindGenericParam || n.Language != "go" || n.Name == "" {
|
||||
continue
|
||||
}
|
||||
owner := enclosingFunctionForBinding(n.ID)
|
||||
if owner == "" || owner == n.ID {
|
||||
continue
|
||||
}
|
||||
set, ok := owned[owner]
|
||||
if !ok {
|
||||
set = map[string]string{}
|
||||
owned[owner] = set
|
||||
}
|
||||
if _, dup := set[n.Name]; dup {
|
||||
set[n.Name] = ""
|
||||
continue
|
||||
}
|
||||
set[n.Name] = n.ID
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return
|
||||
}
|
||||
var batch []graph.EdgeReindex
|
||||
for _, e := range r.fileOutEdges(filePath) {
|
||||
switch e.Kind {
|
||||
case graph.EdgeReferences, graph.EdgeTypedAs, graph.EdgeReturns, graph.EdgeInstantiates:
|
||||
if old := r.tryBindGenericParam(e, owned); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// tryBindGenericParam returns the old To value (for batched reindex)
|
||||
// when the edge was rewritten, or "" when left alone.
|
||||
func (r *Resolver) tryBindGenericParam(e *graph.Edge, owned map[string]map[string]string) string {
|
||||
if e == nil || !strings.HasPrefix(e.To, "unresolved::") {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimPrefix(e.To, "unresolved::")
|
||||
if name == "" || strings.ContainsAny(name, ".*:#") {
|
||||
return ""
|
||||
}
|
||||
ownerID := enclosingFunctionForBinding(e.From)
|
||||
if ownerID == "" {
|
||||
return ""
|
||||
}
|
||||
set := owned[ownerID]
|
||||
if len(set) == 0 {
|
||||
return ""
|
||||
}
|
||||
target, ok := set[name]
|
||||
if !ok || target == "" || target == e.To {
|
||||
return ""
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = target
|
||||
return oldTo
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestBindGenericParamRefs_RewritesTRefToTParam(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Map"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Map", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
tparamID := owner + "#tparam:T"
|
||||
g.AddNode(&graph.Node{ID: tparamID, Kind: graph.KindGenericParam, Name: "T", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: tparamID, To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
// `var x T` inside Map's body — EdgeTypedAs from a local-ish
|
||||
// source to the unresolved-T target.
|
||||
from := owner + "#local:x@+3"
|
||||
g.AddNode(&graph.Node{ID: from, Kind: graph.KindLocal, Name: "x", FilePath: "pkg/foo.go", StartLine: 3, Language: "go"})
|
||||
edge := &graph.Edge{From: from, To: "unresolved::T", Kind: graph.EdgeTypedAs, Line: 3}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindGenericParamRefs()
|
||||
assert.Equal(t, tparamID, edge.To, "var x T must bind to the function's KindGenericParam T")
|
||||
}
|
||||
|
||||
func TestBindGenericParamRefs_OtherFunctionsLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Function A declares tparam T.
|
||||
a := "pkg/a.go::A"
|
||||
g.AddNode(&graph.Node{ID: a, Kind: graph.KindFunction, Name: "A", FilePath: "pkg/a.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: a + "#tparam:T", Kind: graph.KindGenericParam, Name: "T", FilePath: "pkg/a.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: a + "#tparam:T", To: a, Kind: graph.EdgeMemberOf})
|
||||
|
||||
// Function B has its OWN body and references `T`, but doesn't
|
||||
// declare it. Pass must NOT bind to A's tparam.
|
||||
b := "pkg/b.go::B"
|
||||
g.AddNode(&graph.Node{ID: b, Kind: graph.KindFunction, Name: "B", FilePath: "pkg/b.go", Language: "go"})
|
||||
edge := &graph.Edge{From: b, To: "unresolved::T", Kind: graph.EdgeReferences, Line: 1}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).bindGenericParamRefs()
|
||||
assert.Equal(t, "unresolved::T", edge.To, "must not cross-bind to another function's tparam")
|
||||
}
|
||||
|
||||
func TestBindGenericParamRefs_QualifiedShapesIgnored(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: owner + "#tparam:T", Kind: graph.KindGenericParam, Name: "T", FilePath: "pkg/foo.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: owner + "#tparam:T", To: owner, Kind: graph.EdgeMemberOf})
|
||||
|
||||
keep := []*graph.Edge{
|
||||
{From: owner, To: "unresolved::*.T", Kind: graph.EdgeReferences, Line: 1},
|
||||
{From: owner, To: "unresolved::pkg.T", Kind: graph.EdgeReferences, Line: 2},
|
||||
}
|
||||
for _, e := range keep {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
New(g).bindGenericParamRefs()
|
||||
for _, e := range keep {
|
||||
assert.True(t,
|
||||
e.To == "unresolved::*.T" || e.To == "unresolved::pkg.T",
|
||||
"qualified shape %q must be left alone", e.To,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// ginMiddlewareVia tags the synthesized dispatcher→handler edges.
|
||||
const ginMiddlewareVia = "gin_middleware_chain"
|
||||
|
||||
// ginFanoutCap bounds the dispatcher×handler fan-out so a pathological repo
|
||||
// (a custom chain plus hundreds of registered handlers) cannot explode the
|
||||
// edge set.
|
||||
const ginFanoutCap = 512
|
||||
|
||||
// ResolveGinMiddlewareCalls bridges a Gin middleware-chain dispatcher to the
|
||||
// handlers it dispatches to. The Go extractor stamps gin_dispatcher=true on the
|
||||
// method that invokes `c.handlers[idx](c)` (the indexed-slice indirection a
|
||||
// call graph drops) and gin_handlers=[names] on each function that registers
|
||||
// routes/middleware with `.GET`/`.Use`/`.Handle`. This pass resolves those
|
||||
// names to their definitions and emits a tier-tagged dispatcher→handler call
|
||||
// edge per pair, so request→handler reachability flows through get_call_chain
|
||||
// where a same-file scanner sees a dead end. Gated on a dispatcher existing
|
||||
// (so it is inert outside a Gin-style chain) and repo-scoped via
|
||||
// sameDispatchBoundary (per the intra-process dispatch discipline).
|
||||
func ResolveGinMiddlewareCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dispatchers []*graph.Node
|
||||
var registrars []*graph.Node
|
||||
nameIndex := map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod, graph.KindFunction) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
nameIndex[n.Name] = append(nameIndex[n.Name], n)
|
||||
if n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if d, _ := n.Meta["gin_dispatcher"].(bool); d {
|
||||
dispatchers = append(dispatchers, n)
|
||||
}
|
||||
if _, ok := n.Meta["gin_handlers"]; ok {
|
||||
registrars = append(registrars, n)
|
||||
}
|
||||
}
|
||||
// Gated on the dispatcher existing: no chain dispatcher in the graph means
|
||||
// no Gin-style indirection to bridge.
|
||||
if len(dispatchers) == 0 || len(registrars) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Collect the distinct handler names registered anywhere, deterministically.
|
||||
handlerNames := map[string]bool{}
|
||||
for _, r := range registrars {
|
||||
for _, name := range ginHandlerNames(r.Meta["gin_handlers"]) {
|
||||
handlerNames[name] = true
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(handlerNames))
|
||||
for name := range handlerNames {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
sort.Slice(dispatchers, func(i, j int) bool { return dispatchers[i].ID < dispatchers[j].ID })
|
||||
|
||||
var batch []*graph.Edge
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dispatchers {
|
||||
for _, name := range names {
|
||||
cands := nameIndex[name]
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].ID < cands[j].ID })
|
||||
for _, h := range cands {
|
||||
if h == nil || h.ID == d.ID {
|
||||
continue
|
||||
}
|
||||
// Repo-scope: a dispatcher only reaches handlers in its own
|
||||
// dispatch boundary (vendored Gin + app handlers in one repo).
|
||||
if !sameDispatchBoundary(d, h) {
|
||||
continue
|
||||
}
|
||||
key := d.ID + "\x00" + h.ID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
batch = append(batch, ginMiddlewareEdge(d, h))
|
||||
if len(batch) >= ginFanoutCap {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return len(batch)
|
||||
}
|
||||
|
||||
// ginHandlerNames coerces the gin_handlers Meta value (stamped as []string,
|
||||
// possibly []any after a serialization round-trip) into a name slice.
|
||||
func ginHandlerNames(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ginMiddlewareEdge builds one dispatcher→handler speculative call edge.
|
||||
func ginMiddlewareEdge(from, to *graph.Node) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from.ID,
|
||||
To: to.ID,
|
||||
Kind: graph.EdgeCalls,
|
||||
FilePath: from.FilePath,
|
||||
Line: from.StartLine,
|
||||
Confidence: 0.4,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, 0.4),
|
||||
Origin: graph.OriginSpeculative,
|
||||
Meta: map[string]any{
|
||||
"via": ginMiddlewareVia,
|
||||
"speculative": true,
|
||||
MetaSynthesizedBy: SynthGinMiddleware,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// TestGinMiddlewareDispatcherToHandlerEdges drives the Gin middleware-chain
|
||||
// synthesizer end-to-end: the Go extractor's source parse stamps the dispatcher
|
||||
// (the method indexing `c.handlers[idx](c)`) and the registered handler names,
|
||||
// and the resolver bridges the dispatcher to each handler with a tier-tagged
|
||||
// traversable call edge. That edge is the reachability a same-file scanner
|
||||
// drops at the indexed-slice indirection — get_call_chain now reaches the
|
||||
// handlers from the dispatcher. String path args and inline closures are not
|
||||
// handlers, and with no dispatcher in the graph nothing is synthesized.
|
||||
func TestGinMiddlewareDispatcherToHandlerEdges(t *testing.T) {
|
||||
const router = `package web
|
||||
|
||||
type Context struct {
|
||||
index int
|
||||
handlers []HandlerFunc
|
||||
}
|
||||
type HandlerFunc func(*Context)
|
||||
|
||||
func (c *Context) Next() {
|
||||
for c.index < len(c.handlers) {
|
||||
c.handlers[c.index](c)
|
||||
c.index++
|
||||
}
|
||||
}
|
||||
|
||||
func Logger() HandlerFunc { return nil }
|
||||
func listUsers(c *Context) {}
|
||||
func createUser(c *Context) {}
|
||||
|
||||
func setup(r *Engine) {
|
||||
r.Use(Logger())
|
||||
r.GET("/users", listUsers)
|
||||
r.POST("/users", createUser, func(c *Context) {})
|
||||
}
|
||||
`
|
||||
build := func(src string) *graph.Graph {
|
||||
res, err := languages.NewGoExtractor().Extract("web/web.go", []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
g := graph.New()
|
||||
for _, n := range res.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
for _, e := range res.Edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
g := build(router)
|
||||
n := ResolveGinMiddlewareCalls(g)
|
||||
if n != 3 {
|
||||
t.Fatalf("synthesized %d dispatcher→handler edges, want 3", n)
|
||||
}
|
||||
|
||||
// Index the synthesized edges by target.
|
||||
const disp = "web/web.go::Context.Next"
|
||||
targets := map[string]*graph.Edge{}
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Meta != nil && e.Meta["via"] == ginMiddlewareVia && e.From == disp {
|
||||
targets[e.To] = e
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"web/web.go::Logger", "web/web.go::listUsers", "web/web.go::createUser"} {
|
||||
e, ok := targets[want]
|
||||
if !ok {
|
||||
t.Errorf("missing dispatcher→handler edge to %q", want)
|
||||
continue
|
||||
}
|
||||
// Tier-tagged + provenance-stamped so the edge is min_tier filterable
|
||||
// and attributable — not an opaque heuristic.
|
||||
if e.Origin != graph.OriginSpeculative {
|
||||
t.Errorf("edge to %q origin=%v, want OriginSpeculative", want, e.Origin)
|
||||
}
|
||||
if e.Meta[MetaSynthesizedBy] != SynthGinMiddleware {
|
||||
t.Errorf("edge to %q synthesized_by=%v, want %q", want, e.Meta[MetaSynthesizedBy], SynthGinMiddleware)
|
||||
}
|
||||
if e.Kind != graph.EdgeCalls {
|
||||
t.Errorf("edge to %q kind=%v, want EdgeCalls (traversable in get_call_chain)", want, e.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// The inline closure passed to POST is not a named handler.
|
||||
for to := range targets {
|
||||
if to == "" {
|
||||
t.Errorf("synthesized an edge to an empty target (a closure leaked in)")
|
||||
}
|
||||
}
|
||||
|
||||
// Gate: no dispatcher in the graph → nothing synthesized.
|
||||
const noDispatcher = `package web
|
||||
type HandlerFunc func()
|
||||
func listUsers() {}
|
||||
func setup(r *Engine) {
|
||||
r.GET("/users", listUsers)
|
||||
}
|
||||
`
|
||||
g2 := build(noDispatcher)
|
||||
if got := ResolveGinMiddlewareCalls(g2); got != 0 {
|
||||
t.Errorf("with no dispatcher present, synthesized %d edges, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// goBuiltinFuncs is the complete set of pre-declared Go built-in
|
||||
// functions. Source: https://pkg.go.dev/builtin (functions section).
|
||||
// Kept in sync with the language spec — when a new builtin lands
|
||||
// (e.g. clear / min / max in Go 1.21) add it here.
|
||||
var goBuiltinFuncs = map[string]struct{}{
|
||||
"append": {}, "cap": {}, "clear": {}, "close": {}, "complex": {},
|
||||
"copy": {}, "delete": {}, "imag": {}, "len": {}, "make": {},
|
||||
"max": {}, "min": {}, "new": {}, "panic": {}, "print": {},
|
||||
"println": {}, "real": {}, "recover": {},
|
||||
}
|
||||
|
||||
// goBuiltinTypes is the complete set of pre-declared Go built-in
|
||||
// types. Source: https://pkg.go.dev/builtin (types section).
|
||||
var goBuiltinTypes = map[string]struct{}{
|
||||
"any": {}, "bool": {}, "byte": {}, "comparable": {},
|
||||
"complex64": {}, "complex128": {}, "error": {},
|
||||
"float32": {}, "float64": {},
|
||||
"int": {}, "int8": {}, "int16": {}, "int32": {}, "int64": {},
|
||||
"rune": {}, "string": {},
|
||||
"uint": {}, "uint8": {}, "uint16": {}, "uint32": {}, "uint64": {},
|
||||
"uintptr": {},
|
||||
}
|
||||
|
||||
// goBuiltinConsts is the set of pre-declared Go constants (true,
|
||||
// false, iota, nil). Mostly emitted for completeness — `true` /
|
||||
// `false` rarely show up as unresolved edge targets in practice
|
||||
// because the parser handles them inline.
|
||||
var goBuiltinConsts = map[string]struct{}{
|
||||
"true": {}, "false": {}, "iota": {}, "nil": {},
|
||||
}
|
||||
|
||||
// attributeGoBuiltins rewrites `unresolved::<name>` edges whose name
|
||||
// is a Go language intrinsic onto the canonical `builtin::go::*` ID,
|
||||
// and materialises a single KindBuiltin node per unique builtin so
|
||||
// the rewritten edges land at a real graph node instead of a
|
||||
// rel-table FK stub. Mirrors the existing builtin::py / builtin::ts
|
||||
// classifier in internal/resolver/builtins.go but completes the
|
||||
// pattern by also creating nodes for the targets — so
|
||||
// `find_usages(builtin::go::type::float64)` answers "every variable
|
||||
// typed as float64 in this codebase", and the on-disk-backend stub
|
||||
// inflation drops by ~50k rows on a gortex-scale Go codebase.
|
||||
//
|
||||
// Three ID namespaces under `builtin::go::`:
|
||||
//
|
||||
// functions: builtin::go::<name> (append, len, make, ...)
|
||||
// types: builtin::go::type::<name> (string, int, float64, ...)
|
||||
// constants: builtin::go::const::<name> (true, false, iota, nil)
|
||||
//
|
||||
// Functions get the shortest namespace because their fan-in is the
|
||||
// biggest and the shorter ID is what most downstream `find_usages`
|
||||
// queries will type.
|
||||
func (r *Resolver) attributeGoBuiltins() {
|
||||
// Go-only pass: skip the scan entirely when the graph has no Go nodes
|
||||
// (e.g. a TS/Python repo).
|
||||
if !r.graphHasLanguage("go") {
|
||||
return
|
||||
}
|
||||
materialised := map[string]struct{}{}
|
||||
var batch []graph.EdgeReindex
|
||||
|
||||
// tryAttributeGoBuiltin only ever acts on an edge whose To has the bare
|
||||
// `unresolved::` prefix — every other edge is a guaranteed no-op. This
|
||||
// used to scan every edge of each of the 11 candidate kinds below
|
||||
// (resolved and unresolved alike) via 11 separate EdgesByKind calls;
|
||||
// EdgesWithUnresolvedTarget's is_unresolved index (see isUnresolvedColumnDDL)
|
||||
// already collects the exact superset in one indexed scan, so filtering
|
||||
// to these kinds in Go is equivalent but skips every resolved edge and
|
||||
// every kind this pass never inspects. IsUnresolvedTarget covers both
|
||||
// the bare and repo-qualified forms; tryAttributeGoBuiltin's own prefix
|
||||
// check still rejects the repo-qualified ones exactly as it always did.
|
||||
for e := range r.graph.EdgesWithUnresolvedTarget() {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := attributeGoBuiltinCandidateKinds[e.Kind]; !ok {
|
||||
continue
|
||||
}
|
||||
if old := r.tryAttributeGoBuiltin(e, materialised); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// attributeGoBuiltinCandidateKinds is every edge kind a builtin can be the
|
||||
// target of. Type-system edges (typed_as / returns) carry type references;
|
||||
// call / arg-of / value-flow carry function or const references.
|
||||
var attributeGoBuiltinCandidateKinds = map[graph.EdgeKind]struct{}{
|
||||
graph.EdgeCalls: {},
|
||||
graph.EdgeReferences: {},
|
||||
graph.EdgeReads: {},
|
||||
graph.EdgeArgOf: {},
|
||||
graph.EdgeValueFlow: {},
|
||||
graph.EdgeReturnsTo: {},
|
||||
graph.EdgeTypedAs: {},
|
||||
graph.EdgeReturns: {},
|
||||
graph.EdgeInstantiates: {},
|
||||
graph.EdgeCaptures: {},
|
||||
graph.EdgeThrows: {},
|
||||
}
|
||||
|
||||
// attributeGoBuiltinsForFile is the single-file scope of attributeGoBuiltins:
|
||||
// it only inspects the edited file's outgoing edges. A builtin reference's
|
||||
// source endpoint is always inside the file that mentions it, so this
|
||||
// produces the same rewrites as the whole-graph sweep for a per-save
|
||||
// resolve without scanning every edge of eleven kinds across the graph.
|
||||
func (r *Resolver) attributeGoBuiltinsForFile(filePath string) {
|
||||
if !r.graphHasLanguage("go") {
|
||||
return
|
||||
}
|
||||
materialised := map[string]struct{}{}
|
||||
var batch []graph.EdgeReindex
|
||||
for _, e := range r.fileOutEdges(filePath) {
|
||||
if old := r.tryAttributeGoBuiltin(e, materialised); old != "" {
|
||||
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: old})
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
r.graph.ReindexEdges(batch)
|
||||
}
|
||||
}
|
||||
|
||||
// tryAttributeGoBuiltin checks if e.To is `unresolved::<bareName>`
|
||||
// where bareName is a Go builtin and the source language is Go (the
|
||||
// source is inside a Go function / file). On a match it materialises
|
||||
// the target node (once per unique ID), rewrites e.To, and returns
|
||||
// the old To value for the batched reindex. Returns "" when the edge
|
||||
// is left alone.
|
||||
func (r *Resolver) tryAttributeGoBuiltin(e *graph.Edge, materialised map[string]struct{}) string {
|
||||
if e == nil || !strings.HasPrefix(e.To, "unresolved::") {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimPrefix(e.To, "unresolved::")
|
||||
if name == "" || strings.ContainsAny(name, ".*:#") {
|
||||
return ""
|
||||
}
|
||||
// Cheap membership check first: three small map lookups, no graph
|
||||
// access. Only ~2% of candidate names are ever a Go builtin in
|
||||
// practice, so rejecting the rest here — before the language-origin
|
||||
// check and the repo-prefix lookup below, both of which can fall back
|
||||
// to a graph node lookup — avoids paying for either on the ~98% that
|
||||
// were always going to return "" anyway.
|
||||
if !isGoBuiltinName(name) {
|
||||
return ""
|
||||
}
|
||||
// Only attribute when the source is Go. Without this guard a
|
||||
// Python reference to a local named `len` would get re-targeted
|
||||
// at Go's builtin `len`, which would be obviously wrong. Dataflow
|
||||
// edges (arg_of / value_flow) carry an `unresolved::` From placeholder
|
||||
// that fromIsGo cannot classify, so fall back to the call-site file
|
||||
// extension: a `.go` file's `append` / `make` / `len` argument is the Go
|
||||
// builtin regardless of whether the argument side ever bound to a node.
|
||||
// (langFromFilePath only classifies js/ts/py, so a `.go` suffix test is
|
||||
// the right check here.) e.FilePath is a free struct-field read while
|
||||
// fromIsGo's fallback path can hit a node lookup, so try FilePath
|
||||
// first — De Morgan's / && being commutative means this is the exact
|
||||
// same condition, just evaluated in the cheaper order.
|
||||
if !strings.HasSuffix(e.FilePath, ".go") && !r.fromIsGo(e.From) {
|
||||
return ""
|
||||
}
|
||||
newID, kind, builtinKind := goBuiltinTarget(r.callerRepoPrefix(e), name)
|
||||
if newID == "" {
|
||||
return ""
|
||||
}
|
||||
if _, ok := materialised[newID]; !ok {
|
||||
// AddNode is idempotent on ID, so even a second
|
||||
// concurrent pass would not duplicate the row.
|
||||
r.graph.AddNode(&graph.Node{
|
||||
ID: newID,
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
Language: "go",
|
||||
Meta: map[string]any{
|
||||
"builtin": true,
|
||||
"builtin_kind": builtinKind,
|
||||
},
|
||||
})
|
||||
materialised[newID] = struct{}{}
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = newID
|
||||
return oldTo
|
||||
}
|
||||
|
||||
// isGoBuiltinName reports whether name is in any of the three builtin
|
||||
// namespaces, without needing a repo prefix — the cheap pre-check
|
||||
// tryAttributeGoBuiltin runs before the (potentially graph-lookup-backed)
|
||||
// language-origin check and repo-prefix resolution. Mirrors goBuiltinTarget's
|
||||
// own membership tests exactly; kept as a separate, repo-prefix-free
|
||||
// function so the common "not a builtin" case never has to compute
|
||||
// anything else first.
|
||||
func isGoBuiltinName(name string) bool {
|
||||
if _, ok := goBuiltinFuncs[name]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := goBuiltinTypes[name]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := goBuiltinConsts[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// goBuiltinTarget classifies a bare identifier as one of Go's
|
||||
// intrinsics. Returns the canonical builtin::go:: ID (per-repo
|
||||
// prefixed via graph.StubID — see internal/graph/stub.go for why
|
||||
// two repos can disagree on what's a builtin), the NodeKind to
|
||||
// materialise it under (always KindBuiltin), and a meta tag
|
||||
// recording which subspace (func / type / const) it belongs to.
|
||||
// Returns ("", "", "") when the name is not a Go builtin.
|
||||
// repoPrefix is the owning repo's RepoPrefix (empty in
|
||||
// single-repo / legacy callers). Callers on the tryAttributeGoBuiltin path
|
||||
// have already confirmed isGoBuiltinName(name) before calling this, so the
|
||||
// repeated map lookups here run only on the small matching subset.
|
||||
func goBuiltinTarget(repoPrefix, name string) (id string, kind graph.NodeKind, builtinKind string) {
|
||||
if _, ok := goBuiltinFuncs[name]; ok {
|
||||
return graph.StubID(repoPrefix, graph.StubKindBuiltin, "go", name), graph.KindBuiltin, "func"
|
||||
}
|
||||
if _, ok := goBuiltinTypes[name]; ok {
|
||||
return graph.StubID(repoPrefix, graph.StubKindBuiltin, "go", "type", name), graph.KindBuiltin, "type"
|
||||
}
|
||||
if _, ok := goBuiltinConsts[name]; ok {
|
||||
return graph.StubID(repoPrefix, graph.StubKindBuiltin, "go", "const", name), graph.KindBuiltin, "const"
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// fromIsGo reports whether the source endpoint of an edge sits
|
||||
// inside Go code. Uses the From's enclosing function (via the same
|
||||
// suffix-stripping helper bare-name binding uses) — Go is the only
|
||||
// language whose IDs follow the `file.go::Func` convention with a
|
||||
// `.go` extension, so a path-based check is both cheap and reliable.
|
||||
func (r *Resolver) fromIsGo(fromID string) bool {
|
||||
owner := enclosingFunctionForBinding(fromID)
|
||||
if owner == "" {
|
||||
return false
|
||||
}
|
||||
if i := strings.Index(owner, "::"); i > 0 {
|
||||
// `pkg/foo.go::Func` shape — peek at the file extension.
|
||||
head := owner[:i]
|
||||
if strings.HasSuffix(head, ".go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Fall back to looking up the owner node and checking its
|
||||
// Language. More expensive but covers edge cases where the ID
|
||||
// doesn't follow the `.go::Func` pattern.
|
||||
if n := r.cachedGetNode(owner); n != nil && n.Language == "go" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestAttributeGoBuiltins_FunctionCall(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Run"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Run", FilePath: "pkg/foo.go", Language: "go"})
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::append", Kind: graph.EdgeArgOf, FilePath: "pkg/foo.go", Line: 5}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
assert.Equal(t, "builtin::go::append", edge.To,
|
||||
"call to `append` must retarget onto builtin::go::append")
|
||||
n := g.GetNode("builtin::go::append")
|
||||
require.NotNil(t, n, "KindBuiltin node must be materialised")
|
||||
assert.Equal(t, graph.KindBuiltin, n.Kind)
|
||||
assert.Equal(t, "append", n.Name)
|
||||
assert.Equal(t, "go", n.Language)
|
||||
assert.Equal(t, true, n.Meta["builtin"])
|
||||
assert.Equal(t, "func", n.Meta["builtin_kind"])
|
||||
}
|
||||
|
||||
func TestAttributeGoBuiltins_Type(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::Handler"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "Handler", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
paramID := owner + "#param:s"
|
||||
g.AddNode(&graph.Node{ID: paramID, Kind: graph.KindParam, Name: "s", FilePath: "pkg/foo.go", Language: "go"})
|
||||
edge := &graph.Edge{From: paramID, To: "unresolved::string", Kind: graph.EdgeTypedAs, FilePath: "pkg/foo.go", Line: 1}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
assert.Equal(t, "builtin::go::type::string", edge.To,
|
||||
"typed_as `string` must retarget onto builtin::go::type::string")
|
||||
n := g.GetNode("builtin::go::type::string")
|
||||
require.NotNil(t, n)
|
||||
assert.Equal(t, graph.KindBuiltin, n.Kind)
|
||||
assert.Equal(t, "type", n.Meta["builtin_kind"])
|
||||
}
|
||||
|
||||
func TestAttributeGoBuiltins_DedupedAcrossManyEdges(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
// Many calls to len from the same function.
|
||||
for i := 1; i <= 5; i++ {
|
||||
g.AddEdge(&graph.Edge{From: owner, To: "unresolved::len", Kind: graph.EdgeArgOf, FilePath: "pkg/foo.go", Line: i})
|
||||
}
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
// Exactly one KindBuiltin node should be created regardless of
|
||||
// how many edges referenced it.
|
||||
count := 0
|
||||
for n := range g.NodesByKind(graph.KindBuiltin) {
|
||||
if n.ID == "builtin::go::len" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "exactly one KindBuiltin per unique builtin")
|
||||
}
|
||||
|
||||
func TestAttributeGoBuiltins_NonGoLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
// A Python source emitting a reference to `len` (Python builtin)
|
||||
// — must NOT get attributed to Go's `builtin::go::len`.
|
||||
owner := "pkg/app.py::process"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "process", FilePath: "pkg/app.py", Language: "python"})
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::len", Kind: graph.EdgeArgOf, FilePath: "pkg/app.py", Line: 1}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
assert.Equal(t, "unresolved::len", edge.To,
|
||||
"Python source must NOT cross-bind to Go's len builtin")
|
||||
}
|
||||
|
||||
func TestAttributeGoBuiltins_UnknownNameLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::myCustomFunc", Kind: graph.EdgeArgOf, FilePath: "pkg/foo.go", Line: 1}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
assert.Equal(t, "unresolved::myCustomFunc", edge.To,
|
||||
"non-builtin names must stay unresolved")
|
||||
}
|
||||
|
||||
func TestAttributeGoBuiltins_QualifiedShapeLeftAlone(t *testing.T) {
|
||||
g := graph.New()
|
||||
owner := "pkg/foo.go::F"
|
||||
g.AddNode(&graph.Node{ID: owner, Kind: graph.KindFunction, Name: "F", FilePath: "pkg/foo.go", Language: "go"})
|
||||
|
||||
// `*.len` is qualified — leave to other passes.
|
||||
edge := &graph.Edge{From: owner, To: "unresolved::*.len", Kind: graph.EdgeArgOf, FilePath: "pkg/foo.go", Line: 1}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).attributeGoBuiltins()
|
||||
|
||||
assert.Equal(t, "unresolved::*.len", edge.To, "qualified `*.len` shape must be left alone")
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// goframeRouteVia is the Meta["via"] tag the Go extractor stamps on a
|
||||
// GoFrame route placeholder (route → request-struct type).
|
||||
const goframeRouteVia = "goframe-route"
|
||||
|
||||
// ResolveGoFrameRoutes joins each GoFrame route to the controller method
|
||||
// that handles it, by request-struct type rather than name: a route
|
||||
// materialised from a `g.Meta`-tagged request struct binds to the method
|
||||
// whose pointer parameter is that struct. When several methods share a
|
||||
// request type, a controller bound via `g.Bind(new(Ctrl))` (the addonRoot
|
||||
// set) wins, then a same-directory method. Emits both a call edge
|
||||
// (route → method, for get_callers) and a handles_route edge
|
||||
// (method → route, for analyze routes). Typed tier.
|
||||
//
|
||||
// Returns the number of routes joined to a handler.
|
||||
func ResolveGoFrameRoutes(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
byReqType := map[string][]*graph.Node{}
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod, graph.KindFunction) {
|
||||
if n == nil || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if rt, _ := n.Meta["goframe_request_type"].(string); rt != "" {
|
||||
byReqType[rt] = append(byReqType[rt], n)
|
||||
}
|
||||
}
|
||||
if len(byReqType) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
var batch []*graph.Edge
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != goframeRouteVia {
|
||||
continue
|
||||
}
|
||||
reqType, _ := e.Meta["goframe_request_type"].(string)
|
||||
if reqType == "" {
|
||||
continue
|
||||
}
|
||||
target := goframePickMethod(g, e, byReqType[reqType])
|
||||
|
||||
want := "unresolved::*." + reqType
|
||||
if target != nil {
|
||||
want = target.ID
|
||||
}
|
||||
if e.To == want {
|
||||
if target != nil {
|
||||
resolved++
|
||||
}
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = want
|
||||
if target != nil {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = ConfidenceTyped
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped)
|
||||
StampSynthesizedTyped(e, SynthGoFrameRoute)
|
||||
resolved++
|
||||
// Mirror the route in the handler→route direction so the route
|
||||
// surfaces in analyze kind=routes.
|
||||
routeID, _ := e.Meta["goframe_route"].(string)
|
||||
if routeID == "" {
|
||||
routeID = e.From
|
||||
}
|
||||
batch = append(batch, &graph.Edge{
|
||||
From: target.ID, To: routeID, Kind: graph.EdgeHandlesRoute,
|
||||
FilePath: e.FilePath, Line: e.Line,
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{"via": goframeRouteVia, "framework": "goframe"},
|
||||
})
|
||||
} else {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0
|
||||
e.ConfidenceLabel = ""
|
||||
UnstampSynthesized(e)
|
||||
}
|
||||
reindex = append(reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
for _, ne := range batch {
|
||||
g.AddEdge(ne)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// goframePickMethod selects the handler for a route from the methods of a
|
||||
// request type: candidates are first narrowed to the route's request-struct
|
||||
// package (so a same-named request type in another package can never claim the
|
||||
// route), then a bound controller (addonRoot) wins, then a method in the
|
||||
// route's directory, then a unique match.
|
||||
func goframePickMethod(g graph.Store, route *graph.Edge, cands []*graph.Node) *graph.Node {
|
||||
cands = sameBoundaryCandidates(g, route.From, cands)
|
||||
cands = goframeSamePackage(route, cands)
|
||||
if len(cands) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(cands) == 1 {
|
||||
return cands[0]
|
||||
}
|
||||
// addonRoot: prefer bound controllers.
|
||||
var boundCands []*graph.Node
|
||||
for _, c := range cands {
|
||||
if c.Meta != nil {
|
||||
if b, _ := c.Meta["goframe_bound"].(bool); b {
|
||||
boundCands = append(boundCands, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(boundCands) == 1 {
|
||||
return boundCands[0]
|
||||
}
|
||||
if len(boundCands) > 1 {
|
||||
cands = boundCands
|
||||
}
|
||||
// Then a same-directory method.
|
||||
routeDir := goframeDir(route.FilePath)
|
||||
var sameDir []*graph.Node
|
||||
for _, c := range cands {
|
||||
if goframeDir(c.FilePath) == routeDir {
|
||||
sameDir = append(sameDir, c)
|
||||
}
|
||||
}
|
||||
if len(sameDir) == 1 {
|
||||
return sameDir[0]
|
||||
}
|
||||
if len(sameDir) > 1 {
|
||||
cands = sameDir
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].ID < cands[j].ID })
|
||||
if len(cands) == 1 {
|
||||
return cands[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// goframeSamePackage narrows candidates to those whose request-struct package
|
||||
// matches the route's. A route materialised from `pkg.CreateReq` must bind only
|
||||
// to a handler taking `*pkg.CreateReq` — never to a same-named `CreateReq` in
|
||||
// another package. The package qualifier is the extractor's `goframe_request_pkg`
|
||||
// stamp; when the route carries none (a same-package, bare-name binding the
|
||||
// extractor could not qualify), candidates pass through unfiltered so existing
|
||||
// single-package resolution is never weakened.
|
||||
func goframeSamePackage(route *graph.Edge, cands []*graph.Node) []*graph.Node {
|
||||
routePkg := ""
|
||||
if route.Meta != nil {
|
||||
routePkg, _ = route.Meta["goframe_request_pkg"].(string)
|
||||
}
|
||||
if routePkg == "" {
|
||||
return cands
|
||||
}
|
||||
out := make([]*graph.Node, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if c == nil || c.Meta == nil {
|
||||
continue
|
||||
}
|
||||
// A candidate with no package qualifier cannot be proven to belong to
|
||||
// the route's package, so it is excluded once the route is qualified.
|
||||
if cp, _ := c.Meta["goframe_request_pkg"].(string); cp == routePkg {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func goframeDir(path string) string {
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func goframeRoute(g *graph.Graph, routeID, file, method, path, reqType string) {
|
||||
g.AddNode(&graph.Node{ID: routeID, Kind: graph.KindContract, Name: method + " " + path, FilePath: file,
|
||||
Meta: map[string]any{"type": "http", "method": method, "path": path, "framework": "goframe", "goframe_request_type": reqType}})
|
||||
g.AddEdge(&graph.Edge{From: routeID, To: "unresolved::*." + reqType, Kind: graph.EdgeCalls, FilePath: file,
|
||||
Meta: map[string]any{"via": goframeRouteVia, "goframe_request_type": reqType, "goframe_route": routeID}})
|
||||
}
|
||||
|
||||
func goframeMethod(g *graph.Graph, id, file, reqType string, bound bool) {
|
||||
meta := map[string]any{"goframe_request_type": reqType}
|
||||
if bound {
|
||||
meta["goframe_bound"] = true
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: lastSeg(id), FilePath: file, Language: "go", Meta: meta})
|
||||
}
|
||||
|
||||
func synthGoFrameCall(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthGoFrameRoute {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func goframeHandlesRoute(g graph.Store, from, to string) bool {
|
||||
for e := range g.EdgesByKind(graph.EdgeHandlesRoute) {
|
||||
if e != nil && e.From == from && e.To == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestResolveGoFrameRoutes_JoinByRequestType(t *testing.T) {
|
||||
g := graph.New()
|
||||
goframeRoute(g, "route::goframe::POST::/users", "ctrl/user.go", "POST", "/users", "CreateReq")
|
||||
goframeMethod(g, "ctrl/user.go::UserCtrl.Create", "ctrl/user.go", "CreateReq", true)
|
||||
|
||||
n := ResolveGoFrameRoutes(g)
|
||||
require.Equal(t, 1, n)
|
||||
// Call edge route → handler (for get_callers), joined by request type
|
||||
// even though the method name (Create) is not the route path.
|
||||
e := synthGoFrameCall(g, "route::goframe::POST::/users", "ctrl/user.go::UserCtrl.Create")
|
||||
require.NotNil(t, e)
|
||||
assert.Equal(t, ConfidenceTyped, e.Confidence)
|
||||
assert.Equal(t, ProvenanceFramework, e.Meta[MetaProvenance])
|
||||
// handles_route edge handler → route (for analyze routes).
|
||||
assert.True(t, goframeHandlesRoute(g, "ctrl/user.go::UserCtrl.Create", "route::goframe::POST::/users"))
|
||||
}
|
||||
|
||||
func TestResolveGoFrameRoutes_AddonRootTiebreak(t *testing.T) {
|
||||
// Two controllers define a method taking the same request type; the one
|
||||
// bound via g.Bind(new(Ctrl)) wins.
|
||||
g := graph.New()
|
||||
goframeRoute(g, "route::goframe::POST::/users", "a/route.go", "POST", "/users", "CreateReq")
|
||||
goframeMethod(g, "a/ctrl.go::BoundCtrl.Create", "a/ctrl.go", "CreateReq", true)
|
||||
goframeMethod(g, "b/ctrl.go::OtherCtrl.Create", "b/ctrl.go", "CreateReq", false)
|
||||
|
||||
ResolveGoFrameRoutes(g)
|
||||
assert.NotNil(t, synthGoFrameCall(g, "route::goframe::POST::/users", "a/ctrl.go::BoundCtrl.Create"),
|
||||
"the bound controller wins the request-type collision")
|
||||
assert.Nil(t, synthGoFrameCall(g, "route::goframe::POST::/users", "b/ctrl.go::OtherCtrl.Create"))
|
||||
}
|
||||
|
||||
// goframeRoutePkg adds a route whose request struct is qualified by its
|
||||
// declaring package (the `goframe_request_pkg` stamp the extractor derives from
|
||||
// the struct file's package clause).
|
||||
func goframeRoutePkg(g *graph.Graph, routeID, file, method, path, reqType, pkg string) {
|
||||
g.AddNode(&graph.Node{ID: routeID, Kind: graph.KindContract, Name: method + " " + path, FilePath: file,
|
||||
Meta: map[string]any{"type": "http", "method": method, "path": path, "framework": "goframe",
|
||||
"goframe_request_type": reqType, "goframe_request_pkg": pkg}})
|
||||
g.AddEdge(&graph.Edge{From: routeID, To: "unresolved::*." + reqType, Kind: graph.EdgeCalls, FilePath: file,
|
||||
Meta: map[string]any{"via": goframeRouteVia, "goframe_request_type": reqType,
|
||||
"goframe_request_pkg": pkg, "goframe_route": routeID}})
|
||||
}
|
||||
|
||||
// goframeMethodPkg adds a handler whose request param is qualified
|
||||
// (`*pkg.CreateReq`), so the resolver can join by (package, type), never by a
|
||||
// bare same-named type across packages.
|
||||
func goframeMethodPkg(g *graph.Graph, id, file, reqType, pkg string, bound bool) {
|
||||
meta := map[string]any{"goframe_request_type": reqType, "goframe_request_pkg": pkg}
|
||||
if bound {
|
||||
meta["goframe_bound"] = true
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: lastSeg(id), FilePath: file, Language: "go", Meta: meta})
|
||||
}
|
||||
|
||||
func TestResolveGoFrameRoutes_CrossPackageSameTypeName(t *testing.T) {
|
||||
// Two packages each declare their own `CreateReq` request struct (each
|
||||
// tagged via g.Meta) and a handler for it. The req struct (route source)
|
||||
// and the controller live in different directories — the realistic GoFrame
|
||||
// layout (api/ vs controller/) — so the same-directory tiebreak cannot
|
||||
// disambiguate. Each route must bind to ITS OWN package's handler; a route
|
||||
// must never cross-bind to the other package's same-named handler.
|
||||
g := graph.New()
|
||||
// Package a: route declared in package "a", handler takes *a.CreateReq.
|
||||
goframeRoutePkg(g, "route::goframe::POST::/a/create", "a/api/create.go", "POST", "/a/create", "CreateReq", "a")
|
||||
goframeMethodPkg(g, "a/controller/create.go::ACtrl.Create", "a/controller/create.go", "CreateReq", "a", false)
|
||||
// Package b: route declared in package "b", handler takes *b.CreateReq.
|
||||
goframeRoutePkg(g, "route::goframe::POST::/b/create", "b/api/create.go", "POST", "/b/create", "CreateReq", "b")
|
||||
goframeMethodPkg(g, "b/controller/create.go::BCtrl.Create", "b/controller/create.go", "CreateReq", "b", false)
|
||||
|
||||
ResolveGoFrameRoutes(g)
|
||||
|
||||
assert.NotNil(t, synthGoFrameCall(g, "route::goframe::POST::/a/create", "a/controller/create.go::ACtrl.Create"),
|
||||
"package a's route binds to package a's handler")
|
||||
assert.Nil(t, synthGoFrameCall(g, "route::goframe::POST::/a/create", "b/controller/create.go::BCtrl.Create"),
|
||||
"package a's route must NOT cross-bind to package b's handler")
|
||||
|
||||
assert.NotNil(t, synthGoFrameCall(g, "route::goframe::POST::/b/create", "b/controller/create.go::BCtrl.Create"),
|
||||
"package b's route binds to package b's handler")
|
||||
assert.Nil(t, synthGoFrameCall(g, "route::goframe::POST::/b/create", "a/controller/create.go::ACtrl.Create"),
|
||||
"package b's route must NOT cross-bind to package a's handler")
|
||||
}
|
||||
|
||||
func TestResolveGoFrameRoutes_NoHandlerStaysPlaceholder(t *testing.T) {
|
||||
g := graph.New()
|
||||
goframeRoute(g, "route::goframe::GET::/x", "r.go", "GET", "/x", "XReq")
|
||||
goframeMethod(g, "c.go::C.Other", "c.go", "YReq", false) // different request type
|
||||
|
||||
assert.Equal(t, 0, ResolveGoFrameRoutes(g))
|
||||
assert.False(t, goframeHandlesRoute(g, "c.go::C.Other", "route::goframe::GET::/x"))
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// grpcStubPrefix is the placeholder namespace the Go extractor emits
|
||||
// for a gRPC client-stub call it can't land locally
|
||||
// (`unresolved::grpc::<Service>::<Method>`).
|
||||
const grpcStubPrefix = unresolvedPrefix + "grpc::"
|
||||
|
||||
// ResolveGRPCStubCalls is the graph-wide materialisation pass for the
|
||||
// gRPC stub-call layer (M4). It lands every gRPC client-stub method
|
||||
// call — emitted by the Go extractor as an EdgeCalls edge to the
|
||||
// `unresolved::grpc::<Service>::<Method>` placeholder, carrying
|
||||
// `Meta["via"]="grpc.stub"` plus `grpc_service` / `grpc_method` — on
|
||||
// the server-side handler method that implements that RPC.
|
||||
//
|
||||
// Handler discovery uses two signals, in priority order:
|
||||
//
|
||||
// 1. Registration. The generated gRPC code's `Register<Service>Server`
|
||||
// helper is called by the server with the concrete implementation
|
||||
// as its second argument (`pb.RegisterUserServiceServer(s, &userServer{})`).
|
||||
// The Go extractor stamps `grpc_register_service` / `grpc_register_impl`
|
||||
// meta on that call edge; this pass joins the impl type's methods
|
||||
// by name. Most precise — independent of InferImplements and of the
|
||||
// forward-compat `Unimplemented<Service>Server` embedding pattern.
|
||||
// Resolved edges ride at ast_resolved.
|
||||
//
|
||||
// 2. Interface satisfaction. When no registration is found, the pass
|
||||
// falls back to the `<Service>Server` interface and the concrete
|
||||
// types that EdgeImplements it (materialised by InferImplements,
|
||||
// skipping the generated `Unimplemented*` stub type). Resolved
|
||||
// edges ride at ast_inferred.
|
||||
//
|
||||
// The pass is a full recompute and idempotent: every grpc.stub edge's
|
||||
// target is recomputed from its own `grpc_service` / `grpc_method`
|
||||
// meta, so it is incremental-safe — a reindex of either the client or
|
||||
// the server file leaves the meta intact and the next pass re-lands
|
||||
// (or un-lands) the edge. graph.ReindexEdge keeps the out/in buckets
|
||||
// consistent. An edge whose handler is no longer in the graph is reset
|
||||
// back to the placeholder and loses its resolution-tier metadata.
|
||||
//
|
||||
// Runs at every resolver settle point that already runs InferImplements
|
||||
// (so signal 2 has its EdgeImplements edges) and before
|
||||
// DetectCrossRepoEdges (so a cross-repo gRPC call gets its parallel
|
||||
// cross_repo_calls edge).
|
||||
//
|
||||
// Returns the number of grpc.stub edges pointing at a resolved handler
|
||||
// after the pass.
|
||||
func ResolveGRPCStubCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
idx := buildGRPCHandlerIndex(g)
|
||||
resolved := 0
|
||||
var reindexBatch []graph.EdgeReindex
|
||||
// First pass: collect every grpc.stub edge plus the From IDs we'll
|
||||
// need to read RepoPrefix off, so the per-edge GetNode below
|
||||
// collapses to a single GetNodesByIDs batch on disk backends.
|
||||
type stubEdge struct {
|
||||
edge *graph.Edge
|
||||
service, method string
|
||||
}
|
||||
var stubs []stubEdge
|
||||
fromIDs := make(map[string]struct{})
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != "grpc.stub" {
|
||||
continue
|
||||
}
|
||||
service, _ := e.Meta["grpc_service"].(string)
|
||||
method, _ := e.Meta["grpc_method"].(string)
|
||||
if service == "" || method == "" {
|
||||
continue
|
||||
}
|
||||
stubs = append(stubs, stubEdge{edge: e, service: service, method: method})
|
||||
if e.From != "" {
|
||||
fromIDs[e.From] = struct{}{}
|
||||
}
|
||||
}
|
||||
fromList := make([]string, 0, len(fromIDs))
|
||||
for id := range fromIDs {
|
||||
fromList = append(fromList, id)
|
||||
}
|
||||
callerNodes := g.GetNodesByIDs(fromList)
|
||||
|
||||
for _, s := range stubs {
|
||||
e := s.edge
|
||||
callerRepo := ""
|
||||
if from := callerNodes[e.From]; from != nil {
|
||||
callerRepo = from.RepoPrefix
|
||||
}
|
||||
handlerID, origin, conf := idx.lookup(s.service, s.method, callerRepo)
|
||||
|
||||
want := handlerID
|
||||
if want == "" {
|
||||
want = grpcStubPlaceholder(s.service, s.method)
|
||||
}
|
||||
if e.To == want {
|
||||
if handlerID != "" {
|
||||
resolved++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
oldTo := e.To
|
||||
e.To = want
|
||||
if handlerID != "" {
|
||||
e.Origin = origin
|
||||
e.Confidence = conf
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf)
|
||||
e.Meta["grpc_resolution"] = origin
|
||||
StampSynthesized(e, SynthGRPCStub)
|
||||
resolved++
|
||||
} else {
|
||||
// Re-orphaned (handler removed since the last pass): drop the
|
||||
// resolution-tier metadata so the edge reads as a plain
|
||||
// unresolved placeholder again.
|
||||
e.Origin = ""
|
||||
e.Confidence = 0
|
||||
e.ConfidenceLabel = ""
|
||||
delete(e.Meta, "grpc_resolution")
|
||||
UnstampSynthesized(e)
|
||||
}
|
||||
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
}
|
||||
if len(reindexBatch) > 0 {
|
||||
g.ReindexEdges(reindexBatch)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// grpcStubPlaceholder is the canonical placeholder target for an
|
||||
// unresolved gRPC stub call.
|
||||
func grpcStubPlaceholder(service, method string) string {
|
||||
return grpcStubPrefix + service + "::" + method
|
||||
}
|
||||
|
||||
// grpcHandlerIndex maps a gRPC service name to candidate handler method
|
||||
// nodes, discovered via the registration and interface signals.
|
||||
type grpcHandlerIndex struct {
|
||||
registration map[string][]*graph.Node // service → impl method nodes (ast_resolved)
|
||||
iface map[string][]*graph.Node // service → impl method nodes (ast_inferred)
|
||||
}
|
||||
|
||||
// lookup returns the handler node ID for (service, method), preferring
|
||||
// the registration signal over the interface signal and a same-repo
|
||||
// candidate over a cross-repo one. Returns ("", "", 0) when no unique
|
||||
// handler is found.
|
||||
func (idx *grpcHandlerIndex) lookup(service, method, callerRepo string) (id, origin string, confidence float64) {
|
||||
if n := pickGRPCHandler(idx.registration[service], method, callerRepo); n != nil {
|
||||
return n.ID, graph.OriginASTResolved, 0.9
|
||||
}
|
||||
if n := pickGRPCHandler(idx.iface[service], method, callerRepo); n != nil {
|
||||
return n.ID, graph.OriginASTInferred, 0.7
|
||||
}
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
// buildGRPCHandlerIndex walks the graph once and indexes server-side
|
||||
// gRPC handler methods by service, via both discovery signals.
|
||||
func buildGRPCHandlerIndex(g graph.Store) *grpcHandlerIndex {
|
||||
typesByName := map[string][]*graph.Node{}
|
||||
ifacesByName := map[string][]*graph.Node{}
|
||||
typeAndIfaceNodes := nodesByKindsOrAll(g, graph.KindType, graph.KindInterface)
|
||||
for _, n := range typeAndIfaceNodes {
|
||||
switch n.Kind {
|
||||
case graph.KindType:
|
||||
typesByName[n.Name] = append(typesByName[n.Name], n)
|
||||
case graph.KindInterface:
|
||||
ifacesByName[n.Name] = append(ifacesByName[n.Name], n)
|
||||
}
|
||||
}
|
||||
|
||||
// methodsByType: type node ID → its method nodes (via EdgeMemberOf).
|
||||
// Use the MemberMethodsByType capability — projects only the four
|
||||
// columns we read (id/name/file/line) per row, no per-edge GetNode.
|
||||
rawMembers := memberMethodInfosByType(g)
|
||||
methodsByType := map[string][]*graph.Node{}
|
||||
for typeID, infos := range rawMembers {
|
||||
nodes := make([]*graph.Node, 0, len(infos))
|
||||
for _, m := range infos {
|
||||
nodes = append(nodes, &graph.Node{
|
||||
ID: m.MethodID,
|
||||
Kind: graph.KindMethod,
|
||||
Name: m.Name,
|
||||
FilePath: m.FilePath,
|
||||
StartLine: m.StartLine,
|
||||
RepoPrefix: m.RepoPrefix,
|
||||
})
|
||||
}
|
||||
methodsByType[typeID] = nodes
|
||||
}
|
||||
|
||||
// implementorsByIface: interface node ID → implementing type node
|
||||
// IDs. Pull only EdgeImplements; the From IDs are kept as-is for the
|
||||
// later impl filter (Unimplemented*).
|
||||
implementorsByIface := map[string][]string{}
|
||||
var registrations []*graph.Edge
|
||||
for e := range g.EdgesByKind(graph.EdgeImplements) {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
implementorsByIface[e.To] = append(implementorsByIface[e.To], e.From)
|
||||
}
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if svc, _ := e.Meta["grpc_register_service"].(string); svc != "" {
|
||||
registrations = append(registrations, e)
|
||||
}
|
||||
}
|
||||
|
||||
idx := &grpcHandlerIndex{
|
||||
registration: map[string][]*graph.Node{},
|
||||
iface: map[string][]*graph.Node{},
|
||||
}
|
||||
|
||||
// Prefetch the From nodes for every registration call so the
|
||||
// per-registration repo / dir lookup collapses to a single batch
|
||||
// GetNodesByIDs on disk backends.
|
||||
regFromIDs := make([]string, 0, len(registrations))
|
||||
for _, e := range registrations {
|
||||
if e.From != "" {
|
||||
regFromIDs = append(regFromIDs, e.From)
|
||||
}
|
||||
}
|
||||
regFromNodes := g.GetNodesByIDs(regFromIDs)
|
||||
|
||||
// Signal 1: registration calls. Resolve the impl type named by the
|
||||
// registration's second argument, then index its methods.
|
||||
for _, e := range registrations {
|
||||
service, _ := e.Meta["grpc_register_service"].(string)
|
||||
implType, _ := e.Meta["grpc_register_impl"].(string)
|
||||
if service == "" || implType == "" {
|
||||
continue
|
||||
}
|
||||
regRepo, regDir := "", ""
|
||||
if from := regFromNodes[e.From]; from != nil {
|
||||
regRepo = from.RepoPrefix
|
||||
regDir = grpcParentDir(from.FilePath)
|
||||
}
|
||||
typeNode := pickGRPCType(typesByName[implType], regRepo, regDir)
|
||||
if typeNode == nil {
|
||||
continue
|
||||
}
|
||||
idx.registration[service] = append(idx.registration[service], methodsByType[typeNode.ID]...)
|
||||
}
|
||||
|
||||
// Prefetch every implementor type referenced by a `<Service>Server`
|
||||
// interface so the per-implementor GetNode in Signal 2 collapses to
|
||||
// a batch.
|
||||
implTypeIDs := make(map[string]struct{})
|
||||
for name, ifaceNodes := range ifacesByName {
|
||||
const sfx = "Server"
|
||||
if len(name) <= len(sfx) || !strings.HasSuffix(name, sfx) {
|
||||
continue
|
||||
}
|
||||
for _, ifn := range ifaceNodes {
|
||||
for _, typeID := range implementorsByIface[ifn.ID] {
|
||||
if typeID != "" {
|
||||
implTypeIDs[typeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
implTypeList := make([]string, 0, len(implTypeIDs))
|
||||
for id := range implTypeIDs {
|
||||
implTypeList = append(implTypeList, id)
|
||||
}
|
||||
implTypeNodes := g.GetNodesByIDs(implTypeList)
|
||||
|
||||
// Signal 2: the `<Service>Server` interface and the concrete types
|
||||
// that implement it. The generated `Unimplemented<Service>Server`
|
||||
// stub also implements the interface — skip it so the fallback
|
||||
// lands on a real handler, not a "not implemented" stub.
|
||||
for name, ifaceNodes := range ifacesByName {
|
||||
const sfx = "Server"
|
||||
if len(name) <= len(sfx) || !strings.HasSuffix(name, sfx) {
|
||||
continue
|
||||
}
|
||||
service := name[:len(name)-len(sfx)]
|
||||
for _, ifn := range ifaceNodes {
|
||||
for _, typeID := range implementorsByIface[ifn.ID] {
|
||||
tn := implTypeNodes[typeID]
|
||||
if tn == nil || strings.HasPrefix(tn.Name, "Unimplemented") {
|
||||
continue
|
||||
}
|
||||
idx.iface[service] = append(idx.iface[service], methodsByType[typeID]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// pickGRPCType selects the impl type node for a registration call from
|
||||
// same-name candidates: an exact same-directory match wins outright,
|
||||
// then a unique same-repo match. Returns nil when ambiguous.
|
||||
func pickGRPCType(candidates []*graph.Node, repo, dir string) *graph.Node {
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return candidates[0]
|
||||
}
|
||||
var sameRepo []*graph.Node
|
||||
for _, n := range candidates {
|
||||
if dir != "" && grpcParentDir(n.FilePath) == dir {
|
||||
return n
|
||||
}
|
||||
if repo != "" && n.RepoPrefix == repo {
|
||||
sameRepo = append(sameRepo, n)
|
||||
}
|
||||
}
|
||||
if len(sameRepo) == 1 {
|
||||
return sameRepo[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickGRPCHandler selects the handler method named `name` from a
|
||||
// service's candidate methods, preferring a unique same-repo match,
|
||||
// then a unique match overall. Returns nil when no candidate matches
|
||||
// or the choice is ambiguous.
|
||||
func pickGRPCHandler(methods []*graph.Node, name, callerRepo string) *graph.Node {
|
||||
var all, sameRepo []*graph.Node
|
||||
seen := map[string]bool{}
|
||||
for _, m := range methods {
|
||||
if m == nil || m.Name != name || seen[m.ID] {
|
||||
continue
|
||||
}
|
||||
seen[m.ID] = true
|
||||
all = append(all, m)
|
||||
if callerRepo != "" && m.RepoPrefix == callerRepo {
|
||||
sameRepo = append(sameRepo, m)
|
||||
}
|
||||
}
|
||||
if len(sameRepo) == 1 {
|
||||
return sameRepo[0]
|
||||
}
|
||||
if len(sameRepo) == 0 && len(all) == 1 {
|
||||
return all[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// grpcParentDir returns the slash-separated parent directory of a graph
|
||||
// file path. Graph paths are slash-normalised, so a plain byte scan is
|
||||
// correct on every OS.
|
||||
func grpcParentDir(path string) string {
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// grpcTestGraph is a builder for the minimal graph shape the
|
||||
// ResolveGRPCStubCalls pass consumes: a client function with a
|
||||
// grpc.stub call edge, and a server-side handler discoverable via
|
||||
// registration and/or interface satisfaction.
|
||||
type grpcTestGraph struct {
|
||||
g graph.Store
|
||||
}
|
||||
|
||||
func newGRPCTestGraph() *grpcTestGraph { return &grpcTestGraph{g: graph.New()} }
|
||||
|
||||
// addCaller adds a client-side function node.
|
||||
func (b *grpcTestGraph) addCaller(id, filePath, repo string) {
|
||||
b.g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: lastSeg(id), FilePath: filePath, RepoPrefix: repo})
|
||||
}
|
||||
|
||||
// addStubCall adds the grpc.stub EdgeCalls placeholder edge from caller.
|
||||
func (b *grpcTestGraph) addStubCall(callerID, service, method, filePath string) *graph.Edge {
|
||||
e := &graph.Edge{
|
||||
From: callerID, To: grpcStubPlaceholder(service, method),
|
||||
Kind: graph.EdgeCalls, FilePath: filePath, Line: 10,
|
||||
Meta: map[string]any{"via": "grpc.stub", "grpc_service": service, "grpc_method": method},
|
||||
}
|
||||
b.g.AddEdge(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// addServerImpl adds a server impl type plus one method per name, wired
|
||||
// with EdgeMemberOf. Returns the method node IDs keyed by name.
|
||||
func (b *grpcTestGraph) addServerImpl(typeName, dir, repo string, methods ...string) map[string]string {
|
||||
typeID := dir + "/impl.go::" + typeName
|
||||
b.g.AddNode(&graph.Node{ID: typeID, Kind: graph.KindType, Name: typeName, FilePath: dir + "/impl.go", RepoPrefix: repo})
|
||||
out := map[string]string{}
|
||||
for _, m := range methods {
|
||||
mid := dir + "/impl.go::" + typeName + "." + m
|
||||
b.g.AddNode(&graph.Node{
|
||||
ID: mid, Kind: graph.KindMethod, Name: m, FilePath: dir + "/impl.go", RepoPrefix: repo,
|
||||
Meta: map[string]any{"receiver": typeName},
|
||||
})
|
||||
b.g.AddEdge(&graph.Edge{From: mid, To: typeID, Kind: graph.EdgeMemberOf, FilePath: dir + "/impl.go", Line: 1})
|
||||
out[m] = mid
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addRegistration adds the server-side registration call edge that
|
||||
// names typeName as the impl for service.
|
||||
func (b *grpcTestGraph) addRegistration(service, typeName, regFuncID, regFilePath, repo string) {
|
||||
b.g.AddNode(&graph.Node{ID: regFuncID, Kind: graph.KindFunction, Name: lastSeg(regFuncID), FilePath: regFilePath, RepoPrefix: repo})
|
||||
b.g.AddEdge(&graph.Edge{
|
||||
From: regFuncID, To: "unresolved::extern::example.com/proto::Register" + service + "Server",
|
||||
Kind: graph.EdgeCalls, FilePath: regFilePath, Line: 5,
|
||||
Meta: map[string]any{"grpc_register_service": service, "grpc_register_impl": typeName},
|
||||
})
|
||||
}
|
||||
|
||||
// addServerInterface adds a `<Service>Server` interface and an
|
||||
// EdgeImplements edge from the impl type to it.
|
||||
func (b *grpcTestGraph) addServerInterface(service, implTypeID, ifaceDir, repo string) {
|
||||
ifaceID := ifaceDir + "/grpc.pb.go::" + service + "Server"
|
||||
b.g.AddNode(&graph.Node{ID: ifaceID, Kind: graph.KindInterface, Name: service + "Server", FilePath: ifaceDir + "/grpc.pb.go", RepoPrefix: repo})
|
||||
b.g.AddEdge(&graph.Edge{From: implTypeID, To: ifaceID, Kind: graph.EdgeImplements, FilePath: ifaceDir + "/grpc.pb.go", Line: 1})
|
||||
}
|
||||
|
||||
func lastSeg(id string) string {
|
||||
for i := len(id) - 1; i >= 0; i-- {
|
||||
if id[i] == ':' || id[i] == '.' || id[i] == '/' {
|
||||
return id[i+1:]
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_Registration(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
methods := b.addServerImpl("userServer", "svc", "svc", "GetUser", "ListUsers")
|
||||
b.addRegistration("UserService", "userServer", "svc/main.go::main", "svc/main.go", "svc")
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 1, resolved)
|
||||
assert.Equal(t, methods["GetUser"], call.To, "stub call must land on the registered handler")
|
||||
assert.Equal(t, graph.OriginASTResolved, call.Origin)
|
||||
assert.Equal(t, 0.9, call.Confidence)
|
||||
assert.Equal(t, "EXTRACTED", call.ConfidenceLabel)
|
||||
assert.Equal(t, graph.OriginASTResolved, call.Meta["grpc_resolution"])
|
||||
|
||||
// Edge buckets stay consistent after ReindexEdge.
|
||||
assert.Equal(t, call, firstOutEdgeByKind(b.g, "cli/main.go::run", graph.EdgeCalls))
|
||||
require.Len(t, b.g.GetInEdges(methods["GetUser"]), 1, "handler must see the inbound call edge")
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_InterfaceFallback(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
methods := b.addServerImpl("userServer", "svc", "svc", "GetUser")
|
||||
b.addServerInterface("UserService", "svc/impl.go::userServer", "svc", "svc")
|
||||
// No registration — interface satisfaction is the only signal.
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 1, resolved)
|
||||
assert.Equal(t, methods["GetUser"], call.To)
|
||||
assert.Equal(t, graph.OriginASTInferred, call.Origin)
|
||||
assert.Equal(t, 0.7, call.Confidence)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_RegistrationPreferredOverInterface(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
// The real handler, found via registration.
|
||||
real := b.addServerImpl("userServer", "svc", "svc", "GetUser")
|
||||
b.addRegistration("UserService", "userServer", "svc/main.go::main", "svc/main.go", "svc")
|
||||
// A decoy type also structurally implements the interface.
|
||||
decoy := b.addServerImpl("decoyServer", "decoy", "svc", "GetUser")
|
||||
b.addServerInterface("UserService", "decoy/impl.go::decoyServer", "decoy", "svc")
|
||||
|
||||
ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, real["GetUser"], call.To, "registration signal must win over interface signal")
|
||||
assert.NotEqual(t, decoy["GetUser"], call.To)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_UnimplementedSkipped(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
// Only the generated Unimplemented stub implements the interface;
|
||||
// the interface signal must skip it and resolve nothing.
|
||||
b.addServerImpl("UnimplementedUserServiceServer", "svc", "svc", "GetUser")
|
||||
b.addServerInterface("UserService", "svc/impl.go::UnimplementedUserServiceServer", "svc", "svc")
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 0, resolved)
|
||||
assert.Equal(t, grpcStubPlaceholder("UserService", "GetUser"), call.To)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_NoHandlerStaysPlaceholder(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 0, resolved)
|
||||
assert.Equal(t, grpcStubPlaceholder("UserService", "GetUser"), call.To)
|
||||
assert.Empty(t, call.Origin)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_Idempotent(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
methods := b.addServerImpl("userServer", "svc", "svc", "GetUser")
|
||||
b.addRegistration("UserService", "userServer", "svc/main.go::main", "svc/main.go", "svc")
|
||||
|
||||
first := ResolveGRPCStubCalls(b.g)
|
||||
second := ResolveGRPCStubCalls(b.g)
|
||||
third := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
assert.Equal(t, 1, third)
|
||||
assert.Equal(t, methods["GetUser"], call.To)
|
||||
// No duplicate inbound edges accreted across re-runs.
|
||||
require.Len(t, b.g.GetInEdges(methods["GetUser"]), 1)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_ReorphanWhenSignalLost(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
b.addServerImpl("userServer", "svc", "svc", "GetUser")
|
||||
b.addRegistration("UserService", "userServer", "svc/main.go::main", "svc/main.go", "svc")
|
||||
|
||||
ResolveGRPCStubCalls(b.g)
|
||||
require.NotEqual(t, grpcStubPlaceholder("UserService", "GetUser"), call.To)
|
||||
|
||||
// The wiring file is reindexed and no longer registers the server
|
||||
// (and there is no interface-satisfaction fallback). The handler
|
||||
// node and the client call edge both still exist, but the pass can
|
||||
// no longer discover a handler — the edge must re-orphan.
|
||||
b.g.EvictFile("svc/main.go")
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 0, resolved)
|
||||
assert.Equal(t, grpcStubPlaceholder("UserService", "GetUser"), call.To, "edge must re-orphan to the placeholder")
|
||||
assert.Empty(t, call.Origin)
|
||||
assert.Empty(t, call.ConfidenceLabel)
|
||||
_, hasRes := call.Meta["grpc_resolution"]
|
||||
assert.False(t, hasRes, "grpc_resolution meta must be cleared on re-orphan")
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_AmbiguousUnresolved(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
// Two distinct impl types in the same repo both expose GetUser via
|
||||
// the interface signal — ambiguous, must not resolve.
|
||||
b.addServerImpl("serverA", "a", "svc", "GetUser")
|
||||
b.addServerImpl("serverB", "b", "svc", "GetUser")
|
||||
b.addServerInterface("UserService", "a/impl.go::serverA", "a", "svc")
|
||||
b.addServerInterface("UserService", "b/impl.go::serverB", "b", "svc")
|
||||
|
||||
resolved := ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, 0, resolved)
|
||||
assert.Equal(t, grpcStubPlaceholder("UserService", "GetUser"), call.To)
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_SameRepoPreference(t *testing.T) {
|
||||
b := newGRPCTestGraph()
|
||||
// Caller lives in repo "svc" alongside one of two handler impls.
|
||||
b.addCaller("svc/client.go::run", "svc/client.go", "svc")
|
||||
call := b.addStubCall("svc/client.go::run", "UserService", "GetUser", "svc/client.go")
|
||||
local := b.addServerImpl("localServer", "svc", "svc", "GetUser")
|
||||
b.addServerInterface("UserService", "svc/impl.go::localServer", "svc", "svc")
|
||||
b.addServerImpl("remoteServer", "other", "other", "GetUser")
|
||||
b.addServerInterface("UserService", "other/impl.go::remoteServer", "other", "other")
|
||||
|
||||
ResolveGRPCStubCalls(b.g)
|
||||
assert.Equal(t, local["GetUser"], call.To, "same-repo handler must win the tie-break")
|
||||
}
|
||||
|
||||
func TestResolveGRPCStubCalls_CrossRepo(t *testing.T) {
|
||||
// Client in repo "cli", handler in repo "svc": the resolved
|
||||
// EdgeCalls edge then flows through DetectCrossRepoEdges.
|
||||
b := newGRPCTestGraph()
|
||||
b.addCaller("cli/main.go::run", "cli/main.go", "cli")
|
||||
call := b.addStubCall("cli/main.go::run", "UserService", "GetUser", "cli/main.go")
|
||||
methods := b.addServerImpl("userServer", "svc", "svc", "GetUser")
|
||||
b.addRegistration("UserService", "userServer", "svc/main.go::main", "svc/main.go", "svc")
|
||||
|
||||
ResolveGRPCStubCalls(b.g)
|
||||
require.Equal(t, methods["GetUser"], call.To)
|
||||
|
||||
emitted := DetectCrossRepoEdges(b.g)
|
||||
assert.Equal(t, 1, emitted, "resolved cross-repo gRPC call must materialise a cross_repo_calls edge")
|
||||
cr := firstOutEdgeByKind(b.g, "cli/main.go::run", graph.EdgeCrossRepoCalls)
|
||||
require.NotNil(t, cr)
|
||||
assert.Equal(t, methods["GetUser"], cr.To)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestImportEvidence_DisambiguatesBareJSTSCalls drives the import-closure
|
||||
// disambiguation through the real extract → resolve pipeline (the same
|
||||
// harness as the cross-package guard tests). Each row pins one leg of the
|
||||
// precedence contract documented in import_evidence.go.
|
||||
func TestImportEvidence_DisambiguatesBareJSTSCalls(t *testing.T) {
|
||||
// The library-side alias-cast export (`createStoreImpl as
|
||||
// CreateStore`) lands as a variable/constant node — the value-callee
|
||||
// shape (zustand's `persist`-style export) whose repo-wide ambiguity
|
||||
// refuses every call edge without import evidence.
|
||||
vanillaCastExport := `type CreateStore = (init?: unknown) => unknown;
|
||||
const createStoreImpl = (init: unknown): unknown => init;
|
||||
export const createStore = createStoreImpl as CreateStore;
|
||||
export const other = 1;
|
||||
`
|
||||
cases := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
callerID string
|
||||
callLine int
|
||||
// wantTo, when set, is the node the call MUST resolve to.
|
||||
wantTo string
|
||||
// forbidTo, when set, is a node the call must NOT resolve to.
|
||||
forbidTo string
|
||||
// wantUnresolved requires the edge to stay an `unresolved::`
|
||||
// placeholder (ambiguity without import evidence still refuses).
|
||||
wantUnresolved bool
|
||||
}{
|
||||
{
|
||||
// Cross-dir ambiguity that used to refuse: the caller's
|
||||
// explicit import of one candidate's file resolves it.
|
||||
name: "import closure wins over ambiguity refusal",
|
||||
files: map[string]string{
|
||||
"src/vanilla.ts": vanillaCastExport,
|
||||
"tests/shadow/helper.test.ts": `export const createStore = () => ({ local: true });
|
||||
`,
|
||||
"tests/basic.test.ts": `import { createStore } from '../src/vanilla';
|
||||
function makeCounter(): unknown {
|
||||
return createStore(() => ({ count: 0 }));
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "tests/basic.test.ts::makeCounter",
|
||||
callLine: 3,
|
||||
wantTo: "src/vanilla.ts::createStore",
|
||||
forbidTo: "tests/shadow/helper.test.ts::createStore",
|
||||
},
|
||||
{
|
||||
// A same-directory neighbour defining the name is NOT ambient
|
||||
// scope in the ES module system — the explicit import of
|
||||
// another candidate must beat the same-dir shadow the
|
||||
// locality loop would otherwise bind.
|
||||
name: "same-dir shadow loses to explicit import",
|
||||
files: map[string]string{
|
||||
"src/vanilla.ts": `export function createStore(init: unknown): unknown { return init; }
|
||||
`,
|
||||
"tests/util.ts": `export function createStore(): unknown { return { helper: true }; }
|
||||
`,
|
||||
"tests/a.test.ts": `import { createStore } from '../src/vanilla';
|
||||
function setup(): unknown {
|
||||
return createStore({});
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "tests/a.test.ts::setup",
|
||||
callLine: 3,
|
||||
wantTo: "src/vanilla.ts::createStore",
|
||||
forbidTo: "tests/util.ts::createStore",
|
||||
},
|
||||
{
|
||||
// No import evidence: cross-dir value-callee ambiguity keeps
|
||||
// today's refusal — no arbitrary winner.
|
||||
name: "no-import ambiguity still refuses",
|
||||
files: map[string]string{
|
||||
"src/vanilla.ts": vanillaCastExport,
|
||||
"other/helper.ts": `export const createStore = () => ({ local: true });
|
||||
`,
|
||||
"app/main.ts": `function run(): unknown {
|
||||
return createStore();
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "app/main.ts::run",
|
||||
callLine: 2,
|
||||
wantUnresolved: true,
|
||||
},
|
||||
{
|
||||
// Both candidates' files are imported (one for an unrelated
|
||||
// binding): the import statement alone cannot arbitrate, so
|
||||
// the pick refuses exactly like the no-import case.
|
||||
// Alias-cast exports (KindVariable) keep today's behaviour
|
||||
// deterministic: the value-callee fallback refuses on
|
||||
// repo-wide ambiguity.
|
||||
name: "multiple imported candidates still refuse",
|
||||
files: map[string]string{
|
||||
"a/one.ts": `type MakeA = () => unknown;
|
||||
const implA = () => ({ a: true });
|
||||
export const createStore = implA as MakeA;
|
||||
`,
|
||||
"b/two.ts": `type MakeB = () => unknown;
|
||||
const implB = () => ({ b: true });
|
||||
export const createStore = implB as MakeB;
|
||||
export const helper = () => 1;
|
||||
`,
|
||||
"app/main.ts": `import { createStore } from '../a/one';
|
||||
import { helper } from '../b/two';
|
||||
function run(): unknown {
|
||||
helper();
|
||||
return createStore();
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "app/main.ts::run",
|
||||
callLine: 5,
|
||||
wantUnresolved: true,
|
||||
},
|
||||
{
|
||||
// A module-local definition blocks the import pick: the call
|
||||
// binds to the caller file's own symbol even though another
|
||||
// candidate's file is imported (for an unrelated binding).
|
||||
name: "module-local definition beats import evidence",
|
||||
files: map[string]string{
|
||||
"src/vanilla.ts": vanillaCastExport,
|
||||
"tests/local.test.ts": `import { other } from '../src/vanilla';
|
||||
export function createStore(): unknown { return { local: other }; }
|
||||
function setup(): unknown {
|
||||
return createStore();
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "tests/local.test.ts::setup",
|
||||
callLine: 4,
|
||||
wantTo: "tests/local.test.ts::createStore",
|
||||
forbidTo: "src/vanilla.ts::createStore",
|
||||
},
|
||||
{
|
||||
// File-local tier: with NO import, the caller's own helper
|
||||
// must win over a same-named helper in a neighbouring file
|
||||
// of the same directory (candidate iteration order used to
|
||||
// decide this — zustand's persistSync tests bound to
|
||||
// persistAsync's helper).
|
||||
name: "own-file helper beats same-dir neighbour shadow",
|
||||
files: map[string]string{
|
||||
"tests/sync.test.ts": `export function createStore(): unknown { return { sync: true }; }
|
||||
function setup(): unknown {
|
||||
return createStore();
|
||||
}
|
||||
`,
|
||||
"tests/async.test.ts": `export function createStore(): unknown { return { async: true }; }
|
||||
`,
|
||||
},
|
||||
callerID: "tests/sync.test.ts::setup",
|
||||
callLine: 3,
|
||||
wantTo: "tests/sync.test.ts::createStore",
|
||||
forbidTo: "tests/async.test.ts::createStore",
|
||||
},
|
||||
{
|
||||
// Barrel hop: importing a re-exporting module makes the
|
||||
// re-exported module's symbols import-evidence too.
|
||||
name: "re-export barrel hop carries the evidence",
|
||||
files: map[string]string{
|
||||
"src/vanilla.ts": vanillaCastExport,
|
||||
"src/index.ts": `export { createStore } from './vanilla.ts';
|
||||
`,
|
||||
"tests/shadow/helper.test.ts": `export const createStore = () => ({ local: true });
|
||||
`,
|
||||
"tests/barrel.test.ts": `import { createStore } from '../src/index';
|
||||
function setup(): unknown {
|
||||
return createStore(() => ({}));
|
||||
}
|
||||
`,
|
||||
},
|
||||
callerID: "tests/barrel.test.ts::setup",
|
||||
callLine: 3,
|
||||
wantTo: "src/vanilla.ts::createStore",
|
||||
forbidTo: "tests/shadow/helper.test.ts::createStore",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := buildGraphFromSources(t, tc.files)
|
||||
New(g).ResolveAll()
|
||||
|
||||
got := callEdgeTo(g, tc.callerID, tc.callLine)
|
||||
if got == "" {
|
||||
t.Fatalf("no call edge found from %s at line %d", tc.callerID, tc.callLine)
|
||||
}
|
||||
if tc.wantTo != "" && got != tc.wantTo {
|
||||
t.Errorf("call resolved to %q, want %q", got, tc.wantTo)
|
||||
}
|
||||
if tc.forbidTo != "" && got == tc.forbidTo {
|
||||
t.Errorf("call mis-resolved to forbidden candidate %q", tc.forbidTo)
|
||||
}
|
||||
if tc.wantUnresolved && !strings.HasPrefix(got, "unresolved::") {
|
||||
t.Errorf("call resolved to %q, expected it to stay unresolved", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportEvidence_ResolvedEdgeStampsProvenance asserts the winning edge
|
||||
// carries the structural-evidence tier: ast_resolved origin and the
|
||||
// import_closure resolution marker, so the cross-package guard (which only
|
||||
// polices text_matched / ast_inferred) never reverts it.
|
||||
func TestImportEvidence_ResolvedEdgeStampsProvenance(t *testing.T) {
|
||||
g := buildGraphFromSources(t, map[string]string{
|
||||
"src/vanilla.ts": `export function createStore(init: unknown): unknown { return init; }
|
||||
`,
|
||||
"tests/util.ts": `export function createStore(): unknown { return { helper: true }; }
|
||||
`,
|
||||
"tests/a.test.ts": `import { createStore } from '../src/vanilla';
|
||||
function setup(): unknown {
|
||||
return createStore({});
|
||||
}
|
||||
`,
|
||||
})
|
||||
New(g).ResolveAll()
|
||||
|
||||
for _, e := range g.GetOutEdges("tests/a.test.ts::setup") {
|
||||
if e.Kind != graph.EdgeCalls || e.Line != 3 {
|
||||
continue
|
||||
}
|
||||
if e.To != "src/vanilla.ts::createStore" {
|
||||
t.Fatalf("call resolved to %q, want src/vanilla.ts::createStore", e.To)
|
||||
}
|
||||
if e.Origin != graph.OriginASTResolved {
|
||||
t.Errorf("origin = %q, want %q", e.Origin, graph.OriginASTResolved)
|
||||
}
|
||||
if e.Meta == nil || e.Meta["resolution"] != "import_closure" {
|
||||
t.Errorf("meta resolution = %v, want import_closure", e.Meta)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("no call edge found from tests/a.test.ts::setup at line 3")
|
||||
}
|
||||
|
||||
// TestPickImportEvidenceCallee_LanguageGate pins the correctness gate: the
|
||||
// pick never runs for non-JS/TS callers, so Go (directory-scoped packages,
|
||||
// where a bare call can never name another package's symbol) and Python
|
||||
// (`import x` does not bring bare names into scope) resolution is
|
||||
// bit-for-bit unchanged.
|
||||
func TestPickImportEvidenceCallee_LanguageGate(t *testing.T) {
|
||||
g := graph.New()
|
||||
r := New(g)
|
||||
candidates := []*graph.Node{
|
||||
{ID: "pkgA/b.go::helper", Kind: graph.KindFunction, FilePath: "pkgA/b.go", Name: "helper"},
|
||||
{ID: "pkgB/c.go::helper", Kind: graph.KindFunction, FilePath: "pkgB/c.go", Name: "helper"},
|
||||
}
|
||||
if pick := r.pickImportEvidenceCallee("pkgA/a.go", "helper", candidates); pick != nil {
|
||||
t.Errorf("go caller: pick = %v, want nil (language gate)", pick.ID)
|
||||
}
|
||||
if pick := r.pickImportEvidenceCallee("app/mod.py", "helper", candidates); pick != nil {
|
||||
t.Errorf("python caller: pick = %v, want nil (language gate)", pick.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// Incremental single-file resolve: skip re-resolving a file's references that
|
||||
// were already unresolved before the edit.
|
||||
//
|
||||
// On a save the whole file is evicted, re-parsed (every edge unresolved), and
|
||||
// re-resolved. For a reference-heavy file most of those edges are stdlib /
|
||||
// external calls (strings.HasPrefix, fmt.Sprintf, …) that never bind to an
|
||||
// in-repo symbol — yet the resolver re-runs its full candidate fetch + cascade
|
||||
// on each of them every save. That re-work dominates edit latency.
|
||||
//
|
||||
// An edge that was unresolved before the edit and is unchanged will not bind
|
||||
// now either: it still points at the same stub, and the incoming pass already
|
||||
// rebinds it if a matching symbol later appears (when that symbol's file is
|
||||
// indexed). So the single-file path captures those prior-unresolved shapes and
|
||||
// the forward pass skips them, touching only references the edit actually
|
||||
// added or changed.
|
||||
|
||||
// SetIncrementalSkip installs the prior-unresolved out-edge shapes for the
|
||||
// file about to be re-resolved (nil to clear afterwards). Only the forward
|
||||
// pass honours it; the incoming pass still rebinds other files' references to
|
||||
// this file's symbols. The per-file resolve runs single-goroutine under r.mu,
|
||||
// so this field needs no extra synchronisation.
|
||||
func (r *Resolver) SetIncrementalSkip(priorUnresolved []*graph.Edge) {
|
||||
if len(priorUnresolved) == 0 {
|
||||
r.incrementalSkip = nil
|
||||
return
|
||||
}
|
||||
skip := make(map[string]struct{}, len(priorUnresolved))
|
||||
for _, e := range priorUnresolved {
|
||||
if e != nil {
|
||||
skip[r.edgeShape(e)] = struct{}{}
|
||||
}
|
||||
}
|
||||
r.incrementalSkip = skip
|
||||
}
|
||||
|
||||
// edgeShape canonicalises an edge by its source-side identity — origin symbol,
|
||||
// kind, receiver type, referenced name — independent of line number and of
|
||||
// whether the edge is currently resolved. The captured prior edges and the
|
||||
// freshly re-parsed ones run through the same function, so an unchanged
|
||||
// reference produces the same key and is recognised as a carry-over.
|
||||
func (r *Resolver) edgeShape(e *graph.Edge) string {
|
||||
name := identifierFromTarget(graph.UnresolvedName(e.To))
|
||||
return e.From + "\x1f" + string(e.Kind) + "\x1f" + edgeReceiverType(e) + "\x1f" + name
|
||||
}
|
||||
|
||||
// incrementalSkipped reports whether an unresolved edge should be left for the
|
||||
// incoming pass instead of re-running the forward cascade on it.
|
||||
func (r *Resolver) incrementalSkipped(e *graph.Edge) bool {
|
||||
if r.incrementalSkip == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := r.incrementalSkip[r.edgeShape(e)]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// implementsFixture builds a graph where type A and type B each have a method M
|
||||
// matching interface I (same repo), so InferImplements should infer A→I, B→I.
|
||||
func implementsFixture() *graph.Graph {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "iface.go::I", Kind: graph.KindInterface, Name: "I", Meta: map[string]any{"methods": []string{"M"}}})
|
||||
for _, ty := range []string{"A", "B"} {
|
||||
g.AddNode(&graph.Node{ID: "x.go::" + ty, Kind: graph.KindType, Name: ty})
|
||||
g.AddNode(&graph.Node{ID: "x.go::" + ty + ".M", Kind: graph.KindMethod, Name: "M"})
|
||||
g.AddEdge(&graph.Edge{From: "x.go::" + ty + ".M", To: "x.go::" + ty, Kind: graph.EdgeMemberOf})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func implementsEdges(g *graph.Graph) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Kind == graph.EdgeImplements {
|
||||
out[e.From+"->"+e.To] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestInferImplementsScoped_ParityFull asserts the scoped pass, given the
|
||||
// changed interface in its scope, re-derives the SAME implements edges as the
|
||||
// full pass — even for implementor types in unchanged files (the EvictFile
|
||||
// in-edge-drop regression guard).
|
||||
func TestInferImplementsScoped_ParityFull(t *testing.T) {
|
||||
full := implementsFixture()
|
||||
New(full).InferImplements()
|
||||
wantEdges := implementsEdges(full)
|
||||
if len(wantEdges) != 2 {
|
||||
t.Fatalf("setup: expected 2 implements edges from full pass, got %d", len(wantEdges))
|
||||
}
|
||||
|
||||
// Scope = the interface changed (its in-edges were dropped); scoped must
|
||||
// re-check every type against it.
|
||||
scoped := implementsFixture()
|
||||
New(scoped).InferImplementsScoped(map[string]bool{}, map[string]bool{"iface.go::I": true})
|
||||
if got := implementsEdges(scoped); len(got) != len(wantEdges) {
|
||||
t.Fatalf("scoped (changed iface) = %v, want %v", got, wantEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferImplementsScoped_AffectedType(t *testing.T) {
|
||||
// Only type A is affected (changed); scoped must re-check A against all
|
||||
// interfaces and add A->I, but not touch B.
|
||||
g := implementsFixture()
|
||||
New(g).InferImplementsScoped(map[string]bool{"x.go::A": true}, map[string]bool{})
|
||||
got := implementsEdges(g)
|
||||
if !got["x.go::A->iface.go::I"] {
|
||||
t.Errorf("expected A->I from affected type A, got %v", got)
|
||||
}
|
||||
if got["x.go::B->iface.go::I"] {
|
||||
t.Errorf("B was not affected and has no survivor edge here, must not be inferred: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferImplementsScoped_EmptyScopeNoWork(t *testing.T) {
|
||||
g := implementsFixture()
|
||||
if n := New(g).InferImplementsScoped(map[string]bool{}, map[string]bool{}); n != 0 {
|
||||
t.Errorf("empty scope must do zero work, added %d", n)
|
||||
}
|
||||
if len(implementsEdges(g)) != 0 {
|
||||
t.Errorf("empty scope must add no edges")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferOverridesScoped_Parity: child C overrides parent P's method; scoped
|
||||
// with the parent in scope re-derives the override edge.
|
||||
func TestInferOverridesScoped_Parity(t *testing.T) {
|
||||
build := func() *graph.Graph {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "p.go::P", Kind: graph.KindType, Name: "P"})
|
||||
g.AddNode(&graph.Node{ID: "p.go::P.M", Kind: graph.KindMethod, Name: "M", FilePath: "p.go"})
|
||||
g.AddEdge(&graph.Edge{From: "p.go::P.M", To: "p.go::P", Kind: graph.EdgeMemberOf})
|
||||
g.AddNode(&graph.Node{ID: "c.go::C", Kind: graph.KindType, Name: "C"})
|
||||
g.AddNode(&graph.Node{ID: "c.go::C.M", Kind: graph.KindMethod, Name: "M", FilePath: "c.go"})
|
||||
g.AddEdge(&graph.Edge{From: "c.go::C.M", To: "c.go::C", Kind: graph.EdgeMemberOf})
|
||||
g.AddEdge(&graph.Edge{From: "c.go::C", To: "p.go::P", Kind: graph.EdgeExtends, Origin: graph.OriginASTResolved})
|
||||
return g
|
||||
}
|
||||
overrideEdges := func(g *graph.Graph) int {
|
||||
n := 0
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Kind == graph.EdgeOverrides {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
full := build()
|
||||
New(full).InferOverrides()
|
||||
if overrideEdges(full) != 1 {
|
||||
t.Fatalf("setup: expected 1 override edge from full pass, got %d", overrideEdges(full))
|
||||
}
|
||||
// Parent changed → scope includes P; scoped must re-derive C.M->P.M.
|
||||
scoped := build()
|
||||
New(scoped).InferOverridesScoped(map[string]bool{"p.go::P": true})
|
||||
if overrideEdges(scoped) != 1 {
|
||||
t.Errorf("scoped override (parent in scope) = %d, want 1", overrideEdges(scoped))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// A `new Foo(arg)` constructor-call candidate must bind to the class's explicit
|
||||
// constructor node, and a `new Bar()` whose class has only an implicit default
|
||||
// constructor (no `<init>` node) must stay unresolved rather than latching onto
|
||||
// an unrelated class's constructor.
|
||||
func TestResolveMethodCall_JavaConstructorCall(t *testing.T) {
|
||||
g := graph.New()
|
||||
fooFile := "src/main/java/org/example/PetTypeFormatter.java"
|
||||
testFile := "src/test/java/org/example/PetTypeFormatterTests.java"
|
||||
g.AddNode(&graph.Node{ID: fooFile, Kind: graph.KindFile, Name: "PetTypeFormatter.java", FilePath: fooFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: testFile, Kind: graph.KindFile, Name: "PetTypeFormatterTests.java", FilePath: testFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: fooFile + "::PetTypeFormatter", Kind: graph.KindType, Name: "PetTypeFormatter", FilePath: fooFile, Language: "java", Meta: map[string]any{"scope_pkg": "org.example"}})
|
||||
// Explicit constructor node: flat name `<Class>.<init>`, receiver meta.
|
||||
g.AddNode(&graph.Node{ID: fooFile + "::PetTypeFormatter.<init>", Kind: graph.KindMethod, Name: "PetTypeFormatter.<init>", FilePath: fooFile, Language: "java", Meta: map[string]any{"receiver": "PetTypeFormatter", "scope_pkg": "org.example"}})
|
||||
g.AddNode(&graph.Node{ID: testFile + "::PetTypeFormatterTests.shouldFormat", Kind: graph.KindMethod, Name: "shouldFormat", FilePath: testFile, Language: "java", Meta: map[string]any{"receiver": "PetTypeFormatterTests", "scope_pkg": "org.example"}})
|
||||
|
||||
bound := &graph.Edge{From: testFile + "::PetTypeFormatterTests.shouldFormat", To: "unresolved::*.PetTypeFormatter.<init>", Kind: graph.EdgeCalls, FilePath: testFile, Line: 52, Meta: map[string]any{"receiver_type": "PetTypeFormatter", "via": "constructor"}}
|
||||
// A class with only an implicit default constructor — no `<init>` node.
|
||||
implicit := &graph.Edge{From: testFile + "::PetTypeFormatterTests.shouldFormat", To: "unresolved::*.Visit.<init>", Kind: graph.EdgeCalls, FilePath: testFile, Line: 53, Meta: map[string]any{"receiver_type": "Visit", "via": "constructor"}}
|
||||
g.AddEdge(bound)
|
||||
g.AddEdge(implicit)
|
||||
|
||||
r := New(g)
|
||||
r.ResolveAll()
|
||||
|
||||
assert.Equal(t, fooFile+"::PetTypeFormatter.<init>", bound.To,
|
||||
"new PetTypeFormatter() must bind to the explicit constructor node")
|
||||
assert.Equal(t, "unresolved::*.Visit.<init>", implicit.To,
|
||||
"new Visit() with no explicit constructor must stay unresolved, not latch onto another class's ctor")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// A test-tree caller invoking an interface method declared in the main tree —
|
||||
// same Java package, two Maven directories, `this.<field>` receiver stamped
|
||||
// with receiver_type — must resolve to the interface method node and survive
|
||||
// the cross-package guard (which reverts directory-only "unreachable" edges).
|
||||
func TestResolveMethodCall_JavaMavenThisFieldInterface(t *testing.T) {
|
||||
const pkg = "org.springframework.samples.petclinic.owner"
|
||||
g := graph.New()
|
||||
mainFile := "src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java"
|
||||
testFile := "src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java"
|
||||
g.AddNode(&graph.Node{ID: mainFile, Kind: graph.KindFile, Name: "OwnerRepository.java", FilePath: mainFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: testFile, Kind: graph.KindFile, Name: "OwnerControllerTests.java", FilePath: testFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: mainFile + "::OwnerRepository", Kind: graph.KindInterface, Name: "OwnerRepository", FilePath: mainFile, Language: "java", Meta: map[string]any{"scope_pkg": pkg}})
|
||||
// Flat interface method node carrying receiver meta (WS-A #2).
|
||||
g.AddNode(&graph.Node{ID: mainFile + "::findByLastNameStartingWith", Kind: graph.KindMethod, Name: "findByLastNameStartingWith", FilePath: mainFile, Language: "java", Meta: map[string]any{"receiver": "OwnerRepository", "scope_pkg": pkg}})
|
||||
g.AddNode(&graph.Node{ID: testFile + "::OwnerControllerTests.processFindFormSuccess", Kind: graph.KindMethod, Name: "processFindFormSuccess", FilePath: testFile, Language: "java", Meta: map[string]any{"receiver": "OwnerControllerTests", "scope_pkg": pkg}})
|
||||
|
||||
// `this.owners.findByLastNameStartingWith(...)` — receiver_type stamped by
|
||||
// the extractor (WS-A #1).
|
||||
edge := &graph.Edge{From: testFile + "::OwnerControllerTests.processFindFormSuccess", To: "unresolved::*.findByLastNameStartingWith", Kind: graph.EdgeCalls, FilePath: testFile, Line: 94, Meta: map[string]any{"receiver_type": "OwnerRepository"}}
|
||||
g.AddEdge(edge)
|
||||
|
||||
r := New(g)
|
||||
r.ResolveAll()
|
||||
|
||||
assert.Equal(t, mainFile+"::findByLastNameStartingWith", edge.To,
|
||||
"same-package test→main interface method call must resolve and survive the cross-package guard")
|
||||
}
|
||||
|
||||
// An inherited-method call — receiver typed as an in-repo class, callee
|
||||
// declared two packages up as the sole in-repo definition of the name — must
|
||||
// resolve via the lone-definition locality pick and not be reverted, even
|
||||
// though the declaring package is never imported by name.
|
||||
func TestResolveMethodCall_JavaInheritedLoneDefinition(t *testing.T) {
|
||||
g := graph.New()
|
||||
modelFile := "src/main/java/org/example/model/BaseEntity.java"
|
||||
ownerFile := "src/main/java/org/example/owner/OwnerController.java"
|
||||
g.AddNode(&graph.Node{ID: modelFile, Kind: graph.KindFile, Name: "BaseEntity.java", FilePath: modelFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: ownerFile, Kind: graph.KindFile, Name: "OwnerController.java", FilePath: ownerFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: modelFile + "::BaseEntity", Kind: graph.KindType, Name: "BaseEntity", FilePath: modelFile, Language: "java", Meta: map[string]any{"scope_pkg": "org.example.model"}})
|
||||
g.AddNode(&graph.Node{ID: modelFile + "::BaseEntity.getId", Kind: graph.KindMethod, Name: "getId", FilePath: modelFile, Language: "java", Meta: map[string]any{"receiver": "BaseEntity", "scope_pkg": "org.example.model"}})
|
||||
// The receiver's concrete type is in-repo (gate for the lone-defn keep).
|
||||
g.AddNode(&graph.Node{ID: ownerFile + "::Owner", Kind: graph.KindType, Name: "Owner", FilePath: ownerFile, Language: "java", Meta: map[string]any{"scope_pkg": "org.example.owner"}})
|
||||
g.AddNode(&graph.Node{ID: ownerFile + "::OwnerController.process", Kind: graph.KindMethod, Name: "process", FilePath: ownerFile, Language: "java", Meta: map[string]any{"receiver": "OwnerController", "scope_pkg": "org.example.owner"}})
|
||||
|
||||
edge := &graph.Edge{From: ownerFile + "::OwnerController.process", To: "unresolved::*.getId", Kind: graph.EdgeCalls, FilePath: ownerFile, Line: 20, Meta: map[string]any{"receiver_type": "Owner"}}
|
||||
g.AddEdge(edge)
|
||||
|
||||
r := New(g)
|
||||
r.ResolveAll()
|
||||
|
||||
assert.Equal(t, modelFile+"::BaseEntity.getId", edge.To,
|
||||
"call to the sole in-repo definition of getId must resolve and survive the cross-package guard")
|
||||
assert.Equal(t, graph.OriginASTInferred, edge.Origin, "lone-candidate Java pick must land at ast_inferred grade")
|
||||
}
|
||||
|
||||
// An external-typed receiver whose method name happens to collide with an
|
||||
// unrelated in-repo method must NOT latch onto it — the guard's lone-defn
|
||||
// exception is gated on the receiver naming an in-repo type.
|
||||
func TestResolveMethodCall_JavaExternalReceiverStillReverts(t *testing.T) {
|
||||
g := graph.New()
|
||||
aFile := "src/main/java/org/example/a/Service.java"
|
||||
bFile := "src/main/java/org/example/b/Widget.java"
|
||||
g.AddNode(&graph.Node{ID: aFile, Kind: graph.KindFile, Name: "Service.java", FilePath: aFile, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: bFile, Kind: graph.KindFile, Name: "Widget.java", FilePath: bFile, Language: "java"})
|
||||
// Unrelated in-repo `info` method in a different package.
|
||||
g.AddNode(&graph.Node{ID: bFile + "::Widget.info", Kind: graph.KindMethod, Name: "info", FilePath: bFile, Language: "java", Meta: map[string]any{"receiver": "Widget", "scope_pkg": "org.example.b"}})
|
||||
g.AddNode(&graph.Node{ID: aFile + "::Service.run", Kind: graph.KindMethod, Name: "run", FilePath: aFile, Language: "java", Meta: map[string]any{"receiver": "Service", "scope_pkg": "org.example.a"}})
|
||||
|
||||
// `logger.info(...)` — receiver_type Logger is an external facade, not an
|
||||
// in-repo type.
|
||||
edge := &graph.Edge{From: aFile + "::Service.run", To: "unresolved::*.info", Kind: graph.EdgeCalls, FilePath: aFile, Line: 5, Meta: map[string]any{"receiver_type": "Logger"}}
|
||||
g.AddEdge(edge)
|
||||
|
||||
r := New(g)
|
||||
r.ResolveAll()
|
||||
|
||||
assert.Equal(t, "unresolved::*.info", edge.To,
|
||||
"a call on an external-typed receiver must not latch onto an unrelated same-named in-repo method")
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// javaOverrideDispatchCap bounds how many overrides a single ambiguous call
|
||||
// may fan out to. A name shared by more definitions than this is too generic
|
||||
// to attribute confidently, so the call is left ambiguous rather than sprayed
|
||||
// across the graph.
|
||||
const javaOverrideDispatchCap = 8
|
||||
|
||||
// resolveJavaOverrideDispatch fans out an ambiguous Java member call whose
|
||||
// same-name candidates are overrides related through the class hierarchy into
|
||||
// one call edge per override — the call-hierarchy semantics jdtls and gopls
|
||||
// present, where a call on a supertype-typed receiver is a usage of every
|
||||
// override in that hierarchy. Without this, a `x.toString()` site whose static
|
||||
// type is a base class stays unresolved (two candidate overrides, no exact
|
||||
// type match) and reports as a usage of neither override.
|
||||
//
|
||||
// Because the picked target set is a best guess over legal runtime targets that
|
||||
// no receiver type disambiguated, the edges land at the speculative tier
|
||||
// (OriginSpeculative + Meta["speculative"]): hidden from default find_usages so
|
||||
// they never inflate a code symbol's usage set, surfaced on demand with
|
||||
// include_speculative and via analyze kind=speculative, and marked
|
||||
// Meta["dispatch"]="override". Resolving the primary out of the
|
||||
// `unresolved::*` state also clears the ambiguous_multi_match classification.
|
||||
// Scoped to Java so Go/TS/Python dispatch presentation is unchanged.
|
||||
func (r *Resolver) resolveJavaOverrideDispatch() int {
|
||||
g := r.graph
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
ancestors := javaTypeAncestors(g)
|
||||
if len(ancestors) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type fanout struct {
|
||||
edge *graph.Edge
|
||||
base *graph.Node
|
||||
others []*graph.Node
|
||||
}
|
||||
var jobs []fanout
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.IsSpeculative() {
|
||||
continue
|
||||
}
|
||||
// Scoped warm pass: an unchanged repo's calls were already dispatched (or
|
||||
// left ambiguous) by a prior full pass over the same hierarchy, so only
|
||||
// reconsider the changed repos' calls.
|
||||
if !r.edgeFromInScope(e.From) {
|
||||
continue
|
||||
}
|
||||
name := javaUnresolvedMemberName(e.To)
|
||||
if name == "" || strings.HasSuffix(name, ".<init>") {
|
||||
continue
|
||||
}
|
||||
caller := r.cachedGetNode(e.From)
|
||||
if caller == nil || caller.Language != "java" {
|
||||
continue
|
||||
}
|
||||
cands := javaOverrideCandidates(r.cachedFindNodesByNameInRepo(name, r.callerRepoPrefix(e)))
|
||||
if len(cands) < 2 || len(cands) > javaOverrideDispatchCap {
|
||||
continue
|
||||
}
|
||||
if !javaOverridesRelated(cands, ancestors) {
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, fanout{edge: e, base: cands[0], others: cands[1:]})
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, j := range jobs {
|
||||
oldTo := j.edge.To
|
||||
j.edge.To = j.base.ID
|
||||
j.edge.Origin = graph.OriginSpeculative
|
||||
j.edge.Confidence = 0.3
|
||||
if j.edge.Meta == nil {
|
||||
j.edge.Meta = map[string]any{}
|
||||
}
|
||||
j.edge.Meta[graph.MetaSpeculative] = true
|
||||
j.edge.Meta["dispatch"] = "override"
|
||||
g.ReindexEdges([]graph.EdgeReindex{{Edge: j.edge, OldTo: oldTo}})
|
||||
n++
|
||||
for _, o := range j.others {
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: j.edge.From, To: o.ID, Kind: graph.EdgeCalls,
|
||||
FilePath: j.edge.FilePath, Line: j.edge.Line,
|
||||
Origin: graph.OriginSpeculative,
|
||||
Confidence: 0.3,
|
||||
Meta: map[string]any{graph.MetaSpeculative: true, "dispatch": "override"},
|
||||
})
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// javaUnresolvedMemberName returns the method name of an `unresolved::*.<name>`
|
||||
// member-call target, or "" for any other target shape.
|
||||
func javaUnresolvedMemberName(to string) string {
|
||||
name := graph.UnresolvedName(to)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
rest, ok := strings.CutPrefix(name, "*.")
|
||||
if !ok || strings.Contains(rest, "::") {
|
||||
return ""
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// javaOverrideCandidates filters name-matched nodes to the in-repo Java method
|
||||
// definitions, one per declaring type (deduped by receiver), excluding stubs
|
||||
// and definitions with no declaring type.
|
||||
func javaOverrideCandidates(raw []*graph.Node) []*graph.Node {
|
||||
var out []*graph.Node
|
||||
seen := map[string]bool{}
|
||||
for _, n := range raw {
|
||||
if n == nil || n.Language != "java" || n.Kind != graph.KindMethod {
|
||||
continue
|
||||
}
|
||||
if graph.IsStub(n.ID) || graph.IsUnresolvedTarget(n.ID) {
|
||||
continue
|
||||
}
|
||||
recv := nodeReceiverType(n)
|
||||
if recv == "" || seen[recv] {
|
||||
continue
|
||||
}
|
||||
seen[recv] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// javaOverridesRelated reports whether the candidate methods are overrides of a
|
||||
// common supertype: their declaring types share at least one common ancestor
|
||||
// (or one is an ancestor of another). This is the precision gate — same-name
|
||||
// methods on unrelated types (no shared ancestor) are never sprayed together,
|
||||
// while genuine overrides of a common base (two entities overriding
|
||||
// BaseEntity's toString) fan out to every override the way a language server's
|
||||
// call hierarchy attributes them.
|
||||
func javaOverridesRelated(cands []*graph.Node, ancestors map[string]map[string]bool) bool {
|
||||
var common map[string]bool
|
||||
for i, c := range cands {
|
||||
rc := nodeReceiverType(c)
|
||||
if rc == "" {
|
||||
return false
|
||||
}
|
||||
// Ancestor-or-self set of this candidate's declaring type.
|
||||
set := map[string]bool{rc: true}
|
||||
for a := range ancestors[rc] {
|
||||
set[a] = true
|
||||
}
|
||||
if i == 0 {
|
||||
common = set
|
||||
continue
|
||||
}
|
||||
for k := range common {
|
||||
if !set[k] {
|
||||
delete(common, k)
|
||||
}
|
||||
}
|
||||
if len(common) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(common) > 0
|
||||
}
|
||||
|
||||
// javaBaseTypeName reduces a possibly package-qualified, generic type reference
|
||||
// to its simple class name (`model.Person<X>` → `Person`).
|
||||
func javaBaseTypeName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '<'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.LastIndexByte(s, '.'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// javaTypeAncestors builds, for each Java type simple name, the transitive set
|
||||
// of its superclass simple names. The direct superclass is read from each type
|
||||
// node's scope_parent meta — the same source the scope resolver's super-method
|
||||
// walk uses — because regular Java `extends` is recorded there, not as a graph
|
||||
// EdgeExtends (only anonymous classes emit that). So a cross-package
|
||||
// inheritance chain (`owner.Owner extends model.Person extends model.BaseEntity`)
|
||||
// contributes to the hierarchy even though its supertype references never
|
||||
// resolved to a type node. Empty when the graph indexes no Java hierarchy.
|
||||
func javaTypeAncestors(g graph.Store) map[string]map[string]bool {
|
||||
direct := map[string]map[string]bool{}
|
||||
add := func(childName, parentName string) {
|
||||
if childName == "" || parentName == "" || childName == parentName {
|
||||
return
|
||||
}
|
||||
set := direct[childName]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
direct[childName] = set
|
||||
}
|
||||
set[parentName] = true
|
||||
}
|
||||
for _, kind := range []graph.NodeKind{graph.KindType, graph.KindInterface} {
|
||||
for n := range g.NodesByKind(kind) {
|
||||
if n == nil || n.Language != "java" || n.Name == "" || n.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if p, ok := n.Meta[MetaScopeParentClass].(string); ok {
|
||||
add(n.Name, javaBaseTypeName(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(direct) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Transitive closure via DFS from each type.
|
||||
closure := make(map[string]map[string]bool, len(direct))
|
||||
var visit func(t string, acc map[string]bool, seen map[string]bool)
|
||||
visit = func(t string, acc, seen map[string]bool) {
|
||||
for p := range direct[t] {
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
acc[p] = true
|
||||
visit(p, acc, seen)
|
||||
}
|
||||
}
|
||||
for t := range direct {
|
||||
acc := map[string]bool{}
|
||||
visit(t, acc, map[string]bool{t: true})
|
||||
closure[t] = acc
|
||||
}
|
||||
return closure
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// An ambiguous member call whose same-name candidates are overrides sharing a
|
||||
// common supertype fans out to every override at the speculative tier, matching
|
||||
// the language server's call-hierarchy semantics. Unrelated same-name methods
|
||||
// are left ambiguous.
|
||||
func TestResolveJavaOverrideDispatch(t *testing.T) {
|
||||
g := graph.New()
|
||||
baseF := "src/main/java/org/example/model/NamedEntity.java"
|
||||
ownerF := "src/main/java/org/example/owner/Owner.java"
|
||||
sysF := "src/main/java/org/example/system/PropertiesLogger.java"
|
||||
widgetF := "src/main/java/org/example/ui/Widget.java"
|
||||
|
||||
for _, f := range []string{baseF, ownerF, sysF, widgetF} {
|
||||
g.AddNode(&graph.Node{ID: f, Kind: graph.KindFile, Name: f, FilePath: f, Language: "java"})
|
||||
}
|
||||
// Hierarchy recorded via scope_parent (how the Java extractor records a
|
||||
// superclass — regular `extends` is not a graph EdgeExtends): both Owner
|
||||
// and NamedEntity extend BaseEntity, so they share it as a common ancestor.
|
||||
g.AddNode(&graph.Node{ID: baseF + "::NamedEntity", Kind: graph.KindType, Name: "NamedEntity", FilePath: baseF, Language: "java", Meta: map[string]any{"scope_parent": "BaseEntity"}})
|
||||
g.AddNode(&graph.Node{ID: ownerF + "::Owner", Kind: graph.KindType, Name: "Owner", FilePath: ownerF, Language: "java", Meta: map[string]any{"scope_parent": "Person"}})
|
||||
g.AddNode(&graph.Node{ID: ownerF + "::Person", Kind: graph.KindType, Name: "Person", FilePath: ownerF, Language: "java", Meta: map[string]any{"scope_parent": "BaseEntity"}})
|
||||
// Both override toString.
|
||||
g.AddNode(&graph.Node{ID: baseF + "::NamedEntity.toString", Kind: graph.KindMethod, Name: "toString", FilePath: baseF, Language: "java", Meta: map[string]any{"receiver": "NamedEntity"}})
|
||||
g.AddNode(&graph.Node{ID: ownerF + "::Owner.toString", Kind: graph.KindMethod, Name: "toString", FilePath: ownerF, Language: "java", Meta: map[string]any{"receiver": "Owner"}})
|
||||
// An unrelated Widget.render in a separate hierarchy — must NOT join.
|
||||
g.AddNode(&graph.Node{ID: widgetF + "::Widget", Kind: graph.KindType, Name: "Widget", FilePath: widgetF, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: widgetF + "::Widget.render", Kind: graph.KindMethod, Name: "render", FilePath: widgetF, Language: "java", Meta: map[string]any{"receiver": "Widget"}})
|
||||
|
||||
caller := sysF + "::PropertiesLogger.printProperties"
|
||||
g.AddNode(&graph.Node{ID: caller, Kind: graph.KindMethod, Name: "printProperties", FilePath: sysF, Language: "java", Meta: map[string]any{"receiver": "PropertiesLogger"}})
|
||||
// sourceProperty.toString() at two call sites, receiver type unknown.
|
||||
g.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.toString", Kind: graph.EdgeCalls, FilePath: sysF, Line: 125})
|
||||
g.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.toString", Kind: graph.EdgeCalls, FilePath: sysF, Line: 127})
|
||||
// A lone-candidate call that must NOT fan out (only render exists).
|
||||
g.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.render", Kind: graph.EdgeCalls, FilePath: sysF, Line: 130})
|
||||
|
||||
r := New(g)
|
||||
r.ResolveAll()
|
||||
|
||||
// Every override receives the call at both sites, at the speculative
|
||||
// override tier (present in the graph, gated out of default responses).
|
||||
for _, target := range []string{baseF + "::NamedEntity.toString", ownerF + "::Owner.toString"} {
|
||||
got := map[int]bool{}
|
||||
for _, in := range g.GetInEdges(target) {
|
||||
if in.Kind == graph.EdgeCalls && in.From == caller {
|
||||
assert.Equal(t, "override", in.Meta["dispatch"], "fan-out edge must be marked dispatch=override")
|
||||
assert.True(t, in.IsSpeculative(), "override fan-out edge must land at the speculative tier")
|
||||
got[in.Line] = true
|
||||
}
|
||||
}
|
||||
assert.True(t, got[125], "toString override %s must receive call site 125", target)
|
||||
assert.True(t, got[127], "toString override %s must receive call site 127", target)
|
||||
}
|
||||
|
||||
// No `unresolved::*.toString` edge remains — the ambiguity is resolved.
|
||||
for _, e := range g.GetOutEdges(caller) {
|
||||
assert.NotEqual(t, "unresolved::*.toString", e.To, "no toString call should remain ambiguous after fan-out")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// A selector call `testee.triggerException()` whose method name collides with a
|
||||
// method in the caller's OWN class must bind to the receiver's type, not the
|
||||
// same-named enclosing-class method — even when the receiver's type lives in a
|
||||
// sibling Maven directory the import-reachability filter would otherwise drop.
|
||||
func TestResolveMethodCall_ReceiverTypeBeatsEnclosingClass(t *testing.T) {
|
||||
g := graph.New()
|
||||
mainF := "src/main/java/org/example/system/CrashController.java"
|
||||
testF := "src/test/java/org/example/system/CrashControllerTests.java"
|
||||
g.AddNode(&graph.Node{ID: mainF, Kind: graph.KindFile, Name: "CrashController.java", FilePath: mainF, Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: testF, Kind: graph.KindFile, Name: "CrashControllerTests.java", FilePath: testF, Language: "java"})
|
||||
// The production method the receiver-typed call should bind to.
|
||||
g.AddNode(&graph.Node{ID: mainF + "::CrashController.triggerException", Kind: graph.KindMethod, Name: "triggerException", FilePath: mainF, Language: "java", Meta: map[string]any{"receiver": "CrashController", "scope_class": "CrashController"}})
|
||||
// A same-named method in the caller's own class — the wrong target.
|
||||
g.AddNode(&graph.Node{ID: testF + "::CrashControllerTests.triggerException", Kind: graph.KindMethod, Name: "triggerException", FilePath: testF, Language: "java", Meta: map[string]any{"receiver": "CrashControllerTests", "scope_class": "CrashControllerTests"}})
|
||||
|
||||
// `testee.triggerException()` from the test method of the same name.
|
||||
edge := &graph.Edge{From: testF + "::CrashControllerTests.triggerException", To: "unresolved::*.triggerException", Kind: graph.EdgeCalls, FilePath: testF, Line: 37, Meta: map[string]any{"receiver_type": "CrashController"}}
|
||||
g.AddEdge(edge)
|
||||
|
||||
New(g).ResolveAll()
|
||||
|
||||
assert.Equal(t, mainF+"::CrashController.triggerException", edge.To,
|
||||
"a receiver-typed selector call must bind to the receiver's type, not a same-named method in the caller's own class")
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// PathAliasResolver expands a JS/TS path-alias import specifier — one
|
||||
// declared in the importing file's nearest-ancestor tsconfig.json /
|
||||
// jsconfig.json `compilerOptions.paths` (e.g. `@/lib/auth`) or resolved
|
||||
// against `baseUrl` — to the repo-prefixed, extension-stripped file stem
|
||||
// it targets (`src/lib/auth`). It returns "" when the specifier is not an
|
||||
// alias / baseUrl import for that file (a relative path or a genuine
|
||||
// third-party package).
|
||||
//
|
||||
// Like NpmAliasResolver it is defined in the resolver package so the
|
||||
// resolver carries no compile-time dependency on the indexer or the
|
||||
// filesystem: the indexer constructs a concrete implementation (which
|
||||
// reads tsconfig from disk via the tsalias package) and injects it via
|
||||
// SetPathAliasResolver.
|
||||
type PathAliasResolver func(callerFile, specifier string) string
|
||||
|
||||
// SetPathAliasResolver installs a tsconfig path-alias expander. Pass nil
|
||||
// to detach. Must be called before ResolveAll / ResolveFile — the
|
||||
// resolver caches no alias state across passes.
|
||||
func (r *Resolver) SetPathAliasResolver(fn PathAliasResolver) { r.pathAlias = fn }
|
||||
|
||||
// SetPathAliasResolver installs a tsconfig path-alias expander on the
|
||||
// cross-repo resolver. Same contract as the Resolver method.
|
||||
func (cr *CrossRepoResolver) SetPathAliasResolver(fn PathAliasResolver) { cr.pathAlias = fn }
|
||||
|
||||
// jsTSImportExts are the source extensions a resolved JS/TS module
|
||||
// specifier may carry on disk, probed in order. The stem (no extension)
|
||||
// is also probed as a directory whose entry point is `index.<ext>`.
|
||||
var jsTSImportExts = []string{
|
||||
".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs",
|
||||
}
|
||||
|
||||
// resolveJSTSImportTarget resolves a JS/TS EdgeImports / EdgeReExports
|
||||
// specifier onto the in-repo file (or exported symbol) it names, or
|
||||
// returns "" when the caller is not JS/TS, the specifier is a genuine
|
||||
// third-party package, or no indexed file matches. importPath is the raw
|
||||
// `import::` payload — the module specifier for a module-level edge, or
|
||||
// `<specifier>::<export>` for a per-binding edge. getNode is the resolver's
|
||||
// node lookup (cachedGetNode); pathAlias is the injected tsconfig expander
|
||||
// (may be nil).
|
||||
//
|
||||
// Two specifier shapes resolve:
|
||||
//
|
||||
// - Relative (`./auth`, `../lib/auth`) — joined against the importing
|
||||
// file's directory.
|
||||
// - tsconfig / jsconfig path alias or baseUrl import (`@/lib/auth`) —
|
||||
// expanded via the injected PathAliasResolver.
|
||||
//
|
||||
// The expanded stem is probed against the indexed file nodes
|
||||
// (`stem.<ext>`, `stem/index.<ext>`); the first match wins. A module-level
|
||||
// edge resolves to the file node; a per-binding edge resolves to that
|
||||
// file's exported-symbol node when it exists, else the file node.
|
||||
//
|
||||
// Running this inside resolveImport (rather than as a post-pass) means the
|
||||
// cold ResolveAll worker phase AND every incremental ResolveFile path land
|
||||
// the import edge before buildImportClosure reads it — so a cross-directory
|
||||
// JS/TS import contributes real reachability and the cross-package guard
|
||||
// stops reverting its callers to `unresolved::*` (issue #136). Shared by
|
||||
// Resolver.resolveImport and CrossRepoResolver.resolveImport.
|
||||
func resolveJSTSImportTarget(getNode func(string) *graph.Node, pathAlias PathAliasResolver, callerFile, importPath string) string {
|
||||
if !isJSTSPath(callerFile) {
|
||||
return ""
|
||||
}
|
||||
spec, symbol := splitImportSpecSymbol(importPath)
|
||||
stem := expandJSTSSpecifier(pathAlias, callerFile, spec)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
file := probeJSTSFile(getNode, stem)
|
||||
if file == "" {
|
||||
return ""
|
||||
}
|
||||
if symbol != "" {
|
||||
if symID := file + "::" + symbol; getNode(symID) != nil {
|
||||
return symID
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// jsTSImportCallerFile returns the importing file's graph path for an
|
||||
// import edge, preferring the explicit FilePath and falling back to the
|
||||
// From end (a file node ID is the file path).
|
||||
func jsTSImportCallerFile(e *graph.Edge) string {
|
||||
if e.FilePath != "" {
|
||||
return e.FilePath
|
||||
}
|
||||
return e.From
|
||||
}
|
||||
|
||||
// expandJSTSSpecifier turns a JS/TS module specifier into a repo-prefixed,
|
||||
// extension-stripped file stem, or "" when it is a genuine third-party
|
||||
// package. Relative specifiers are joined against the importing file's
|
||||
// directory; everything else is handed to the injected PathAliasResolver
|
||||
// (tsconfig `paths` / `baseUrl`).
|
||||
func expandJSTSSpecifier(pathAlias PathAliasResolver, callerFile, spec string) string {
|
||||
if spec == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(spec, "./") || strings.HasPrefix(spec, "../") {
|
||||
dir := ""
|
||||
if i := strings.LastIndex(callerFile, "/"); i >= 0 {
|
||||
dir = callerFile[:i]
|
||||
}
|
||||
return joinRelativePath(dir, spec)
|
||||
}
|
||||
if pathAlias != nil {
|
||||
return pathAlias(callerFile, spec)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeJSTSFile returns the indexed KindFile node ID an extension-stripped
|
||||
// stem resolves to — trying an explicit author-written extension first,
|
||||
// then each source extension, then a directory `index.<ext>` barrel — or
|
||||
// "" when no indexed file matches.
|
||||
func probeJSTSFile(getNode func(string) *graph.Node, stem string) string {
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
isFile := func(id string) bool {
|
||||
n := getNode(id)
|
||||
return n != nil && n.Kind == graph.KindFile
|
||||
}
|
||||
// An explicit source extension the author wrote (`./auth.js`) is tried
|
||||
// verbatim first.
|
||||
switch jsTSExt(stem) {
|
||||
case ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs":
|
||||
if isFile(stem) {
|
||||
return stem
|
||||
}
|
||||
}
|
||||
for _, ext := range jsTSImportExts {
|
||||
if id := stem + ext; isFile(id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
for _, ext := range jsTSImportExts {
|
||||
if id := stem + "/index" + ext; isFile(id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitImportSpecSymbol splits an `import::` edge payload into the module
|
||||
// specifier and the per-binding export name. The per-binding edge the JS
|
||||
// extractor emits is `<specifier>::<exportName>`; a module-level edge is
|
||||
// just `<specifier>`. A module specifier never contains `::`, so the final
|
||||
// `::` (when present) is the separator.
|
||||
//
|
||||
// "./auth" → ("./auth", "")
|
||||
// "./auth::getAuth" → ("./auth", "getAuth")
|
||||
// "@/lib/auth::Foo" → ("@/lib/auth", "Foo")
|
||||
func splitImportSpecSymbol(importPath string) (spec, symbol string) {
|
||||
if i := strings.LastIndex(importPath, "::"); i >= 0 {
|
||||
return importPath[:i], importPath[i+2:]
|
||||
}
|
||||
return importPath, ""
|
||||
}
|
||||
|
||||
// jsTSExt returns the lowercase file extension of p, or "" when none.
|
||||
func jsTSExt(p string) string {
|
||||
dot := strings.LastIndexByte(p, '.')
|
||||
if dot < 0 {
|
||||
return ""
|
||||
}
|
||||
if slash := strings.LastIndexByte(p, '/'); slash > dot {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(p[dot:])
|
||||
}
|
||||
|
||||
// isJSTSPath reports whether a file path is a JavaScript / TypeScript
|
||||
// source file — the only importers whose specifiers this resolver expands.
|
||||
func isJSTSPath(p string) bool {
|
||||
switch jsTSExt(p) {
|
||||
case ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// kmpExpectActualVia marks a synthesized Kotlin Multiplatform expect↔actual
|
||||
// pairing edge.
|
||||
const kmpExpectActualVia = "kmp.expect-actual"
|
||||
|
||||
// ResolveKMPExpectActual is the framework-dispatch synthesizer for Kotlin
|
||||
// Multiplatform expect/actual declarations. An `expect` declaration in common
|
||||
// code is fulfilled by one `actual` per platform; the link is implicit — the
|
||||
// compiler matches them by signature — so the static graph leaves the expect
|
||||
// declaration looking unreferenced and the per-platform actuals looking
|
||||
// orphaned. The Kotlin extractor stamps Meta["kmp_role"]="expect"|"actual"; this
|
||||
// pass pairs declarations of the same name and kind and synthesizes an
|
||||
// implements edge from each actual to its expect (so find_implementations on
|
||||
// the expect returns every platform actual) plus a references edge back for
|
||||
// navigation.
|
||||
//
|
||||
// One expect fans out to several actuals (android / ios / jvm) by design. Full
|
||||
// recompute and idempotent; edges ride at ast_inferred with synthesizer
|
||||
// provenance. Returns the number of expect↔actual pairs linked.
|
||||
func ResolveKMPExpectActual(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
type key struct {
|
||||
name string
|
||||
kind graph.NodeKind
|
||||
}
|
||||
expects := map[key][]*graph.Node{}
|
||||
actuals := map[key][]*graph.Node{}
|
||||
for _, n := range g.AllNodes() {
|
||||
if n == nil || n.Meta == nil || n.Name == "" {
|
||||
continue
|
||||
}
|
||||
k := key{n.Name, n.Kind}
|
||||
switch role, _ := n.Meta["kmp_role"].(string); role {
|
||||
case "expect":
|
||||
expects[k] = append(expects[k], n)
|
||||
case "actual":
|
||||
actuals[k] = append(actuals[k], n)
|
||||
}
|
||||
}
|
||||
if len(expects) == 0 || len(actuals) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
keys := make([]key, 0, len(expects))
|
||||
for k := range expects {
|
||||
if len(actuals[k]) > 0 {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].name != keys[j].name {
|
||||
return keys[i].name < keys[j].name
|
||||
}
|
||||
return keys[i].kind < keys[j].kind
|
||||
})
|
||||
|
||||
var batch []*graph.Edge
|
||||
paired := 0
|
||||
for _, k := range keys {
|
||||
for _, exp := range expects[k] {
|
||||
for _, act := range actuals[k] {
|
||||
if exp.ID == act.ID {
|
||||
continue
|
||||
}
|
||||
batch = append(batch,
|
||||
kmpEdge(act, exp.ID, graph.EdgeImplements),
|
||||
kmpEdge(exp, act.ID, graph.EdgeReferences),
|
||||
)
|
||||
paired++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return paired
|
||||
}
|
||||
|
||||
// kmpEdge builds one direction of an expect↔actual pairing.
|
||||
func kmpEdge(from *graph.Node, toID string, kind graph.EdgeKind) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: from.ID,
|
||||
To: toID,
|
||||
Kind: kind,
|
||||
FilePath: from.FilePath,
|
||||
Line: from.StartLine,
|
||||
Confidence: 0.8,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(kind, 0.8),
|
||||
Origin: graph.OriginASTInferred,
|
||||
Meta: map[string]any{
|
||||
"via": kmpExpectActualVia,
|
||||
MetaSynthesizedBy: SynthKMPExpectActual,
|
||||
MetaProvenance: ProvenanceHeuristic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func kmpEdgeBetween(g graph.Store, from, to string, kind graph.EdgeKind) *graph.Edge {
|
||||
for _, e := range g.GetOutEdges(from) {
|
||||
if e.To == to && e.Kind == kind && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == kmpExpectActualVia {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kmpNode(g graph.Store, id, name, file, role string, kind graph.NodeKind) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: kind, Name: name, FilePath: file, StartLine: 2, Language: "kotlin", Meta: map[string]any{"kmp_role": role}})
|
||||
}
|
||||
|
||||
func TestResolveKMPExpectActual_PairsExpectToActual(t *testing.T) {
|
||||
g := graph.New()
|
||||
kmpNode(g, "common.kt::platformName", "platformName", "common.kt", "expect", graph.KindFunction)
|
||||
kmpNode(g, "android.kt::platformName", "platformName", "android.kt", "actual", graph.KindFunction)
|
||||
kmpNode(g, "ios.kt::platformName", "platformName", "ios.kt", "actual", graph.KindFunction)
|
||||
kmpNode(g, "common.kt::Platform", "Platform", "common.kt", "expect", graph.KindType)
|
||||
kmpNode(g, "android.kt::Platform", "Platform", "android.kt", "actual", graph.KindType)
|
||||
|
||||
// 2 actuals for platformName + 1 for Platform = 3 pairs.
|
||||
assert.Equal(t, 3, ResolveKMPExpectActual(g))
|
||||
|
||||
// Each actual implements the expect (find_implementations on the expect).
|
||||
require.NotNil(t, kmpEdgeBetween(g, "android.kt::platformName", "common.kt::platformName", graph.EdgeImplements), "android actual → expect")
|
||||
require.NotNil(t, kmpEdgeBetween(g, "ios.kt::platformName", "common.kt::platformName", graph.EdgeImplements), "ios actual → expect")
|
||||
// Reverse navigation edge.
|
||||
require.NotNil(t, kmpEdgeBetween(g, "common.kt::platformName", "android.kt::platformName", graph.EdgeReferences), "expect → actual")
|
||||
// Type pairing.
|
||||
require.NotNil(t, kmpEdgeBetween(g, "android.kt::Platform", "common.kt::Platform", graph.EdgeImplements), "actual type → expect type")
|
||||
|
||||
e := kmpEdgeBetween(g, "android.kt::platformName", "common.kt::platformName", graph.EdgeImplements)
|
||||
assert.Equal(t, SynthKMPExpectActual, e.Meta[MetaSynthesizedBy])
|
||||
}
|
||||
|
||||
func TestResolveKMPExpectActual_ExpectWithoutActualNoEdge(t *testing.T) {
|
||||
g := graph.New()
|
||||
kmpNode(g, "common.kt::lonely", "lonely", "common.kt", "expect", graph.KindFunction)
|
||||
assert.Equal(t, 0, ResolveKMPExpectActual(g))
|
||||
}
|
||||
|
||||
func TestResolveKMPExpectActual_KindMismatchNoPair(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Same name but different kinds must not pair.
|
||||
kmpNode(g, "common.kt::Thing", "Thing", "common.kt", "expect", graph.KindType)
|
||||
kmpNode(g, "android.kt::Thing", "Thing", "android.kt", "actual", graph.KindFunction)
|
||||
assert.Equal(t, 0, ResolveKMPExpectActual(g))
|
||||
}
|
||||
|
||||
func TestResolveKMPExpectActual_Idempotent(t *testing.T) {
|
||||
g := graph.New()
|
||||
kmpNode(g, "common.kt::platformName", "platformName", "common.kt", "expect", graph.KindFunction)
|
||||
kmpNode(g, "android.kt::platformName", "platformName", "android.kt", "actual", graph.KindFunction)
|
||||
first := ResolveKMPExpectActual(g)
|
||||
second := ResolveKMPExpectActual(g)
|
||||
assert.Equal(t, first, second)
|
||||
|
||||
count := 0
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeImplements, graph.EdgeReferences} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e != nil && e.Meta != nil {
|
||||
if v, _ := e.Meta["via"].(string); v == kmpExpectActualVia {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// languageFamily maps a language to the family within which cross-language
|
||||
// symbol resolution is legitimate (a `@model Foo` / `<Counter/>` reference may
|
||||
// bind across languages of the same family). "" means the language belongs to
|
||||
// no multi-language family, so any cross-language bind for it is coincidental.
|
||||
func languageFamily(lang string) string {
|
||||
switch lang {
|
||||
case "java", "kotlin", "scala":
|
||||
return "jvm"
|
||||
case "swift", "objc", "objective-c", "objectivec":
|
||||
return "apple"
|
||||
case "typescript", "ts", "tsx", "javascript", "js", "jsx":
|
||||
return "web"
|
||||
case "c", "cpp", "c++", "cxx":
|
||||
return "c"
|
||||
case "csharp", "c#", "fsharp", "f#", "razor":
|
||||
return "dotnet"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sameLanguageFamily reports whether a and b are the same language or belong to
|
||||
// the same multi-language family (so a within-family cross-language bind is
|
||||
// permitted): csharp↔razor, ts↔tsx, java↔kotlin, swift↔objc.
|
||||
func sameLanguageFamily(a, b string) bool {
|
||||
if a == b {
|
||||
return a != ""
|
||||
}
|
||||
fa := languageFamily(a)
|
||||
return fa != "" && fa == languageFamily(b)
|
||||
}
|
||||
|
||||
// frameworkBridgeSynths are the synthesizers whose entire purpose is to bridge
|
||||
// two language families (JS→native, Swift→ObjC, KMP expect/actual). Their
|
||||
// edges are exempt from the cross-family gate.
|
||||
var frameworkBridgeSynths = map[string]bool{
|
||||
SynthSwiftObjC: true,
|
||||
SynthReactNative: true,
|
||||
SynthReactNativePair: true,
|
||||
SynthExpoModules: true,
|
||||
SynthFabric: true,
|
||||
SynthKMPExpectActual: true,
|
||||
}
|
||||
|
||||
// gateFrameworkResult reports whether a framework-synthesized reference/import
|
||||
// result should be dropped: it crosses two known, different language families
|
||||
// (a coincidental PascalCase collision) and was not produced by a bridge
|
||||
// synthesizer. An unknown family on either side, the same family, or a bridge
|
||||
// synthesizer all permit the result.
|
||||
func gateFrameworkResult(synth, fromLang, toLang string) bool {
|
||||
if frameworkBridgeSynths[synth] {
|
||||
return false
|
||||
}
|
||||
fa, fb := languageFamily(fromLang), languageFamily(toLang)
|
||||
if fa == "" || fb == "" {
|
||||
return false
|
||||
}
|
||||
return fa != fb
|
||||
}
|
||||
|
||||
// applyFrameworkFamilyGate drops framework-synthesized reference / import edges
|
||||
// that cross two known, different language families (e.g. a Razor reference
|
||||
// that coincidentally bound a TypeScript component), keeping bridge-synthesizer
|
||||
// edges and call/config edges. Returns the number of edges dropped.
|
||||
func applyFrameworkFamilyGate(g graph.Store) int {
|
||||
type cand struct {
|
||||
edge *graph.Edge
|
||||
synth string
|
||||
}
|
||||
var cands []cand
|
||||
endpointIDs := map[string]struct{}{}
|
||||
for _, kind := range []graph.EdgeKind{graph.EdgeReferences, graph.EdgeImports} {
|
||||
for e := range g.EdgesByKind(kind) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
synth, _ := e.Meta[MetaSynthesizedBy].(string)
|
||||
if synth == "" || frameworkBridgeSynths[synth] {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, cand{edge: e, synth: synth})
|
||||
endpointIDs[e.From] = struct{}{}
|
||||
endpointIDs[e.To] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return 0
|
||||
}
|
||||
ids := make([]string, 0, len(endpointIDs))
|
||||
for id := range endpointIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
nodes := g.GetNodesByIDs(ids)
|
||||
langOf := func(id string) string {
|
||||
if n := nodes[id]; n != nil {
|
||||
return n.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
dropped := 0
|
||||
for _, c := range cands {
|
||||
if gateFrameworkResult(c.synth, langOf(c.edge.From), langOf(c.edge.To)) {
|
||||
if g.RemoveEdge(c.edge.From, c.edge.To, c.edge.Kind) {
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestLanguageFamily(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"java": "jvm", "kotlin": "jvm", "scala": "jvm",
|
||||
"swift": "apple", "objc": "apple",
|
||||
"typescript": "web", "tsx": "web", "javascript": "web",
|
||||
"c": "c", "cpp": "c",
|
||||
"csharp": "dotnet", "fsharp": "dotnet", "razor": "dotnet",
|
||||
"go": "", "python": "", "rust": "", "": "",
|
||||
}
|
||||
for lang, fam := range cases {
|
||||
assert.Equal(t, fam, languageFamily(lang), "languageFamily(%q)", lang)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameLanguageFamily(t *testing.T) {
|
||||
assert.True(t, sameLanguageFamily("csharp", "razor"), "csharp↔razor are both dotnet")
|
||||
assert.True(t, sameLanguageFamily("typescript", "tsx"), "ts↔tsx are both web")
|
||||
assert.True(t, sameLanguageFamily("java", "kotlin"), "java↔kotlin are both jvm")
|
||||
assert.True(t, sameLanguageFamily("razor", "razor"), "same language")
|
||||
assert.True(t, sameLanguageFamily("go", "go"), "same language, even with no family")
|
||||
assert.False(t, sameLanguageFamily("razor", "typescript"), "dotnet vs web")
|
||||
assert.False(t, sameLanguageFamily("", ""), "empty language is no family")
|
||||
assert.False(t, sameLanguageFamily("go", "python"), "two familyless languages do not match")
|
||||
}
|
||||
|
||||
func TestGateFrameworkResult(t *testing.T) {
|
||||
// Drop: a non-bridge synth result crossing dotnet↔web.
|
||||
assert.True(t, gateFrameworkResult(SynthRustScope, "razor", "typescript"))
|
||||
// Exempt bridges: JS→native and Swift→ObjC are never gated.
|
||||
assert.False(t, gateFrameworkResult(SynthReactNative, "javascript", "swift"))
|
||||
assert.False(t, gateFrameworkResult(SynthSwiftObjC, "swift", "objc"))
|
||||
// Same family → permit.
|
||||
assert.False(t, gateFrameworkResult(SynthRustScope, "csharp", "razor"))
|
||||
// Unknown family on a side → permit.
|
||||
assert.False(t, gateFrameworkResult(SynthRustScope, "go", "typescript"))
|
||||
}
|
||||
|
||||
// TestApplyFrameworkFamilyGate_DropsCrossFamilyRef pins the post-filter dropping
|
||||
// a synthesized cross-family reference from a non-bridge synthesizer.
|
||||
func TestApplyFrameworkFamilyGate_DropsCrossFamilyRef(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.razor::Page", Kind: graph.KindType, Name: "Page", Language: "razor"})
|
||||
g.AddNode(&graph.Node{ID: "b.tsx::Counter", Kind: graph.KindType, Name: "Counter", Language: "typescript"})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "a.razor::Page", To: "b.tsx::Counter", Kind: graph.EdgeReferences,
|
||||
Meta: map[string]any{MetaSynthesizedBy: SynthRustScope},
|
||||
})
|
||||
|
||||
assert.Equal(t, 1, applyFrameworkFamilyGate(g))
|
||||
assert.False(t, g.RemoveEdge("a.razor::Page", "b.tsx::Counter", graph.EdgeReferences),
|
||||
"edge already removed by the gate")
|
||||
}
|
||||
|
||||
// TestApplyFrameworkFamilyGate_KeepsBridge pins that a bridge-synthesizer edge
|
||||
// is exempt even when it crosses families.
|
||||
func TestApplyFrameworkFamilyGate_KeepsBridge(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.js::foo", Kind: graph.KindFunction, Name: "foo", Language: "javascript"})
|
||||
g.AddNode(&graph.Node{ID: "b.swift::Bar", Kind: graph.KindType, Name: "Bar", Language: "swift"})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "a.js::foo", To: "b.swift::Bar", Kind: graph.EdgeReferences,
|
||||
Meta: map[string]any{MetaSynthesizedBy: SynthReactNative},
|
||||
})
|
||||
|
||||
assert.Equal(t, 0, applyFrameworkFamilyGate(g), "bridge synthesizer edge is exempt")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"iter"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// graphHasLanguage reports whether the backing store contains any node of
|
||||
// the given language. Cheap — a LIMIT-1 probe — on stores that implement
|
||||
// it (the on-disk backend); conservatively returns true on stores that don't, so a
|
||||
// language-gated pass still runs rather than being silently skipped. Lets
|
||||
// the Go / Python attribution passes skip a graph that has none of their
|
||||
// language instead of scanning + discarding the whole node/edge set.
|
||||
func (r *Resolver) graphHasLanguage(lang string) bool {
|
||||
if hl, ok := r.graph.(interface{ HasLanguage(string) bool }); ok {
|
||||
return hl.HasLanguage(lang)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nodesByKindLang yields nodes of the given kind AND language, pushed
|
||||
// server-side when the store supports it (so only the matching language's
|
||||
// nodes cross the cgo boundary), else NodesByKind + an in-Go language
|
||||
// filter (memory / overlay are already in-memory, so there is no marshal
|
||||
// cost to push down).
|
||||
func (r *Resolver) nodesByKindLang(kind graph.NodeKind, lang string) iter.Seq[*graph.Node] {
|
||||
if nl, ok := r.graph.(interface {
|
||||
NodesByKindLang(graph.NodeKind, string) iter.Seq[*graph.Node]
|
||||
}); ok {
|
||||
return nl.NodesByKindLang(kind, lang)
|
||||
}
|
||||
return func(yield func(*graph.Node) bool) {
|
||||
for n := range r.graph.NodesByKind(kind) {
|
||||
if n != nil && n.Language == lang {
|
||||
if !yield(n) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// laravelEventVia is the Meta["via"] tag the PHP extractor stamps on a
|
||||
// Laravel event-dispatch placeholder.
|
||||
const laravelEventVia = "laravel-event"
|
||||
|
||||
// ResolveLaravelEventCalls binds Laravel event dispatches to their
|
||||
// listeners' handle methods by event type, from two listener sources: a
|
||||
// typed `handle(OrderShipped $e)` under a Listeners namespace, and the
|
||||
// `$listen` map of an EventServiceProvider (encoded on the provider class
|
||||
// node). A dispatch fans out to every matching listener. Type-keyed, so
|
||||
// edges land at the typed framework tier.
|
||||
//
|
||||
// Returns the number of publisher → listener edges synthesized.
|
||||
func ResolveLaravelEventCalls(g graph.Store) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
// handle methods indexed by their owning class simple name.
|
||||
handleByClass := map[string][]*graph.Node{}
|
||||
listenersByType := map[string][]*graph.Node{}
|
||||
classByMethod := map[string]string{}
|
||||
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
||||
if e != nil && e.From != "" && e.To != "" {
|
||||
classByMethod[e.From] = laravelSimpleName(e.To)
|
||||
}
|
||||
}
|
||||
var listenMaps []string
|
||||
for _, n := range nodesByKindsOrAll(g, graph.KindMethod, graph.KindFunction, graph.KindType) {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if n.Kind == graph.KindType {
|
||||
if m, _ := n.Meta["laravel_listen_map"].(string); m != "" {
|
||||
listenMaps = append(listenMaps, m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n.Name == "handle" {
|
||||
handleByClass[classByMethod[n.ID]] = append(handleByClass[classByMethod[n.ID]], n)
|
||||
}
|
||||
if n.Meta != nil {
|
||||
if t, _ := n.Meta["laravel_listener_type"].(string); t != "" {
|
||||
listenersByType[laravelSimpleName(t)] = append(listenersByType[laravelSimpleName(t)], n)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Source 2: fold the $listen maps into listenersByType via handleByClass.
|
||||
for _, m := range listenMaps {
|
||||
for _, entry := range strings.Split(m, ";") {
|
||||
event, rest, ok := strings.Cut(entry, "=>")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
event = laravelSimpleName(strings.TrimSpace(event))
|
||||
for _, l := range strings.Split(rest, ",") {
|
||||
listenersByType[event] = append(listenersByType[event], handleByClass[laravelSimpleName(strings.TrimSpace(l))]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(listenersByType) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
resolved := 0
|
||||
var reindex []graph.EdgeReindex
|
||||
var batch []*graph.Edge
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := e.Meta["via"].(string); v != laravelEventVia {
|
||||
continue
|
||||
}
|
||||
evType, _ := e.Meta["laravel_event_type"].(string)
|
||||
if evType == "" {
|
||||
continue
|
||||
}
|
||||
listeners := laravelDedupSorted(listenersByType[laravelSimpleName(evType)])
|
||||
if len(listeners) == 0 {
|
||||
resolved += laravelRebind(e, nil, evType, &reindex)
|
||||
continue
|
||||
}
|
||||
resolved += laravelRebind(e, listeners[0], evType, &reindex)
|
||||
for _, l := range listeners[1:] {
|
||||
batch = append(batch, laravelFanoutEdge(e, l, evType))
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
if len(reindex) > 0 {
|
||||
g.ReindexEdges(reindex)
|
||||
}
|
||||
for _, ne := range batch {
|
||||
g.AddEdge(ne)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func laravelRebind(e *graph.Edge, target *graph.Node, evType string, reindex *[]graph.EdgeReindex) int {
|
||||
want := "unresolved::*.handle"
|
||||
if target != nil {
|
||||
want = target.ID
|
||||
}
|
||||
if e.To == want {
|
||||
if target != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = want
|
||||
hit := 0
|
||||
if target != nil {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = ConfidenceTyped
|
||||
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped)
|
||||
StampSynthesizedTyped(e, SynthLaravelEvent)
|
||||
hit = 1
|
||||
} else {
|
||||
e.Origin = graph.OriginASTInferred
|
||||
e.Confidence = 0
|
||||
e.ConfidenceLabel = ""
|
||||
UnstampSynthesized(e)
|
||||
}
|
||||
*reindex = append(*reindex, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
return hit
|
||||
}
|
||||
|
||||
func laravelFanoutEdge(e *graph.Edge, listener *graph.Node, evType string) *graph.Edge {
|
||||
return &graph.Edge{
|
||||
From: e.From, To: listener.ID, Kind: graph.EdgeCalls,
|
||||
FilePath: e.FilePath, Line: e.Line,
|
||||
Origin: graph.OriginASTInferred,
|
||||
Confidence: ConfidenceTyped,
|
||||
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped),
|
||||
Meta: map[string]any{
|
||||
"via": laravelEventVia,
|
||||
"laravel_event_type": evType,
|
||||
MetaSynthesizedBy: SynthLaravelEvent,
|
||||
MetaProvenance: ProvenanceFramework,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// laravelDedupSorted dedups listener nodes by ID and sorts for deterministic
|
||||
// fan-out.
|
||||
func laravelDedupSorted(in []*graph.Node) []*graph.Node {
|
||||
seen := map[string]bool{}
|
||||
out := make([]*graph.Node, 0, len(in))
|
||||
for _, n := range in {
|
||||
if n != nil && !seen[n.ID] {
|
||||
seen[n.ID] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// laravelSimpleName returns the last segment of a `\`- or `::`-qualified PHP
|
||||
// name (or a node ID's symbol part).
|
||||
func laravelSimpleName(s string) string {
|
||||
if i := strings.LastIndex(s, "::"); i >= 0 {
|
||||
s = s[i+2:]
|
||||
}
|
||||
if i := strings.LastIndexByte(s, '\\'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
if i := strings.LastIndexByte(s, '.'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func laravelHandle(g *graph.Graph, id, file, class, evType string) {
|
||||
meta := map[string]any{"receiver": class}
|
||||
if evType != "" {
|
||||
meta["laravel_listener_type"] = evType
|
||||
}
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: "handle", FilePath: file, Language: "php", Meta: meta})
|
||||
g.AddEdge(&graph.Edge{From: id, To: file + "::" + class, Kind: graph.EdgeMemberOf, FilePath: file})
|
||||
}
|
||||
|
||||
func laravelProvider(g *graph.Graph, id, file, listenMap string) {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindType, Name: "EventServiceProvider", FilePath: file, Language: "php",
|
||||
Meta: map[string]any{"laravel_listen_map": listenMap}})
|
||||
}
|
||||
|
||||
func laravelDispatch(g *graph.Graph, fromID, file, evType string) {
|
||||
if g.GetNode(fromID) == nil {
|
||||
g.AddNode(&graph.Node{ID: fromID, Kind: graph.KindMethod, Name: lastSeg(fromID), FilePath: file, Language: "php"})
|
||||
}
|
||||
g.AddEdge(&graph.Edge{From: fromID, To: "unresolved::*.handle", Kind: graph.EdgeCalls, FilePath: file,
|
||||
Meta: map[string]any{"via": laravelEventVia, "laravel_event_type": evType}})
|
||||
}
|
||||
|
||||
func synthLaravelEdge(g graph.Store, from, to string) *graph.Edge {
|
||||
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
||||
if e == nil || e.From != from || e.To != to || e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if by, _ := e.Meta[MetaSynthesizedBy].(string); by == SynthLaravelEvent {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveLaravelEventCalls_TypedHandleSource(t *testing.T) {
|
||||
g := graph.New()
|
||||
laravelHandle(g, "L.php::SendShipmentNotification.handle", "L.php", "SendShipmentNotification", "OrderShipped")
|
||||
laravelDispatch(g, "C.php::OrderController.ship", "C.php", "OrderShipped")
|
||||
|
||||
n := ResolveLaravelEventCalls(g)
|
||||
require.Equal(t, 1, n)
|
||||
e := synthLaravelEdge(g, "C.php::OrderController.ship", "L.php::SendShipmentNotification.handle")
|
||||
require.NotNil(t, e)
|
||||
assert.Equal(t, ConfidenceTyped, e.Confidence)
|
||||
assert.Equal(t, ProvenanceFramework, e.Meta[MetaProvenance])
|
||||
}
|
||||
|
||||
func TestResolveLaravelEventCalls_ListenMapSource(t *testing.T) {
|
||||
// A listener with an untyped handle, discovered only via the $listen map.
|
||||
g := graph.New()
|
||||
laravelHandle(g, "M.php::SendEmail.handle", "M.php", "SendEmail", "")
|
||||
laravelProvider(g, "P.php::EventServiceProvider", "P.php", "OrderShipped=>SendEmail")
|
||||
laravelDispatch(g, "C.php::Ctrl.ship", "C.php", "OrderShipped")
|
||||
|
||||
require.Equal(t, 1, ResolveLaravelEventCalls(g))
|
||||
assert.NotNil(t, synthLaravelEdge(g, "C.php::Ctrl.ship", "M.php::SendEmail.handle"),
|
||||
"the $listen map binds an untyped handle")
|
||||
}
|
||||
|
||||
func TestResolveLaravelEventCalls_BothSourcesFanOut(t *testing.T) {
|
||||
g := graph.New()
|
||||
laravelHandle(g, "L.php::TypedListener.handle", "L.php", "TypedListener", "OrderShipped")
|
||||
laravelHandle(g, "M.php::MappedListener.handle", "M.php", "MappedListener", "")
|
||||
laravelProvider(g, "P.php::EventServiceProvider", "P.php", "OrderShipped=>MappedListener")
|
||||
laravelDispatch(g, "C.php::Ctrl.ship", "C.php", "OrderShipped")
|
||||
|
||||
n := ResolveLaravelEventCalls(g)
|
||||
require.Equal(t, 2, n, "both discovery sources fan out")
|
||||
assert.NotNil(t, synthLaravelEdge(g, "C.php::Ctrl.ship", "L.php::TypedListener.handle"))
|
||||
assert.NotNil(t, synthLaravelEdge(g, "C.php::Ctrl.ship", "M.php::MappedListener.handle"))
|
||||
}
|
||||
|
||||
func TestResolveLaravelEventCalls_UnknownEventStaysPlaceholder(t *testing.T) {
|
||||
g := graph.New()
|
||||
laravelHandle(g, "L.php::L.handle", "L.php", "L", "KnownEvent")
|
||||
laravelDispatch(g, "C.php::Ctrl.go", "C.php", "OtherEvent")
|
||||
|
||||
assert.Equal(t, 0, ResolveLaravelEventCalls(g))
|
||||
assert.Nil(t, synthLaravelEdge(g, "C.php::Ctrl.go", "L.php::L.handle"))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package resolver
|
||||
|
||||
// LSPHelper drives resolve-time LSP queries from the cross-file
|
||||
// resolver. The resolver consults it for TS/JS/JSX/TSX edges before
|
||||
// falling back to AST/name heuristics — letting the type-aware
|
||||
// compiler (tsserver) win on cases the heuristics lose: barrel
|
||||
// re-exports, declaration merging, type-narrowed dispatch, JSX
|
||||
// component-as-callsite.
|
||||
//
|
||||
// The interface is defined in the resolver package so the resolver
|
||||
// has no compile-time dependency on the lsp package — the indexer
|
||||
// constructs a concrete helper (typically wrapping a *lsp.Provider)
|
||||
// and injects it via Resolver.SetLSPHelper. Resolver consults the
|
||||
// helper synchronously during resolveEdge; implementations are
|
||||
// expected to serialise tsserver-bound calls themselves and apply a
|
||||
// per-call timeout so a stalled language server can never gate the
|
||||
// resolve pass.
|
||||
type LSPHelper interface {
|
||||
// SupportsPath reports whether the helper can answer queries for
|
||||
// relPath. Implementations match on file extension; the resolver
|
||||
// short-circuits when SupportsPath is false (no LSP attempt).
|
||||
SupportsPath(relPath string) bool
|
||||
|
||||
// Definition returns the (relativePath, 1-based line) of the
|
||||
// declaration of `name` referenced on `oneBasedLine` inside
|
||||
// relPath. Returns ok=false when the LSP is unavailable, times
|
||||
// out, or has no answer. The caller is responsible for matching
|
||||
// the returned location to a graph node.
|
||||
Definition(relPath string, oneBasedLine int, name string) (defRelPath string, defOneBasedLine int, ok bool)
|
||||
}
|
||||
|
||||
// SetLSPHelper installs a resolve-time LSP helper. Pass nil to detach.
|
||||
// Must be called before ResolveAll / ResolveFile — the resolver caches
|
||||
// no LSP state across passes, so changing helpers between passes is
|
||||
// safe but mid-pass installation is racy with the parallel resolveEdge
|
||||
// workers and is not supported.
|
||||
func (r *Resolver) SetLSPHelper(h LSPHelper) {
|
||||
r.lspHelper = h
|
||||
}
|
||||
|
||||
// LSPHelper returns the currently installed helper, or nil.
|
||||
func (r *Resolver) LSPHelper() LSPHelper {
|
||||
return r.lspHelper
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// tryResolveViaLSP attempts to bind e to a graph node using the
|
||||
// configured LSPHelper. Returns true when the edge has been
|
||||
// resolved (e.To rewritten + stats incremented + Origin stamped).
|
||||
// On false the caller falls through to the heuristic cascade.
|
||||
//
|
||||
// The target string is the unresolved-prefix-stripped form of e.To,
|
||||
// matching the value resolveEdge already computed. We expect one of:
|
||||
// - "import::<path>" → import edge, ask LSP for the module file
|
||||
// - "extern::<path>::<sym>"→ already specific, LSP rarely improves it
|
||||
// - "*.<name>" → method/field/property call by selector
|
||||
// - "<name>" → bare function / type / token reference
|
||||
//
|
||||
// LSP-hot-path is intentionally narrow: it consults the helper, asks
|
||||
// for the *definition* location of the identifier at e.Line in
|
||||
// e.FilePath, and binds the edge to the graph node at that location.
|
||||
// The helper is responsible for opening files, serialising calls
|
||||
// against the underlying language server, and applying a per-call
|
||||
// timeout. A nil helper or a helper that doesn't claim e.FilePath
|
||||
// short-circuits to a fast false.
|
||||
func (r *Resolver) tryResolveViaLSP(e *graph.Edge, target string, stats *ResolveStats) bool {
|
||||
if r.lspHelper == nil || e == nil || e.FilePath == "" || e.Line <= 0 {
|
||||
return false
|
||||
}
|
||||
if !r.lspHelper.SupportsPath(e.FilePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Strip the resolver's structural prefixes so the helper sees a
|
||||
// bare identifier. Each branch normalises to the canonical name
|
||||
// the source-file would actually contain at e.Line — i.e. what
|
||||
// the LSP server can locate via textDocument/definition.
|
||||
name := identifierFromTarget(target)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
defRelPath, defLine, ok := r.lspHelper.Definition(e.FilePath, e.Line, name)
|
||||
if !ok || defRelPath == "" || defLine <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalise path. Tsserver's response is absolute; the graph
|
||||
// keeps relative paths anchored at the repo root. The helper
|
||||
// normalises before returning, but defend against trailing
|
||||
// drift (`./` prefix, "" path).
|
||||
defRelPath = strings.TrimPrefix(defRelPath, "./")
|
||||
|
||||
node := r.lookupNodeByLocation(defRelPath, defLine, name)
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject obviously-wrong kinds for the edge. A `calls` edge
|
||||
// landing on a KindFile or KindImport is a misresolution we'd
|
||||
// prefer to expose by falling through to the heuristic than
|
||||
// silently bind. Type-hierarchy edges must land on a type or
|
||||
// interface for the same reason resolveTypeRef gates them.
|
||||
if !lspKindAcceptableFor(e.Kind, node.Kind) {
|
||||
return false
|
||||
}
|
||||
|
||||
e.To = node.ID
|
||||
if e.Confidence < 1.0 {
|
||||
e.Confidence = 1.0
|
||||
}
|
||||
e.Origin = graph.OriginLSPResolved
|
||||
if e.Meta == nil {
|
||||
e.Meta = map[string]any{}
|
||||
}
|
||||
e.Meta["resolved_by"] = "lsp"
|
||||
|
||||
// Mirror the heuristic-path promotion in resolver.go: when an
|
||||
// EdgeReads target resolves to a function or method (h.foo passed
|
||||
// as a method value, or a bare `runClean` passed as a struct
|
||||
// field like `RunE: runClean`), promote to EdgeReferences so
|
||||
// get_callers and find_usages surface the reference. Without
|
||||
// this, every routing-style codebase (HTTP handlers, command
|
||||
// tables, callback maps, cobra/CLI wiring) silently looks like
|
||||
// its handlers have zero callers — the LSP hot path was binding
|
||||
// them but leaving the EdgeReads kind, which the query allowlist
|
||||
// drops. Writes stay as EdgeWrites: assigning a func value to a
|
||||
// method-typed field slot is still a write semantically.
|
||||
if e.Kind == graph.EdgeReads && (node.Kind == graph.KindMethod || node.Kind == graph.KindFunction) {
|
||||
e.Kind = graph.EdgeReferences
|
||||
}
|
||||
|
||||
// Multi-repo tracking: if the resolved node lives in a
|
||||
// different repo than the caller, mark CrossRepo so the
|
||||
// downstream cross-repo materialisation pass picks it up.
|
||||
if callerRepo := r.callerRepoPrefix(e); callerRepo != "" && node.RepoPrefix != "" && node.RepoPrefix != callerRepo {
|
||||
e.CrossRepo = true
|
||||
}
|
||||
|
||||
stats.Resolved++
|
||||
return true
|
||||
}
|
||||
|
||||
// deferredLSPEdge is one entry in the bulk-mode deferred LSP batch: the live
|
||||
// edge plus the pre-heuristic identifier target captured before the heuristic
|
||||
// cascade mutated it. The target is snapshotted while e.To is still the
|
||||
// `unresolved::` stub, because by the time the deferred batch runs the edge
|
||||
// may already carry a heuristic-resolved node ID from which the original
|
||||
// identifier can no longer be recovered.
|
||||
type deferredLSPEdge struct {
|
||||
edge *graph.Edge
|
||||
target string
|
||||
}
|
||||
|
||||
// lspDeferTarget reports whether a bulk-mode ResolveAll should collect e for
|
||||
// the deferred LSP batch and, when so, returns the pre-heuristic identifier
|
||||
// target the helper will look up. Mirrors tryResolveViaLSP's up-front gating
|
||||
// (helper present, real file position, supported extension, a bare identifier
|
||||
// the helper can locate) so the batch only carries edges the helper could
|
||||
// actually bind. Called from the parallel resolve workers on the live edge
|
||||
// BEFORE resolveEdge runs on its clone, so e.To is still the `unresolved::`
|
||||
// stub here and the derived target is the pre-heuristic one. Read-only.
|
||||
func (r *Resolver) lspDeferTarget(e *graph.Edge) (string, bool) {
|
||||
if r.lspHelper == nil || e == nil || e.FilePath == "" || e.Line <= 0 {
|
||||
return "", false
|
||||
}
|
||||
if !graph.IsUnresolvedTarget(e.To) {
|
||||
return "", false
|
||||
}
|
||||
if !r.lspHelper.SupportsPath(e.FilePath) {
|
||||
return "", false
|
||||
}
|
||||
target := graph.UnresolvedName(e.To)
|
||||
if target == "" {
|
||||
target = strings.TrimPrefix(e.To, unresolvedPrefix)
|
||||
}
|
||||
if identifierFromTarget(target) == "" {
|
||||
return "", false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
// resolveDeferredLSP binds the LSP-eligible edges the bulk-mode compute loop
|
||||
// collected through the installed helper, applying every hit via one
|
||||
// ReindexEdges call. It runs AFTER the parallel chunk loop so a synchronous
|
||||
// textDocument/definition round-trip never stalls the heuristic worker fan-out
|
||||
// at its barrier.
|
||||
//
|
||||
// The batch carries EVERY LSP-eligible edge, not only the ones the heuristic
|
||||
// cascade left unresolved: this is what preserves the LSP-first override the
|
||||
// inline (non-bulk) path applies. The heuristic can confidently bind an edge
|
||||
// to the WRONG node (e.g. a same-directory sibling that shadows a symbol whose
|
||||
// real import the resolver can't expand); the type-aware helper re-binds it to
|
||||
// the correct definition here, exactly as running LSP-first would have. Each
|
||||
// entry's target is the pre-heuristic identifier captured before the cascade
|
||||
// ran, so the helper is queried by the source-file identifier even for an edge
|
||||
// whose live To now points at a heuristic-resolved node.
|
||||
//
|
||||
// A successful bind stamps OriginLSPResolved (via tryResolveViaLSP), which is
|
||||
// also the signal the cross-package guard uses to leave these edges alone.
|
||||
//
|
||||
// The helper serialises its own language-server calls, so the batch walks the
|
||||
// edges serially, grouped by file for locality in the helper's open-file set
|
||||
// and lookupNodeByLocation's per-file index. The win over the inline path is
|
||||
// that these calls no longer contend on the helper lock inside the parallel
|
||||
// workers, and the balanced heuristic phase completes without LSP stragglers.
|
||||
//
|
||||
// Caller holds r.mu (the deferred batch is invoked from inside ResolveAll,
|
||||
// while the per-pass lookup / lsp indexes are still live). Returns the number
|
||||
// of edges that were heuristic-UNRESOLVED before the helper bound them — only
|
||||
// those move the pass tally from Unresolved to Resolved. Overriding an
|
||||
// already-resolved heuristic bind changes the target but not the count.
|
||||
func (r *Resolver) resolveDeferredLSP(edges []deferredLSPEdge) int {
|
||||
if len(edges) == 0 || r.lspHelper == nil {
|
||||
return 0
|
||||
}
|
||||
byFile := make(map[string][]deferredLSPEdge, len(edges))
|
||||
files := make([]string, 0, len(edges))
|
||||
for _, de := range edges {
|
||||
if de.edge == nil {
|
||||
continue
|
||||
}
|
||||
fp := de.edge.FilePath
|
||||
if _, seen := byFile[fp]; !seen {
|
||||
files = append(files, fp)
|
||||
}
|
||||
byFile[fp] = append(byFile[fp], de)
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
var stats ResolveStats
|
||||
newlyResolved := 0
|
||||
reindexBatch := make([]graph.EdgeReindex, 0, len(edges))
|
||||
for _, f := range files {
|
||||
for _, de := range byFile[f] {
|
||||
e := de.edge
|
||||
// A concurrent single-file edit during an inter-chunk yield may
|
||||
// have evicted this edge since it was collected; skip anything no
|
||||
// longer in the graph so we don't half-resurrect an evicted edge.
|
||||
// A resolved-but-live edge is NOT skipped: the heuristic may have
|
||||
// confidently bound it to the wrong node, and the LSP override
|
||||
// below is exactly what corrects that.
|
||||
if r.validateLiveness && !edgeStillLive(r.graph, e) {
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
wasUnresolved := graph.IsUnresolvedTarget(oldTo)
|
||||
if r.tryResolveViaLSP(e, de.target, &stats) {
|
||||
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
if wasUnresolved {
|
||||
newlyResolved++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(reindexBatch) > 0 {
|
||||
r.graph.ReindexEdges(reindexBatch)
|
||||
}
|
||||
return newlyResolved
|
||||
}
|
||||
|
||||
// identifierFromTarget extracts the bare identifier from a resolver
|
||||
// target string. Mirrors the branches in resolveEdge: strips the
|
||||
// `*.` selector prefix and the `extern::<path>::` package qualifier.
|
||||
// Returns "" for shapes the LSP-hot-path can't handle (import::,
|
||||
// pyrel::, grpc:: — those are routed through dedicated passes).
|
||||
func identifierFromTarget(target string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(target, "*."):
|
||||
return strings.TrimPrefix(target, "*.")
|
||||
case strings.HasPrefix(target, "extern::"):
|
||||
// extern::<importPath>::<symbol>
|
||||
spec := strings.TrimPrefix(target, "extern::")
|
||||
sep := strings.LastIndex(spec, "::")
|
||||
if sep < 0 {
|
||||
return ""
|
||||
}
|
||||
return spec[sep+2:]
|
||||
case strings.HasPrefix(target, "import::"),
|
||||
strings.HasPrefix(target, "pyrel::"),
|
||||
strings.HasPrefix(target, "grpc::"):
|
||||
// LSP doesn't improve module-path resolution; let the
|
||||
// dedicated passes own these.
|
||||
return ""
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// lookupNodeByLocation finds the graph node whose declaration starts
|
||||
// at (relPath, oneBasedLine). Lazily builds an O(1) index per pass
|
||||
// so repeated LSP hits in the same file don't rescan the graph.
|
||||
//
|
||||
// `nameHint` (when non-empty) narrows the match when the cache miss
|
||||
// has to walk multiple nodes that start on the same line — common
|
||||
// for one-liner exports like `export const X = 1; export const Y = 2;`.
|
||||
func (r *Resolver) lookupNodeByLocation(relPath string, oneBasedLine int, nameHint string) *graph.Node {
|
||||
key := lspLocKey{filePath: relPath, line: oneBasedLine}
|
||||
|
||||
r.lspIndexMu.RLock()
|
||||
if r.lspIndex != nil {
|
||||
if n, ok := r.lspIndex[key]; ok {
|
||||
r.lspIndexMu.RUnlock()
|
||||
if nameHint != "" && n != nil && n.Name != nameHint {
|
||||
// Index entry was a previous resolution for a
|
||||
// different identifier on the same line — fall
|
||||
// back to a name-aware scan.
|
||||
return r.scanNodeAtLocation(relPath, oneBasedLine, nameHint)
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
r.lspIndexMu.RUnlock()
|
||||
|
||||
n := r.scanNodeAtLocation(relPath, oneBasedLine, nameHint)
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.lspIndexMu.Lock()
|
||||
if r.lspIndex == nil {
|
||||
r.lspIndex = make(map[lspLocKey]*graph.Node)
|
||||
}
|
||||
r.lspIndex[key] = n
|
||||
r.lspIndexMu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// scanNodeAtLocation finds the graph node whose declaration line
|
||||
// matches (relPath, oneBasedLine). Prefers an exact StartLine hit;
|
||||
// if multiple nodes share that start line, prefers a name match.
|
||||
// Returns nil when no node anchors there.
|
||||
func (r *Resolver) scanNodeAtLocation(relPath string, oneBasedLine int, nameHint string) *graph.Node {
|
||||
nodes := r.graph.GetFileNodes(relPath)
|
||||
if len(nodes) == 0 {
|
||||
// Fallback: tsserver may return a path with platform-
|
||||
// specific separators or a slightly different case
|
||||
// (macOS HFS+). Try the canonicalised form.
|
||||
alt := filepath.ToSlash(relPath)
|
||||
if alt != relPath {
|
||||
nodes = r.graph.GetFileNodes(alt)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var fallback *graph.Node
|
||||
for _, n := range nodes {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
|
||||
continue
|
||||
}
|
||||
if n.StartLine != oneBasedLine {
|
||||
continue
|
||||
}
|
||||
if nameHint == "" || n.Name == nameHint {
|
||||
return n
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = n
|
||||
}
|
||||
}
|
||||
if fallback != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Looser match: tsserver sometimes reports the position of the
|
||||
// identifier on a line shifted by one (e.g. the JSDoc above the
|
||||
// declaration). Accept a node whose StartLine is within ±1 of
|
||||
// the LSP location when names agree.
|
||||
if nameHint != "" {
|
||||
for _, n := range nodes {
|
||||
if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport {
|
||||
continue
|
||||
}
|
||||
if n.Name != nameHint {
|
||||
continue
|
||||
}
|
||||
if delta := n.StartLine - oneBasedLine; delta >= -1 && delta <= 1 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearLSPIndex drops the per-pass lookup cache.
|
||||
func (r *Resolver) clearLSPIndex() {
|
||||
r.lspIndexMu.Lock()
|
||||
r.lspIndex = nil
|
||||
r.lspIndexMu.Unlock()
|
||||
}
|
||||
|
||||
// lspKindAcceptableFor reports whether a node of kind `nodeKind` is
|
||||
// a sensible target for an edge of kind `edgeKind`. Mirrors the
|
||||
// type-system gates the heuristic resolvers apply (e.g.
|
||||
// resolveTypeRef rejects function/method candidates for extends/
|
||||
// implements edges).
|
||||
func lspKindAcceptableFor(edgeKind graph.EdgeKind, nodeKind graph.NodeKind) bool {
|
||||
switch edgeKind {
|
||||
case graph.EdgeExtends, graph.EdgeImplements, graph.EdgeComposes:
|
||||
return nodeKind == graph.KindType || nodeKind == graph.KindInterface
|
||||
case graph.EdgeCalls:
|
||||
switch nodeKind {
|
||||
case graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindClosure:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case graph.EdgeReads, graph.EdgeWrites:
|
||||
switch nodeKind {
|
||||
case graph.KindField, graph.KindVariable, graph.KindConstant, graph.KindMethod, graph.KindFunction:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case graph.EdgeReferences, graph.EdgeInstantiates:
|
||||
switch nodeKind {
|
||||
case graph.KindFile, graph.KindImport, graph.KindPackage:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case graph.EdgeProvides, graph.EdgeConsumes:
|
||||
switch nodeKind {
|
||||
case graph.KindFile, graph.KindImport:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Default: anything goes that isn't a file/import. File/import
|
||||
// nodes are containers, never the semantic target of a code
|
||||
// reference.
|
||||
if nodeKind == graph.KindFile || nodeKind == graph.KindImport {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// fakeLSPHelper is a deterministic mock implementing LSPHelper for
|
||||
// tests. exts narrows which file paths it claims; defs is the
|
||||
// canonical mapping from (callerPath, line, name) → (defPath, line).
|
||||
type fakeLSPHelper struct {
|
||||
exts []string
|
||||
defs map[lspKey]lspAnswer
|
||||
calls int
|
||||
inFlight int
|
||||
maxInFlight int // high-water mark of concurrent Definition calls
|
||||
mu sync.Mutex
|
||||
hangCh chan struct{} // optional: when non-nil, blocks Definition until closed (timeout testing)
|
||||
}
|
||||
|
||||
type lspKey struct {
|
||||
path string
|
||||
line int
|
||||
name string
|
||||
}
|
||||
|
||||
type lspAnswer struct {
|
||||
defPath string
|
||||
defLine int
|
||||
}
|
||||
|
||||
func (f *fakeLSPHelper) SupportsPath(relPath string) bool {
|
||||
if len(f.exts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range f.exts {
|
||||
if hasSuffix(relPath, e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeLSPHelper) Definition(relPath string, line int, name string) (string, int, bool) {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.inFlight++
|
||||
if f.inFlight > f.maxInFlight {
|
||||
f.maxInFlight = f.inFlight
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if f.hangCh != nil {
|
||||
<-f.hangCh
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.inFlight--
|
||||
f.mu.Unlock()
|
||||
a, ok := f.defs[lspKey{path: relPath, line: line, name: name}]
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
return a.defPath, a.defLine, true
|
||||
}
|
||||
|
||||
func hasSuffix(s, suffix string) bool {
|
||||
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
|
||||
// TestLSPHotPath_BarrelReExport — the canonical case the heuristic
|
||||
// loses: a method called by selector through a barrel re-export. The
|
||||
// AST resolver can find a same-named target anywhere in the repo
|
||||
// (potentially the wrong one); the LSP definition lookup pins the
|
||||
// edge to the precise re-exported declaration.
|
||||
//
|
||||
// Driven through the single-file ResolveFile path: that is where the LSP
|
||||
// helper is consulted inline (LSP-first), the behaviour an interactive edit
|
||||
// relies on. A whole-graph ResolveAll runs in bulk mode, where LSP is deferred
|
||||
// to a post-loop mop-up for the edges the heuristic cascade leaves unresolved
|
||||
// (see TestLSPHotPath_BulkDefersLSP*).
|
||||
func TestLSPHotPath_BarrelReExport(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Files
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/real.ts", Kind: graph.KindFile, Name: "real.ts", FilePath: "src/real.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/decoy.ts", Kind: graph.KindFile, Name: "decoy.ts", FilePath: "src/decoy.ts", Language: "typescript"})
|
||||
|
||||
// Decoy: same name in another file — the heuristic resolver
|
||||
// would pick this first because filterByReachability and same-
|
||||
// dir bias both fail.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/decoy.ts::doWork", Kind: graph.KindFunction, Name: "doWork",
|
||||
FilePath: "src/decoy.ts", StartLine: 12, EndLine: 14, Language: "typescript",
|
||||
})
|
||||
// Real definition the LSP will report.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/real.ts::doWork", Kind: graph.KindFunction, Name: "doWork",
|
||||
FilePath: "src/real.ts", StartLine: 7, EndLine: 9, Language: "typescript",
|
||||
})
|
||||
// Caller
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt",
|
||||
FilePath: "src/caller.ts", StartLine: 3, EndLine: 5, Language: "typescript",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::doWork",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 4,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 4, name: "doWork"}: {defPath: "src/real.ts", defLine: 7},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveFile("src/caller.ts")
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "src/real.ts::doWork", callEdge.To, "edge must bind to LSP-reported definition, not the decoy")
|
||||
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
|
||||
require.NotNil(t, callEdge.Meta)
|
||||
assert.Equal(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
assert.Equal(t, 1, helper.calls)
|
||||
}
|
||||
|
||||
// TestLSPHotPath_FallthroughOnMiss — when the LSP returns no answer,
|
||||
// the heuristic cascade still runs. The edge gets resolved by the
|
||||
// AST resolver and its Origin reflects the AST tier (NOT lsp_*).
|
||||
func TestLSPHotPath_FallthroughOnMiss(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/a.ts", Kind: graph.KindFile, Name: "a.ts", FilePath: "src/a.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/b.ts", Kind: graph.KindFile, Name: "b.ts", FilePath: "src/b.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/a.ts::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "src/a.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/b.ts::theTarget", Kind: graph.KindFunction, Name: "theTarget",
|
||||
FilePath: "src/b.ts", StartLine: 4, EndLine: 6, Language: "typescript",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/a.ts::caller", To: "unresolved::theTarget",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/a.ts", Line: 2,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{}, // empty — every call misses
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved, "heuristic cascade should still resolve")
|
||||
assert.Equal(t, "src/b.ts::theTarget", callEdge.To)
|
||||
assert.NotEqual(t, graph.OriginLSPResolved, callEdge.Origin, "miss → heuristic tier, not lsp_resolved")
|
||||
if callEdge.Meta != nil {
|
||||
assert.NotEqual(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLSPHotPath_ExtensionGate — the helper short-circuits on
|
||||
// SupportsPath, so a Go-file edge doesn't trigger any LSP call when
|
||||
// the helper claims only TS extensions. The heuristic resolver
|
||||
// produces the answer.
|
||||
func TestLSPHotPath_ExtensionGate(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "b.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "a.go::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "a.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "b.go::target", Kind: graph.KindFunction, Name: "target",
|
||||
FilePath: "b.go", StartLine: 3, EndLine: 5, Language: "go",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "a.go::caller", To: "unresolved::target",
|
||||
Kind: graph.EdgeCalls, FilePath: "a.go", Line: 2,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts", ".tsx"},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "b.go::target", callEdge.To)
|
||||
assert.Equal(t, 0, helper.calls, "helper must NOT be called for non-claimed extensions")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_KindGate — the LSP helper returns a file-node
|
||||
// location, but the edge is a `calls` edge that must land on a
|
||||
// function/method/closure. lspKindAcceptableFor rejects the bind
|
||||
// and the heuristic falls through.
|
||||
func TestLSPHotPath_KindGate(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/util.ts", Kind: graph.KindFile, Name: "util.ts", FilePath: "src/util.ts", Language: "typescript", StartLine: 1})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/util.ts::reallyDoIt", Kind: graph.KindFunction, Name: "reallyDoIt",
|
||||
FilePath: "src/util.ts", StartLine: 5, EndLine: 7, Language: "typescript",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::reallyDoIt",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 2,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
// LSP points the edge at the file node, not the function node —
|
||||
// the kind-gate should reject.
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 2, name: "reallyDoIt"}: {defPath: "src/util.ts", defLine: 1},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
// Heuristic picked the function node, not the file.
|
||||
assert.Equal(t, "src/util.ts::reallyDoIt", callEdge.To)
|
||||
// Origin must NOT be lsp_resolved — gate rejected the LSP answer.
|
||||
assert.NotEqual(t, graph.OriginLSPResolved, callEdge.Origin)
|
||||
}
|
||||
|
||||
// TestLSPHotPath_MethodSelector — the resolver receives an unresolved
|
||||
// `*.Name` selector target. tryResolveViaLSP strips the prefix and
|
||||
// asks the helper for `Name`. On a hit, the method edge binds to the
|
||||
// LSP-reported target across files.
|
||||
func TestLSPHotPath_MethodSelector(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/svc.ts", Kind: graph.KindFile, Name: "svc.ts", FilePath: "src/svc.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/decoy.ts", Kind: graph.KindFile, Name: "decoy.ts", FilePath: "src/decoy.ts", Language: "typescript"})
|
||||
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/svc.ts::Service.handle", Kind: graph.KindMethod, Name: "handle",
|
||||
FilePath: "src/svc.ts", StartLine: 10, EndLine: 12, Language: "typescript",
|
||||
})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/decoy.ts::Other.handle", Kind: graph.KindMethod, Name: "handle",
|
||||
FilePath: "src/decoy.ts", StartLine: 5, EndLine: 7, Language: "typescript",
|
||||
})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::*.handle",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 9,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 9, name: "handle"}: {defPath: "src/svc.ts", defLine: 10},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveFile("src/caller.ts")
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "src/svc.ts::Service.handle", callEdge.To)
|
||||
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
|
||||
assert.Equal(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
}
|
||||
|
||||
// TestLSPHotPath_MethodValueReadPromotesToReferences — when the LSP
|
||||
// helper binds an EdgeReads to a KindMethod (the `mux.HandleFunc("/p",
|
||||
// h.foo)` shape where h.foo is passed as a method value), the kind
|
||||
// must be promoted to EdgeReferences. The heuristic cascade already
|
||||
// does this in resolver.go's `*. + Reads/Writes` case; the LSP hot
|
||||
// path used to short-circuit before that branch ran and silently
|
||||
// leave the kind as EdgeReads — which GetCallers/FindUsages drop
|
||||
// (they only follow Calls/Matches/References). Every HTTP handler in
|
||||
// every router-style codebase looked like dead code as a result.
|
||||
func TestLSPHotPath_MethodValueReadPromotesToReferences(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/routes.go", Kind: graph.KindFile, Name: "routes.go", FilePath: "src/routes.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "src/handler.go", Kind: graph.KindFile, Name: "handler.go", FilePath: "src/handler.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "src/routes.go::RegisterRoutes", Kind: graph.KindFunction, Name: "RegisterRoutes", FilePath: "src/routes.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/handler.go::Handler.HandleHealth", Kind: graph.KindMethod, Name: "HandleHealth",
|
||||
FilePath: "src/handler.go", StartLine: 42, EndLine: 45, Language: "go",
|
||||
})
|
||||
|
||||
readEdge := &graph.Edge{
|
||||
From: "src/routes.go::RegisterRoutes", To: "unresolved::*.HandleHealth",
|
||||
Kind: graph.EdgeReads, FilePath: "src/routes.go", Line: 10,
|
||||
}
|
||||
g.AddEdge(readEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".go"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/routes.go", line: 10, name: "HandleHealth"}: {defPath: "src/handler.go", defLine: 42},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveFile("src/routes.go")
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "src/handler.go::Handler.HandleHealth", readEdge.To)
|
||||
assert.Equal(t, graph.OriginLSPResolved, readEdge.Origin)
|
||||
assert.Equal(t, graph.EdgeReferences, readEdge.Kind,
|
||||
"LSP-bound EdgeReads on a KindMethod must be promoted so get_callers surfaces it")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_FunctionValueReadPromotesToReferences — companion
|
||||
// to the method-value test above. The cobra/CLI pattern
|
||||
// `&cobra.Command{RunE: runClean}` emits EdgeReads with To=
|
||||
// "unresolved::runClean", and the LSP helper happily binds it to
|
||||
// the runClean function. Without promotion the wire-up site is
|
||||
// invisible to get_callers, so every cobra subcommand looked dead.
|
||||
func TestLSPHotPath_FunctionValueReadPromotesToReferences(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "cmd/x/main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "cmd/x/main.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "cmd/x/clean.go", Kind: graph.KindFile, Name: "clean.go", FilePath: "cmd/x/clean.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "cmd/x/main.go::init", Kind: graph.KindFunction, Name: "init", FilePath: "cmd/x/main.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "cmd/x/clean.go::runClean", Kind: graph.KindFunction, Name: "runClean",
|
||||
FilePath: "cmd/x/clean.go", StartLine: 20, EndLine: 30, Language: "go",
|
||||
})
|
||||
|
||||
readEdge := &graph.Edge{
|
||||
From: "cmd/x/main.go::init", To: "unresolved::runClean",
|
||||
Kind: graph.EdgeReads, FilePath: "cmd/x/main.go", Line: 13,
|
||||
}
|
||||
g.AddEdge(readEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".go"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "cmd/x/main.go", line: 13, name: "runClean"}: {defPath: "cmd/x/clean.go", defLine: 20},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveFile("cmd/x/main.go")
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "cmd/x/clean.go::runClean", readEdge.To)
|
||||
assert.Equal(t, graph.OriginLSPResolved, readEdge.Origin)
|
||||
assert.Equal(t, graph.EdgeReferences, readEdge.Kind,
|
||||
"LSP-bound EdgeReads on a KindFunction must promote to References")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_NilHelper — when no helper is installed, the
|
||||
// resolver runs heuristic-only as in the pre-N5 world.
|
||||
func TestLSPHotPath_NilHelper(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a.ts", Kind: graph.KindFile, Name: "a.ts", FilePath: "a.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "b.ts", Kind: graph.KindFile, Name: "b.ts", FilePath: "b.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "a.ts::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "a.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "b.ts::tgt", Kind: graph.KindFunction, Name: "tgt",
|
||||
FilePath: "b.ts", StartLine: 1, EndLine: 3, Language: "typescript",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "a.ts::caller", To: "unresolved::tgt",
|
||||
Kind: graph.EdgeCalls, FilePath: "a.ts", Line: 1,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
r := New(g)
|
||||
// no helper installed
|
||||
stats := r.ResolveAll()
|
||||
assert.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "b.ts::tgt", callEdge.To)
|
||||
}
|
||||
|
||||
// TestLSPHotPath_IdentifierFromTarget — covers the prefix stripping
|
||||
// for the target shapes the resolver dispatches on.
|
||||
func TestIdentifierFromTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"foo", "foo"},
|
||||
{"*.handle", "handle"},
|
||||
{"extern::pkg/sub::Symbol", "Symbol"},
|
||||
{"extern::pkg::A::B", "B"},
|
||||
{"import::pkg/foo", ""},
|
||||
{"pyrel::foo", ""},
|
||||
{"grpc::Svc::Method", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := identifierFromTarget(c.in)
|
||||
assert.Equalf(t, c.want, got, "input=%q", c.in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLSPKindAcceptableFor covers the kind-gate rules.
|
||||
func TestLSPKindAcceptableFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
ek graph.EdgeKind
|
||||
nk graph.NodeKind
|
||||
want bool
|
||||
}{
|
||||
{graph.EdgeCalls, graph.KindFunction, true},
|
||||
{graph.EdgeCalls, graph.KindMethod, true},
|
||||
{graph.EdgeCalls, graph.KindFile, false},
|
||||
{graph.EdgeCalls, graph.KindImport, false},
|
||||
{graph.EdgeExtends, graph.KindType, true},
|
||||
{graph.EdgeExtends, graph.KindFunction, false},
|
||||
{graph.EdgeImplements, graph.KindInterface, true},
|
||||
{graph.EdgeImplements, graph.KindMethod, false},
|
||||
{graph.EdgeReads, graph.KindField, true},
|
||||
{graph.EdgeReads, graph.KindVariable, true},
|
||||
{graph.EdgeReads, graph.KindFile, false},
|
||||
{graph.EdgeReferences, graph.KindType, true},
|
||||
{graph.EdgeReferences, graph.KindFile, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := lspKindAcceptableFor(c.ek, c.nk)
|
||||
assert.Equalf(t, c.want, got, "edge=%s node=%s", c.ek, c.nk)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLSPHotPath_LSPIndexCaching — multiple edges resolving via LSP
|
||||
// to the same definition should hit the lspIndex cache, not rescan
|
||||
// the file each time.
|
||||
func TestLSPHotPath_LSPIndexCaching(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/svc.ts", Kind: graph.KindFile, Name: "svc.ts", FilePath: "src/svc.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/svc.ts::theTarget", Kind: graph.KindFunction, Name: "theTarget",
|
||||
FilePath: "src/svc.ts", StartLine: 4, EndLine: 6, Language: "typescript",
|
||||
})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callerA", Kind: graph.KindFunction, Name: "callerA", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callerB", Kind: graph.KindFunction, Name: "callerB", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
e1 := &graph.Edge{From: "src/caller.ts::callerA", To: "unresolved::theTarget", Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 3}
|
||||
e2 := &graph.Edge{From: "src/caller.ts::callerB", To: "unresolved::theTarget", Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 7}
|
||||
g.AddEdge(e1)
|
||||
g.AddEdge(e2)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 3, name: "theTarget"}: {defPath: "src/svc.ts", defLine: 4},
|
||||
{path: "src/caller.ts", line: 7, name: "theTarget"}: {defPath: "src/svc.ts", defLine: 4},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveFile("src/caller.ts")
|
||||
|
||||
require.Equal(t, 2, stats.Resolved)
|
||||
assert.Equal(t, "src/svc.ts::theTarget", e1.To)
|
||||
assert.Equal(t, "src/svc.ts::theTarget", e2.To)
|
||||
assert.Equal(t, graph.OriginLSPResolved, e1.Origin)
|
||||
assert.Equal(t, graph.OriginLSPResolved, e2.Origin)
|
||||
}
|
||||
|
||||
// TestLSPHotPath_NoOpAfterFileMiss — when LSP returns a path that
|
||||
// doesn't exist in the graph, the bind should fall through. This
|
||||
// protects against off-by-one path mismatches.
|
||||
func TestLSPHotPath_NoOpAfterFileMiss(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::ghost",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 2,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 2, name: "ghost"}: {defPath: "nonexistent/file.ts", defLine: 1},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
// LSP miss path triggered (no graph node at that file) — heuristic
|
||||
// has nothing to find either, so the edge is left unresolved.
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 1, stats.Unresolved)
|
||||
}
|
||||
|
||||
// TestLSPHotPath_BulkDefersLSP_ResolvesUnresolved — the deferral contract on
|
||||
// the whole-graph ResolveAll (bulk) path. The heuristic cascade cannot bind
|
||||
// the call (there is no graph node named "doThing"), so the edge is collected
|
||||
// and bound in the post-loop deferred LSP batch instead of an inline round-
|
||||
// trip inside the parallel workers. resolveEdge never consults the helper in
|
||||
// bulk mode, so the single recorded call can only have come from the deferred
|
||||
// batch, and maxInFlight==1 confirms the batch ran the call serially, off the
|
||||
// parallel worker barrier.
|
||||
func TestLSPHotPath_BulkDefersLSP_ResolvesUnresolved(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/svc.ts", Kind: graph.KindFile, Name: "svc.ts", FilePath: "src/svc.ts", Language: "typescript"})
|
||||
// The definition the LSP reports lives under a different name than the
|
||||
// call site uses (a rename / barrel re-export), so the name-only heuristic
|
||||
// finds nothing to bind — only the LSP location lookup can resolve it.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/svc.ts::renamedTarget", Kind: graph.KindFunction, Name: "renamedTarget",
|
||||
FilePath: "src/svc.ts", StartLine: 5, EndLine: 7, Language: "typescript",
|
||||
})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::doThing",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 3,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 3, name: "doThing"}: {defPath: "src/svc.ts", defLine: 5},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "src/svc.ts::renamedTarget", callEdge.To, "deferred LSP batch must bind the heuristic-unresolved edge")
|
||||
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
|
||||
require.NotNil(t, callEdge.Meta)
|
||||
assert.Equal(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
assert.Equal(t, 1, helper.calls, "helper is consulted exactly once, in the deferred batch")
|
||||
assert.Equal(t, 1, helper.maxInFlight, "deferred LSP calls run serially, off the parallel worker barrier")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_BulkDefersLSPEvenWhenHeuristicResolves — the other half of
|
||||
// the deferral contract: the helper is never consulted INLINE during the bulk
|
||||
// chunk loop, but a heuristic-resolved LSP-eligible edge is still collected
|
||||
// for the post-loop deferred batch so LSP keeps its override authority. Here
|
||||
// the heuristic binds the sole same-dir candidate and LSP confirms the same
|
||||
// node; the edge ends LSP-stamped and the single helper call ran serially in
|
||||
// the deferred batch (maxInFlight==1), off the parallel worker barrier —
|
||||
// resolveEdge never touches the helper in bulk mode.
|
||||
func TestLSPHotPath_BulkDefersLSPEvenWhenHeuristicResolves(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/svc.ts", Kind: graph.KindFile, Name: "svc.ts", FilePath: "src/svc.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/svc.ts::realFn", Kind: graph.KindFunction, Name: "realFn",
|
||||
FilePath: "src/svc.ts", StartLine: 4, EndLine: 6, Language: "typescript",
|
||||
})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::realFn",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 3,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
// The heuristic resolves the call itself (sole same-dir candidate), and the
|
||||
// helper confirms the same definition. The deferred batch must still run so
|
||||
// LSP retains override authority — here it upgrades the edge's provenance.
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 3, name: "realFn"}: {defPath: "src/svc.ts", defLine: 4},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved, "the confirmed edge stays counted once, not double-counted by the deferred batch")
|
||||
assert.Equal(t, "src/svc.ts::realFn", callEdge.To)
|
||||
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin, "the deferred batch confirms the bind and stamps LSP provenance")
|
||||
require.NotNil(t, callEdge.Meta)
|
||||
assert.Equal(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
assert.Equal(t, 1, helper.calls, "helper consulted exactly once, in the deferred batch")
|
||||
assert.Equal(t, 1, helper.maxInFlight, "deferred LSP calls run serially, off the parallel worker barrier")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_BulkLSPOverridesHeuristicMisbind — the regression that closes
|
||||
// the bulk-mode LSP-override gap. The heuristic CONFIDENTLY binds a bare call
|
||||
// to a same-directory sibling that shadows the name (src/neighbor.ts::foo at
|
||||
// ast_resolved) because the import bringing the real symbol into scope can't be
|
||||
// expanded. LSP knows the real definition lives cross-directory in
|
||||
// lib/real.ts. In bulk mode the edge must still be deferred despite the
|
||||
// confident heuristic bind, and the deferred batch must OVERRIDE it to the
|
||||
// LSP-reported definition — matching the LSP-first single-file path. Before the
|
||||
// fix, a heuristic-resolved edge was never deferred, so the mis-bind persisted
|
||||
// across every restart and get_callers/find_usages attributed the call to the
|
||||
// wrong function.
|
||||
func TestLSPHotPath_BulkLSPOverridesHeuristicMisbind(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "src/neighbor.ts", Kind: graph.KindFile, Name: "neighbor.ts", FilePath: "src/neighbor.ts", Language: "typescript"})
|
||||
g.AddNode(&graph.Node{ID: "lib/real.ts", Kind: graph.KindFile, Name: "real.ts", FilePath: "lib/real.ts", Language: "typescript"})
|
||||
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
// Same-directory shadow the heuristic will confidently (wrongly) pick.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "src/neighbor.ts::foo", Kind: graph.KindFunction, Name: "foo",
|
||||
FilePath: "src/neighbor.ts", StartLine: 2, EndLine: 4, Language: "typescript",
|
||||
})
|
||||
// The real definition, cross-directory — only LSP resolves to it.
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "lib/real.ts::foo", Kind: graph.KindFunction, Name: "foo",
|
||||
FilePath: "lib/real.ts", StartLine: 7, EndLine: 9, Language: "typescript",
|
||||
})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::foo",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 4,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 4, name: "foo"}: {defPath: "lib/real.ts", defLine: 7},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
require.Equal(t, 1, stats.Resolved)
|
||||
assert.Equal(t, "lib/real.ts::foo", callEdge.To,
|
||||
"deferred LSP batch must override the heuristic's same-dir mis-bind")
|
||||
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
|
||||
require.NotNil(t, callEdge.Meta)
|
||||
assert.Equal(t, "lsp", callEdge.Meta["resolved_by"])
|
||||
assert.Equal(t, 1, helper.calls, "helper consulted once, in the deferred batch")
|
||||
}
|
||||
|
||||
// TestLSPHotPath_BulkDeferRespectsKindGate — a deferred edge whose LSP target
|
||||
// is an unacceptable kind (a file node for a calls edge) is rejected by the
|
||||
// same kind-gate the inline path applies, and left unresolved rather than
|
||||
// mis-bound.
|
||||
func TestLSPHotPath_BulkDeferRespectsKindGate(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", FilePath: "src/caller.ts", Language: "typescript", StartLine: 1})
|
||||
g.AddNode(&graph.Node{ID: "src/util.ts", Kind: graph.KindFile, Name: "util.ts", FilePath: "src/util.ts", Language: "typescript", StartLine: 1})
|
||||
g.AddNode(&graph.Node{ID: "src/caller.ts::callIt", Kind: graph.KindFunction, Name: "callIt", FilePath: "src/caller.ts", Language: "typescript"})
|
||||
|
||||
callEdge := &graph.Edge{
|
||||
From: "src/caller.ts::callIt", To: "unresolved::missing",
|
||||
Kind: graph.EdgeCalls, FilePath: "src/caller.ts", Line: 2,
|
||||
}
|
||||
g.AddEdge(callEdge)
|
||||
|
||||
// LSP points at the util.ts FILE node (line 1) — the kind-gate must reject
|
||||
// binding a calls edge to a file.
|
||||
helper := &fakeLSPHelper{
|
||||
exts: []string{".ts"},
|
||||
defs: map[lspKey]lspAnswer{
|
||||
{path: "src/caller.ts", line: 2, name: "missing"}: {defPath: "src/util.ts", defLine: 1},
|
||||
},
|
||||
}
|
||||
|
||||
r := New(g)
|
||||
r.SetLSPHelper(helper)
|
||||
stats := r.ResolveAll()
|
||||
|
||||
assert.Equal(t, 0, stats.Resolved)
|
||||
assert.Equal(t, 1, helper.calls, "deferred batch attempted the bind")
|
||||
assert.True(t, graph.IsUnresolvedTarget(callEdge.To), "kind-gated LSP answer must leave the edge unresolved")
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// resolveLuaRequires binds Lua / Luau `require(...)` import edges that the
|
||||
// extractor left on `unresolved::import::<module>` placeholders onto the
|
||||
// indexed module file they reference.
|
||||
//
|
||||
// The Lua extractor carries the full dotted module path for a classic string
|
||||
// require (`require("a.b.c")` → target `a.b.c`) and the leaf module name plus
|
||||
// a `roblox_path` for an instance-path require (`require(script.Parent.Foo)` →
|
||||
// target `Foo`, with `roblox_path` set). Classic requires map the dotted path
|
||||
// to candidate files (`a/b/c.lua`, the `.luau` form, then the `a/b/c/init.*`
|
||||
// directory module), the direct repo-relative path winning, then a unique
|
||||
// path-suffix match (the package-root net). Roblox instance-path requires bind
|
||||
// to the unique `<leaf>.lua` / `<leaf>.luau` file in the repo, refusing on
|
||||
// ambiguity. Edges whose target is not indexed stay external.
|
||||
//
|
||||
// Runs serially in ResolveAll's relative-import settle window, after
|
||||
// resolveRelativeImports (which never touches Lua).
|
||||
func (r *Resolver) resolveLuaRequires() {
|
||||
if !r.graphHasLanguage("lua") && !r.graphHasLanguage("luau") {
|
||||
return
|
||||
}
|
||||
fileLang := r.collectFileLanguages()
|
||||
|
||||
fileIDs := make(map[string]struct{}, 1024)
|
||||
// filesByBase indexes every KindFile by basename for the suffix nets.
|
||||
filesByBase := make(map[string][]string, 1024)
|
||||
for n := range r.graph.NodesByKind(graph.KindFile) {
|
||||
if n == nil || n.ID == "" {
|
||||
continue
|
||||
}
|
||||
fileIDs[n.ID] = struct{}{}
|
||||
base := n.ID
|
||||
if i := strings.LastIndex(n.ID, "/"); i >= 0 {
|
||||
base = n.ID[i+1:]
|
||||
}
|
||||
filesByBase[base] = append(filesByBase[base], n.ID)
|
||||
}
|
||||
|
||||
var reindexBatch []graph.EdgeReindex
|
||||
for e := range r.graph.EdgesByKind(graph.EdgeImports) {
|
||||
if e == nil || !strings.HasPrefix(e.To, "unresolved::import::") {
|
||||
continue
|
||||
}
|
||||
// Scoped warm pass: an unchanged repo's requires were already bound by a
|
||||
// prior full pass, so only reconsider the changed repos' imports.
|
||||
if !r.edgeFromInScope(e.From) {
|
||||
continue
|
||||
}
|
||||
if lang := fileLang[e.From]; lang != "lua" && lang != "luau" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(e.To, "unresolved::import::")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var resolved string
|
||||
if _, isRoblox := e.Meta["roblox_path"]; isRoblox {
|
||||
resolved = resolveLuaRobloxRequire(filesByBase, name)
|
||||
} else {
|
||||
resolved = resolveLuaModuleRequire(fileIDs, filesByBase, name)
|
||||
}
|
||||
if resolved == "" {
|
||||
continue
|
||||
}
|
||||
oldTo := e.To
|
||||
e.To = resolved
|
||||
e.Origin = graph.OriginASTResolved
|
||||
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
||||
}
|
||||
if len(reindexBatch) > 0 {
|
||||
r.graph.ReindexEdges(reindexBatch)
|
||||
}
|
||||
}
|
||||
|
||||
// luaModuleCandidates converts a dotted/slashed Lua module path to its ordered
|
||||
// candidate file paths: the `.lua` / `.luau` file, then the `init` directory
|
||||
// module.
|
||||
func luaModuleCandidates(modPath string) []string {
|
||||
p := strings.Trim(strings.ReplaceAll(modPath, ".", "/"), "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{p + ".lua", p + ".luau", p + "/init.lua", p + "/init.luau"}
|
||||
}
|
||||
|
||||
// resolveLuaModuleRequire resolves a classic string require's dotted module
|
||||
// path: the direct repo-relative candidate file wins; otherwise a unique
|
||||
// path-suffix match (handling package-root-prefixed layouts) wins, refusing on
|
||||
// ambiguity.
|
||||
func resolveLuaModuleRequire(fileIDs map[string]struct{}, filesByBase map[string][]string, modPath string) string {
|
||||
cands := luaModuleCandidates(modPath)
|
||||
for _, c := range cands {
|
||||
if _, ok := fileIDs[c]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if m := luaUniqueSuffixMatch(filesByBase, c); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// luaUniqueSuffixMatch returns the single indexed file equal to, or ending with
|
||||
// `/`+path, or "" when there is none or more than one (ambiguous).
|
||||
func luaUniqueSuffixMatch(filesByBase map[string][]string, path string) string {
|
||||
base := path
|
||||
if i := strings.LastIndex(path, "/"); i >= 0 {
|
||||
base = path[i+1:]
|
||||
}
|
||||
suffix := "/" + path
|
||||
match := ""
|
||||
for _, cand := range filesByBase[base] {
|
||||
if cand == path || strings.HasSuffix(cand, suffix) {
|
||||
if match != "" && match != cand {
|
||||
return "" // ambiguous across roots
|
||||
}
|
||||
match = cand
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// resolveLuaRobloxRequire binds a Roblox instance-path require by its leaf
|
||||
// module name to the unique indexed `<leaf>.lua` / `<leaf>.luau` file, refusing
|
||||
// on ambiguity.
|
||||
func resolveLuaRobloxRequire(filesByBase map[string][]string, leaf string) string {
|
||||
match := ""
|
||||
for _, ext := range []string{".lua", ".luau"} {
|
||||
for _, cand := range filesByBase[leaf+ext] {
|
||||
if match != "" && match != cand {
|
||||
return "" // ambiguous module name in the repo
|
||||
}
|
||||
match = cand
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user