a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
282 lines
11 KiB
Go
282 lines
11 KiB
Go
package resolver
|
|
|
|
import (
|
|
"iter"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// countingStore wraps a graph.Store and counts the read paths the scoped global
|
|
// passes take, so a test can prove a scoped run drives off the per-repo readers
|
|
// (GetRepoEdges / GetRepoNodes) instead of the whole-graph scans and materialises
|
|
// fewer nodes. It deliberately does NOT re-expose the optional capabilities
|
|
// (FnValuePlaceholderScanner, NodesByKindsScanner, …) of the wrapped store, so
|
|
// both the scoped and unscoped runs under test take the same generic fallback
|
|
// paths and the comparison isolates the scope effect.
|
|
type countingStore struct {
|
|
graph.Store
|
|
edgesByKind int
|
|
repoEdges map[string]int
|
|
nodesReturned int // total *Node materialised via node-returning reads
|
|
}
|
|
|
|
func newCountingStore(s graph.Store) *countingStore {
|
|
return &countingStore{Store: s, repoEdges: map[string]int{}}
|
|
}
|
|
|
|
func (c *countingStore) EdgesByKind(k graph.EdgeKind) iter.Seq[*graph.Edge] {
|
|
c.edgesByKind++
|
|
return c.Store.EdgesByKind(k)
|
|
}
|
|
|
|
func (c *countingStore) GetRepoEdges(prefix string) []*graph.Edge {
|
|
c.repoEdges[prefix]++
|
|
return c.Store.GetRepoEdges(prefix)
|
|
}
|
|
|
|
func (c *countingStore) GetFileNodes(path string) []*graph.Node {
|
|
ns := c.Store.GetFileNodes(path)
|
|
c.nodesReturned += len(ns)
|
|
return ns
|
|
}
|
|
|
|
func (c *countingStore) FindNodesByName(name string) []*graph.Node {
|
|
ns := c.Store.FindNodesByName(name)
|
|
c.nodesReturned += len(ns)
|
|
return ns
|
|
}
|
|
|
|
func (c *countingStore) NodesByKind(k graph.NodeKind) iter.Seq[*graph.Node] {
|
|
inner := c.Store.NodesByKind(k)
|
|
return func(yield func(*graph.Node) bool) {
|
|
for n := range inner {
|
|
c.nodesReturned++
|
|
if !yield(n) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func overrideEdgeSet(g graph.Store) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind == graph.EdgeOverrides {
|
|
out[e.From+"->"+e.To] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// crossRepoOverrideFixture builds a child type in repo A whose supertype lives
|
|
// in the (unchanged) repo B, so the override edge Derived.Do -> Base.Do spans a
|
|
// repo boundary. InferOverrides has no same-repo gate, so this pair is real.
|
|
func crossRepoOverrideFixture() *graph.Graph {
|
|
g := graph.New()
|
|
// Repo B (unchanged): the parent.
|
|
g.AddNode(&graph.Node{ID: "B::b.go::Base", Kind: graph.KindType, Name: "Base", RepoPrefix: "B", FilePath: "b.go"})
|
|
g.AddNode(&graph.Node{ID: "B::b.go::Base.Do", Kind: graph.KindMethod, Name: "Do", RepoPrefix: "B", FilePath: "b.go"})
|
|
g.AddEdge(&graph.Edge{From: "B::b.go::Base.Do", To: "B::b.go::Base", Kind: graph.EdgeMemberOf})
|
|
// Repo A (changed): the child extending the cross-repo parent.
|
|
g.AddNode(&graph.Node{ID: "A::a.go::Derived", Kind: graph.KindType, Name: "Derived", RepoPrefix: "A", FilePath: "a.go"})
|
|
g.AddNode(&graph.Node{ID: "A::a.go::Derived.Do", Kind: graph.KindMethod, Name: "Do", RepoPrefix: "A", FilePath: "a.go"})
|
|
g.AddEdge(&graph.Edge{From: "A::a.go::Derived.Do", To: "A::a.go::Derived", Kind: graph.EdgeMemberOf})
|
|
g.AddEdge(&graph.Edge{From: "A::a.go::Derived", To: "B::b.go::Base", Kind: graph.EdgeExtends, Origin: graph.OriginASTResolved})
|
|
return g
|
|
}
|
|
|
|
// TestInferOverridesScoped_CrossRepoBoundary asserts a scope that names ONLY the
|
|
// changed repo's types still re-derives a cross-repo override whose parent lives
|
|
// in an unchanged repo — the filter keeps a parent-edge row when EITHER endpoint
|
|
// is in scope, so the changed child is enough.
|
|
func TestInferOverridesScoped_CrossRepoBoundary(t *testing.T) {
|
|
full := crossRepoOverrideFixture()
|
|
New(full).InferOverrides()
|
|
want := overrideEdgeSet(full)
|
|
if len(want) != 1 {
|
|
t.Fatalf("setup: expected 1 cross-repo override from full pass, got %d: %v", len(want), want)
|
|
}
|
|
|
|
// Scope = repo A's type/interface IDs only (repo B did not change). The
|
|
// parent Base lives in B and is NOT in scope; the scoped pass must still
|
|
// re-derive Derived.Do -> Base.Do because the child Derived is in scope.
|
|
scoped := crossRepoOverrideFixture()
|
|
New(scoped).InferOverridesScoped(map[string]bool{"A::a.go::Derived": true})
|
|
got := overrideEdgeSet(scoped)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("scoped cross-repo override = %v, want %v", got, want)
|
|
}
|
|
for k := range want {
|
|
if !got[k] {
|
|
t.Errorf("scoped run dropped cross-repo override %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fnValueCandidate(from, filePath, name string) *graph.Edge {
|
|
return &graph.Edge{
|
|
From: from,
|
|
To: "unresolved::fnvalue::" + name,
|
|
Kind: graph.EdgeReferences,
|
|
FilePath: filePath,
|
|
Meta: map[string]any{
|
|
"via": fnValueCandidateVia,
|
|
metaFnValueName: name,
|
|
"fn_value_ungated": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func callbackEdgeSet(g graph.Store) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeReferences || e.Meta == nil {
|
|
continue
|
|
}
|
|
if via, _ := e.Meta["via"].(string); via == fnValueRegistrationVia {
|
|
out[e.From+"->"+e.To] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// crossRepoFnValueFixture builds a callback registration in repo A whose handler
|
|
// is a uniquely-named function in the (unchanged) repo B, plus a second
|
|
// registration wholly inside repo B. The scoped run over {A} must still bind
|
|
// A's callback to the B handler (resolution is whole-graph) while never touching
|
|
// B's own candidate.
|
|
func crossRepoFnValueFixture() *graph.Graph {
|
|
g := graph.New()
|
|
// Repo B: two handler functions plus a B-local callback registration.
|
|
g.AddNode(&graph.Node{ID: "B::b.go::HandlerB", Kind: graph.KindFunction, Name: "HandlerB", RepoPrefix: "B", FilePath: "b.go"})
|
|
g.AddNode(&graph.Node{ID: "B::b.go::HandlerB2", Kind: graph.KindFunction, Name: "HandlerB2", RepoPrefix: "B", FilePath: "b.go"})
|
|
g.AddNode(&graph.Node{ID: "B::b.go::RegisterB", Kind: graph.KindFunction, Name: "RegisterB", RepoPrefix: "B", FilePath: "b.go"})
|
|
g.AddEdge(fnValueCandidate("B::b.go::RegisterB", "b.go", "HandlerB2"))
|
|
// Repo A: a registration referencing the cross-repo handler by name.
|
|
g.AddNode(&graph.Node{ID: "A::a.go::RegisterA", Kind: graph.KindFunction, Name: "RegisterA", RepoPrefix: "A", FilePath: "a.go"})
|
|
g.AddEdge(fnValueCandidate("A::a.go::RegisterA", "a.go", "HandlerB"))
|
|
return g
|
|
}
|
|
|
|
// TestResolveFnValueCallbacksScoped_CrossRepoHandler asserts the scoped fn-value
|
|
// gate binds a changed repo's callback to a handler in an UNCHANGED repo (the
|
|
// resolution side stays whole-graph), drives its candidate scan off GetRepoEdges
|
|
// rather than the whole-graph EdgeReferences scan, and materialises fewer nodes
|
|
// than the unscoped run (it never resolves the unchanged repo's own candidate).
|
|
func TestResolveFnValueCallbacksScoped_CrossRepoHandler(t *testing.T) {
|
|
// Unscoped: both candidates bind.
|
|
full := newCountingStore(crossRepoFnValueFixture())
|
|
if n := ResolveFnValueCallbacks(full); n != 2 {
|
|
t.Fatalf("unscoped fn-value: expected 2 bound callbacks, got %d", n)
|
|
}
|
|
fullEdges := callbackEdgeSet(full)
|
|
if !fullEdges["A::a.go::RegisterA->B::b.go::HandlerB"] {
|
|
t.Fatalf("unscoped did not bind the cross-repo callback: %v", fullEdges)
|
|
}
|
|
if full.edgesByKind == 0 {
|
|
t.Errorf("unscoped fn-value should scan EdgesByKind(references) whole-graph")
|
|
}
|
|
|
|
// Scoped over {A}: only A's candidate is gated, but it still binds to the B
|
|
// handler via the whole-graph name resolution.
|
|
scoped := newCountingStore(crossRepoFnValueFixture())
|
|
if n := ResolveFnValueCallbacksScoped(scoped, map[string]bool{"A": true}); n != 1 {
|
|
t.Fatalf("scoped fn-value over {A}: expected 1 bound callback, got %d", n)
|
|
}
|
|
scopedEdges := callbackEdgeSet(scoped)
|
|
if !scopedEdges["A::a.go::RegisterA->B::b.go::HandlerB"] {
|
|
t.Errorf("scoped run dropped the cross-repo callback binding: %v", scopedEdges)
|
|
}
|
|
if scopedEdges["B::b.go::RegisterB->B::b.go::HandlerB2"] {
|
|
t.Errorf("scoped run must NOT re-bind the unchanged repo's own candidate: %v", scopedEdges)
|
|
}
|
|
|
|
// Candidate scan path: scoped drives off GetRepoEdges(A), never the
|
|
// whole-graph EdgeReferences scan.
|
|
if scoped.edgesByKind != 0 {
|
|
t.Errorf("scoped fn-value must not scan EdgesByKind whole-graph, got %d calls", scoped.edgesByKind)
|
|
}
|
|
if scoped.repoEdges["A"] == 0 {
|
|
t.Errorf("scoped fn-value must scan GetRepoEdges(\"A\")")
|
|
}
|
|
// Fewer node reads: scoped skips resolving B's candidate entirely.
|
|
if scoped.nodesReturned >= full.nodesReturned {
|
|
t.Errorf("scoped run should materialise fewer nodes than unscoped: scoped=%d full=%d",
|
|
scoped.nodesReturned, full.nodesReturned)
|
|
}
|
|
}
|
|
|
|
func mkValueRefCandidate(from, filePath, name string) *graph.Edge {
|
|
return &graph.Edge{
|
|
From: from,
|
|
To: "unresolved::value::" + name,
|
|
Kind: graph.EdgeReads,
|
|
FilePath: filePath,
|
|
Meta: map[string]any{"via": valueRefCandidateVia, "name": name},
|
|
}
|
|
}
|
|
|
|
// boundValueRefs maps reader -> constant for every value-ref read the pass bound
|
|
// (via == value_ref); a still-unbound candidate keeps the value_ref_candidate
|
|
// marker and is absent from the map.
|
|
func boundValueRefs(g graph.Store) map[string]string {
|
|
out := map[string]string{}
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeReads || e.Meta == nil {
|
|
continue
|
|
}
|
|
if via, _ := e.Meta["via"].(string); via == valueRefVia {
|
|
out[e.From] = e.To
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func crossRepoValueRefFixture() *graph.Graph {
|
|
g := graph.New()
|
|
// Repo A (changed): distinctive constant + a same-file reader candidate.
|
|
g.AddNode(&graph.Node{ID: "A::a.go::MAX_SIZE", Kind: graph.KindConstant, Name: "MAX_SIZE", RepoPrefix: "A", FilePath: "a.go", StartLine: 1})
|
|
g.AddNode(&graph.Node{ID: "A::a.go::useA", Kind: graph.KindFunction, Name: "useA", RepoPrefix: "A", FilePath: "a.go", StartLine: 5})
|
|
g.AddEdge(mkValueRefCandidate("A::a.go::useA", "a.go", "MAX_SIZE"))
|
|
// Repo B (unchanged): its own distinctive constant + same-file reader.
|
|
g.AddNode(&graph.Node{ID: "B::b.go::MIN_SIZE", Kind: graph.KindConstant, Name: "MIN_SIZE", RepoPrefix: "B", FilePath: "b.go", StartLine: 1})
|
|
g.AddNode(&graph.Node{ID: "B::b.go::useB", Kind: graph.KindFunction, Name: "useB", RepoPrefix: "B", FilePath: "b.go", StartLine: 5})
|
|
g.AddEdge(mkValueRefCandidate("B::b.go::useB", "b.go", "MIN_SIZE"))
|
|
return g
|
|
}
|
|
|
|
// TestResolveValueRefsScoped_CrossRepo asserts the scoped value-ref pass binds
|
|
// only the changed repo's candidate (leaving the unchanged repo's on-disk
|
|
// binding untouched), and drives its candidate scan off GetRepoEdges rather than
|
|
// the whole-graph EdgeReads scan. The unscoped pass binds both, proving the
|
|
// scoped pass is a strict narrowing of the same resolution.
|
|
func TestResolveValueRefsScoped_CrossRepo(t *testing.T) {
|
|
full := crossRepoValueRefFixture()
|
|
if n := ResolveValueRefs(full); n != 2 {
|
|
t.Fatalf("unscoped value-ref: expected 2 bound reads, got %d", n)
|
|
}
|
|
fb := boundValueRefs(full)
|
|
if fb["A::a.go::useA"] != "A::a.go::MAX_SIZE" || fb["B::b.go::useB"] != "B::b.go::MIN_SIZE" {
|
|
t.Fatalf("unscoped value-ref bindings wrong: %v", fb)
|
|
}
|
|
|
|
scoped := newCountingStore(crossRepoValueRefFixture())
|
|
if n := ResolveValueRefsScoped(scoped, map[string]bool{"A": true}); n != 1 {
|
|
t.Fatalf("scoped value-ref over {A}: expected 1 bound read, got %d", n)
|
|
}
|
|
sb := boundValueRefs(scoped)
|
|
if sb["A::a.go::useA"] != "A::a.go::MAX_SIZE" {
|
|
t.Errorf("scoped run dropped repo A's value-ref binding: %v", sb)
|
|
}
|
|
if _, ok := sb["B::b.go::useB"]; ok {
|
|
t.Errorf("scoped run must not bind the unchanged repo B's value-ref: %v", sb)
|
|
}
|
|
if scoped.edgesByKind != 0 {
|
|
t.Errorf("scoped value-ref must not scan EdgesByKind whole-graph, got %d calls", scoped.edgesByKind)
|
|
}
|
|
if scoped.repoEdges["A"] == 0 {
|
|
t.Errorf("scoped value-ref must scan GetRepoEdges(\"A\")")
|
|
}
|
|
}
|