Files
zzet--gortex/internal/resolver/php_override_dispatch_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

233 lines
9.0 KiB
Go

package resolver
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
func phpType(s graph.Store, id, name string, meta map[string]any) {
s.AddNode(&graph.Node{ID: id, Kind: graph.KindType, Name: name, FilePath: id, Language: "php", Meta: meta})
}
func phpIface(s graph.Store, id, name string, meta map[string]any) {
s.AddNode(&graph.Node{ID: id, Kind: graph.KindInterface, Name: name, FilePath: id, Language: "php", Meta: meta})
}
func phpMethod(s graph.Store, id, name, receiver string) {
s.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: name, FilePath: id, Language: "php",
Meta: map[string]any{"receiver": receiver}})
}
// Interface-typed receiver: an ambiguous `$h->handle()` fans out to the
// interface declaration + every in-repo implementation, related through the
// `implements` clause (scope_interfaces). Edges land at ast_inferred so they
// are visible in default find_usages.
func TestResolvePHPOverrideDispatch_InterfaceFanOut(t *testing.T) {
var s graph.Store = graph.New()
iface := "src/HandlerInterface.php"
th := "src/TestHandler.php"
sh := "src/StreamHandler.php"
app := "src/app.php"
phpIface(s, iface+"::HandlerInterface", "HandlerInterface", nil)
phpMethod(s, iface+"::HandlerInterface.handle", "handle", "HandlerInterface")
phpType(s, th+"::TestHandler", "TestHandler", map[string]any{"scope_interfaces": "HandlerInterface"})
phpMethod(s, th+"::TestHandler.handle", "handle", "TestHandler")
phpType(s, sh+"::StreamHandler", "StreamHandler", map[string]any{"scope_interfaces": "HandlerInterface"})
phpMethod(s, sh+"::StreamHandler.handle", "handle", "StreamHandler")
caller := app + "::run"
s.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "run", FilePath: app, Language: "php"})
s.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.handle", Kind: graph.EdgeCalls, FilePath: app, Line: 5})
r := New(s)
n := r.resolvePHPOverrideDispatch()
assert.Positive(t, n, "at least one dispatch edge should be bound")
for _, target := range []string{
iface + "::HandlerInterface.handle",
th + "::TestHandler.handle",
sh + "::StreamHandler.handle",
} {
var found *graph.Edge
for _, in := range s.GetInEdges(target) {
if in.From == caller && in.Kind == graph.EdgeCalls {
found = in
break
}
}
require.NotNil(t, found, "expected fan-out call edge run→%s", target)
assert.Equal(t, graph.OriginASTInferred, found.Origin, "dispatch edge is ast_inferred (visible)")
assert.Equal(t, "override", found.Meta["dispatch"])
assert.Equal(t, 5, found.Line, "edge anchored at the call site")
}
// The ambiguous stub is gone.
for _, e := range s.GetOutEdges(caller) {
assert.NotEqual(t, "unresolved::*.handle", e.To)
}
}
// parent::__construct() must bind through a 3-deep extends chain (Leaf → Mid →
// Base, only Base declares the constructor), a precise single bind.
func TestResolvePHPOverrideDispatch_ParentConstructChain(t *testing.T) {
var s graph.Store = graph.New()
base := "src/Base.php"
mid := "src/Mid.php"
leaf := "src/Leaf.php"
phpType(s, base+"::Base", "Base", nil)
phpMethod(s, base+"::Base.__construct", "__construct", "Base")
phpType(s, mid+"::Mid", "Mid", map[string]any{"scope_parent": "Base"})
phpType(s, leaf+"::Leaf", "Leaf", map[string]any{"scope_parent": "Mid"})
phpMethod(s, leaf+"::Leaf.__construct", "__construct", "Leaf")
caller := leaf + "::Leaf.__construct"
s.AddEdge(&graph.Edge{
From: caller, To: "unresolved::*.__construct", Kind: graph.EdgeCalls,
FilePath: leaf, Line: 7, Meta: map[string]any{"scope_kind": "parent"},
})
r := New(s)
r.resolvePHPOverrideDispatch()
var bound *graph.Edge
for _, e := range s.GetOutEdges(caller) {
if e.Kind == graph.EdgeCalls && e.To == base+"::Base.__construct" {
bound = e
break
}
}
require.NotNil(t, bound, "parent::__construct must bind to Base.__construct up the 3-deep chain")
assert.Equal(t, graph.OriginASTInferred, bound.Origin)
assert.Equal(t, "scope", bound.Meta["dispatch"])
for _, e := range s.GetOutEdges(caller) {
assert.NotEqual(t, "unresolved::*.__construct", e.To)
}
}
// A trait-provided method binds via `use`: Button extends Widget, Widget uses
// trait Renderer (an EdgeExtends), so `$x->render()` fans out to Renderer.render
// (the trait) as well as Button.render — related through the trait in the
// ancestor closure.
func TestResolvePHPOverrideDispatch_TraitProvidedMethod(t *testing.T) {
var s graph.Store = graph.New()
trait := "src/Renderer.php"
widget := "src/Widget.php"
button := "src/Button.php"
app := "src/paint.php"
phpType(s, trait+"::Renderer", "Renderer", map[string]any{"type_flavor": "trait", "kind": "trait"})
phpMethod(s, trait+"::Renderer.render", "render", "Renderer")
phpType(s, widget+"::Widget", "Widget", nil)
// Widget uses the Renderer trait — modelled as an EdgeExtends to the stub.
s.AddEdge(&graph.Edge{From: widget + "::Widget", To: "unresolved::Renderer", Kind: graph.EdgeExtends,
FilePath: widget, Line: 3, Meta: map[string]any{"via": "trait"}})
phpType(s, button+"::Button", "Button", map[string]any{"scope_parent": "Widget"})
phpMethod(s, button+"::Button.render", "render", "Button")
caller := app + "::paint"
s.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "paint", FilePath: app, Language: "php"})
s.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.render", Kind: graph.EdgeCalls, FilePath: app, Line: 4})
r := New(s)
r.resolvePHPOverrideDispatch()
for _, target := range []string{trait + "::Renderer.render", button + "::Button.render"} {
var found bool
for _, in := range s.GetInEdges(target) {
if in.From == caller && in.Kind == graph.EdgeCalls {
found = true
}
}
assert.True(t, found, "expected fan-out call edge paint→%s (trait-related)", target)
}
}
// A phpdoc @method virtual node joins the member-call binding population: a
// unique-name call to it resolves like any other method (proving W4's virtual
// nodes are first-class in the resolver, not just the extractor).
func TestResolvePHP_PhpdocVirtualIsBindable(t *testing.T) {
var s graph.Store = graph.New()
f := "src/Foo.php"
app := "src/a.php"
phpType(s, f+"::Foo", "Foo", nil)
s.AddNode(&graph.Node{ID: f + "::Foo.magicAccessor", Kind: graph.KindMethod, Name: "magicAccessor",
FilePath: f, Language: "php", Meta: map[string]any{"receiver": "Foo", "virtual": "phpdoc_method"}})
caller := app + "::run"
s.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "run", FilePath: app, Language: "php"})
s.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.magicAccessor", Kind: graph.EdgeCalls, FilePath: app, Line: 2})
r := New(s)
r.ResolveAll()
var bound bool
for _, e := range s.GetOutEdges(caller) {
if e.Kind == graph.EdgeCalls && e.To == f+"::Foo.magicAccessor" {
bound = true
}
}
assert.True(t, bound, "a call to a unique phpdoc @method virtual must bind to it")
}
// Precision guard: same-name methods on UNRELATED types (no shared ancestor)
// must never be sprayed together.
func TestResolvePHPOverrideDispatch_UnrelatedNotFannedOut(t *testing.T) {
var s graph.Store = graph.New()
a := "src/Alpha.php"
b := "src/Beta.php"
app := "src/main.php"
phpType(s, a+"::Alpha", "Alpha", nil)
phpMethod(s, a+"::Alpha.save", "save", "Alpha")
phpType(s, b+"::Beta", "Beta", nil)
phpMethod(s, b+"::Beta.save", "save", "Beta")
caller := app + "::go"
s.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "go", FilePath: app, Language: "php"})
s.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.save", Kind: graph.EdgeCalls, FilePath: app, Line: 3})
r := New(s)
r.resolvePHPOverrideDispatch()
// Unrelated save() methods share no ancestor → the call stays ambiguous.
var stillUnresolved bool
for _, e := range s.GetOutEdges(caller) {
if e.To == "unresolved::*.save" {
stillUnresolved = true
}
}
assert.True(t, stillUnresolved, "unrelated same-name methods must not be fanned out")
}
// Idempotency: a second pass over an already-bound graph must not duplicate
// the fan-out edges.
func TestResolvePHPOverrideDispatch_Idempotent(t *testing.T) {
var s graph.Store = graph.New()
iface := "src/I.php"
x := "src/X.php"
y := "src/Y.php"
app := "src/a.php"
phpIface(s, iface+"::I", "I", nil)
phpMethod(s, iface+"::I.run", "run", "I")
phpType(s, x+"::X", "X", map[string]any{"scope_interfaces": "I"})
phpMethod(s, x+"::X.run", "run", "X")
phpType(s, y+"::Y", "Y", map[string]any{"scope_interfaces": "I"})
phpMethod(s, y+"::Y.run", "run", "Y")
caller := app + "::z"
s.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "z", FilePath: app, Language: "php"})
s.AddEdge(&graph.Edge{From: caller, To: "unresolved::*.run", Kind: graph.EdgeCalls, FilePath: app, Line: 2})
r := New(s)
r.resolvePHPOverrideDispatch()
countAfterFirst := len(s.GetOutEdges(caller))
// Re-stub the primary to simulate a restub→re-resolve cycle.
r.resolvePHPOverrideDispatch()
assert.Equal(t, countAfterFirst, len(s.GetOutEdges(caller)),
"re-running the pass must not duplicate fan-out edges")
}