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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+41
View File
@@ -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.
+23
View File
@@ -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
}
+236
View File
@@ -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,
)
}
}
+140
View File
@@ -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()
}
}
+146
View File
@@ -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 ""
}
+315
View File
@@ -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")
}
+92
View File
@@ -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)
}
+100
View File
@@ -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
}
+82
View File
@@ -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"))
}
+239
View File
@@ -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
}
+110
View File
@@ -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")
}
+113
View File
@@ -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")
}
+135
View File
@@ -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
}
+270
View File
@@ -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
}
+120
View File
@@ -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)
}
}
+77
View File
@@ -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)
}
}
+468
View File
@@ -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)
}
}
+122
View File
@@ -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
}
+143
View File
@@ -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))
}
+137
View File
@@ -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")
}
}
+347
View File
@@ -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)
}
+102
View File
@@ -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++
}
+181
View File
@@ -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")
}
+408
View File
@@ -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")
})
}
+223
View File
@@ -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")
}
+218
View File
@@ -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
}
+146
View File
@@ -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")
}
+141
View File
@@ -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 ""
}
+78
View File
@@ -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)
}
+47
View File
@@ -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
}
+106
View File
@@ -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
}
+142
View File
@@ -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])
}
+21
View File
@@ -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
+48
View File
@@ -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))
}
+251
View File
@@ -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")
}
+95
View File
@@ -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))
}
+151
View File
@@ -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
}
+76
View File
@@ -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")
}
+558
View File
@@ -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")
}
}
+416
View File
@@ -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))
})
}
}
+111
View File
@@ -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))
}
+77
View File
@@ -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
}
+85
View File
@@ -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"))
}
+158
View File
@@ -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
}
+102
View File
@@ -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)
}
+223
View File
@@ -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))
}
+410
View File
@@ -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
}
+263
View File
@@ -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")
}
+488
View File
@@ -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
}
+99
View File
@@ -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)
}
+142
View File
@@ -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,
)
}
}
+141
View File
@@ -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,
},
}
}
+111
View File
@@ -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")
}
+188
View File
@@ -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"))
}
+363
View File
@@ -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 ""
}
+250
View File
@@ -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)
}
+256
View File
@@ -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 ""
}
+264
View File
@@ -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)
}
}
+58
View File
@@ -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
}
+111
View File
@@ -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))
}
}
+38
View File
@@ -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")
}
+88
View File
@@ -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")
}
+240
View File
@@ -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")
}
+189
View File
@@ -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
}
+107
View File
@@ -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)
}
+112
View File
@@ -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
}
+75
View File
@@ -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")
}
+42
View File
@@ -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
}
}
}
}
}
+180
View File
@@ -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"))
}
+44
View File
@@ -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
}
+403
View File
@@ -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
}
+691
View File
@@ -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")
}
+146
View File
@@ -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