Files
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

354 lines
10 KiB
Go

package resolver
import (
"strings"
"github.com/zzet/gortex/internal/graph"
)
// phpOverrideDispatchCap bounds how many overrides a single ambiguous PHP
// 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.
const phpOverrideDispatchCap = 8
// resolvePHPOverrideDispatch binds ambiguous PHP member / scoped calls that
// static resolution left unresolved, using the class hierarchy:
//
// - scope_kind parent|self → the enclosing class's ancestor chain is
// walked to the nearest type declaring the method (a precise single
// bind). This makes parent::__construct() resolve through a multi-level
// extends chain, not only the direct parent.
// - plain member calls → when the same-name candidates form an
// override family related through the hierarchy (a common interface /
// abstract base / used trait), the call fans out to every override — the
// fan-out-to-implementations semantics a language server presents.
//
// Edges land at the ast_inferred tier (visible in default find_usages, unlike
// the speculative Java analogue) because the PHP dispatch surface is the
// primary recall source for the language and the relatedness gate keeps
// precision high: an untyped receiver whose candidates share no common
// ancestor is left unresolved rather than sprayed repo-wide. Runs AFTER the
// cross-package guard so its edges are never reverted. PHP-only.
func (r *Resolver) resolvePHPOverrideDispatch() int {
g := r.graph
if g == nil {
return 0
}
direct, closure := r.phpTypeHierarchy()
if len(direct) == 0 {
return 0
}
type job struct {
edge *graph.Edge
single *graph.Node // scope-bind target; nil for a fan-out job
base *graph.Node
others []*graph.Node
}
var jobs []job
// Collect first — mutating the graph while ranging EdgesByKind is unsafe.
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 != "php" {
continue
}
repo := r.callerRepoPrefix(e)
// Scope path: parent:: / self:: / static:: bind precisely up the
// enclosing class's ancestor chain.
if sk := phpEdgeMetaString(e, "scope_kind"); sk == "parent" || sk == "self" {
encloser := phpEnclosingClass(caller)
if encloser == "" {
continue
}
var start []string
if sk == "parent" {
for p := range direct[encloser] {
start = append(start, p)
}
} else {
start = []string{encloser}
}
if len(start) == 0 {
continue
}
if target := r.nearestPHPMethod(name, start, direct, repo); target != nil && target.ID != caller.ID {
jobs = append(jobs, job{edge: e, single: target})
}
continue
}
// Fan-out path: a plain member call whose same-name candidates are an
// override family related through the hierarchy.
cands := phpOverrideCandidates(r.cachedFindNodesByNameInRepo(name, repo))
if len(cands) < 2 || len(cands) > phpOverrideDispatchCap {
continue
}
if !javaOverridesRelated(cands, closure) {
continue
}
jobs = append(jobs, job{edge: e, base: cands[0], others: cands[1:]})
}
n := 0
for _, j := range jobs {
if j.single != nil {
if j.edge.To == j.single.ID {
continue
}
oldTo := j.edge.To
j.edge.To = j.single.ID
j.edge.Origin = graph.OriginASTInferred
if j.edge.Confidence < phpDispatchConfidence {
j.edge.Confidence = phpDispatchConfidence
}
phpEnsureMeta(j.edge)["dispatch"] = "scope"
g.ReindexEdges([]graph.EdgeReindex{{Edge: j.edge, OldTo: oldTo}})
n++
continue
}
oldTo := j.edge.To
j.edge.To = j.base.ID
j.edge.Origin = graph.OriginASTInferred
j.edge.Confidence = phpDispatchConfidence
phpEnsureMeta(j.edge)["dispatch"] = "override"
g.ReindexEdges([]graph.EdgeReindex{{Edge: j.edge, OldTo: oldTo}})
n++
for _, o := range j.others {
if phpEdgeExists(g, j.edge.From, o.ID) {
continue // idempotent: the extra override edge is already present
}
g.AddEdge(&graph.Edge{
From: j.edge.From, To: o.ID, Kind: graph.EdgeCalls,
FilePath: j.edge.FilePath, Line: j.edge.Line,
Origin: graph.OriginASTInferred,
Confidence: phpDispatchConfidence,
Meta: map[string]any{"dispatch": "override"},
})
n++
}
}
return n
}
// phpDispatchConfidence is the confidence stamped on a dispatch-resolved edge
// — in the ast_inferred band so DefaultOriginFor agrees with the explicit
// Origin and the read path keeps the edge visible.
const phpDispatchConfidence = 0.7
// phpTypeHierarchy builds the PHP class hierarchy: `direct` maps each type's
// simple name to its DIRECT parents (superclass via scope_parent, implemented
// / extended interfaces via scope_interfaces, used traits via EdgeExtends),
// and `closure` is the transitive ancestor set used by the relatedness gate.
func (r *Resolver) phpTypeHierarchy() (direct, closure map[string]map[string]bool) {
g := r.graph
direct = map[string]map[string]bool{}
add := func(child, parent string) {
child = phpBaseTypeName(child)
parent = phpBaseTypeName(parent)
if child == "" || parent == "" || child == parent {
return
}
set := direct[child]
if set == nil {
set = map[string]bool{}
direct[child] = set
}
set[parent] = true
}
for _, kind := range []graph.NodeKind{graph.KindType, graph.KindInterface} {
for n := range g.NodesByKind(kind) {
if n == nil || n.Language != "php" || n.Name == "" || n.Meta == nil {
continue
}
if p, ok := n.Meta[MetaScopeParentClass].(string); ok {
add(n.Name, p)
}
if ifaces, ok := n.Meta["scope_interfaces"].(string); ok && ifaces != "" {
for _, iface := range strings.Split(ifaces, ",") {
add(n.Name, iface)
}
}
}
}
// Trait `use` (and any other extends-shaped) relationship is a graph edge.
for e := range g.EdgesByKind(graph.EdgeExtends) {
if e == nil {
continue
}
from := r.cachedGetNode(e.From)
if from == nil || from.Language != "php" || from.Name == "" {
continue
}
add(from.Name, phpEdgeTargetName(g, e.To))
}
if len(direct) == 0 {
return direct, nil
}
closure = make(map[string]map[string]bool, len(direct))
var visit func(t string, acc, 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 direct, closure
}
// nearestPHPMethod walks up the hierarchy from start (breadth-first, nearest
// first) to the first ancestor type declaring method name, returning that
// method node. Used by the parent::/self:: scope bind.
func (r *Resolver) nearestPHPMethod(name string, start []string, direct map[string]map[string]bool, repo string) *graph.Node {
byRecv := map[string]*graph.Node{}
for _, m := range r.cachedFindNodesByNameInRepo(name, repo) {
if m == nil || m.Language != "php" || m.Kind != graph.KindMethod {
continue
}
if graph.IsStub(m.ID) || graph.IsUnresolvedTarget(m.ID) {
continue
}
if rc := nodeReceiverType(m); rc != "" {
if _, ok := byRecv[rc]; !ok {
byRecv[rc] = m
}
}
}
if len(byRecv) == 0 {
return nil
}
visited := map[string]bool{}
queue := append([]string{}, start...)
for _, s := range start {
visited[s] = true
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if m := byRecv[cur]; m != nil {
return m
}
for p := range direct[cur] {
if !visited[p] {
visited[p] = true
queue = append(queue, p)
}
}
}
return nil
}
// phpOverrideCandidates filters name-matched nodes to in-repo PHP method
// definitions, one per declaring type (deduped by receiver), excluding stubs
// and definitions with no declaring type.
func phpOverrideCandidates(raw []*graph.Node) []*graph.Node {
var out []*graph.Node
seen := map[string]bool{}
for _, n := range raw {
if n == nil || n.Language != "php" || 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
}
// phpEnclosingClass returns the simple name of the class a caller node belongs
// to (its receiver / scope_class), or "" for a free function.
func phpEnclosingClass(caller *graph.Node) string {
if caller == nil || caller.Meta == nil {
return ""
}
if r, ok := caller.Meta["receiver"].(string); ok && r != "" {
return phpBaseTypeName(r)
}
if r, ok := caller.Meta["scope_class"].(string); ok && r != "" {
return phpBaseTypeName(r)
}
return ""
}
// phpEdgeExists reports whether a call edge from→to already exists (used to
// keep the fan-out idempotent across re-resolution).
func phpEdgeExists(g graph.Store, from, to string) bool {
for _, e := range g.GetOutEdges(from) {
if e != nil && e.To == to && e.Kind == graph.EdgeCalls {
return true
}
}
return false
}
// phpEdgeTargetName resolves an edge target id to a simple type name, handling
// both an unresolved::<name> stub and a resolved node id.
func phpEdgeTargetName(g graph.Store, to string) string {
if graph.IsUnresolvedTarget(to) {
return graph.UnresolvedName(to)
}
if n := g.GetNode(to); n != nil {
return n.Name
}
return graph.UnresolvedName(to)
}
// phpBaseTypeName reduces a possibly namespace-qualified PHP type reference to
// its simple name (`App\Service\User` → `User`, `\Foo` → `Foo`).
func phpBaseTypeName(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, `\`)
if i := strings.LastIndexByte(s, '\\'); i >= 0 {
s = s[i+1:]
}
return strings.TrimSpace(s)
}
// phpEdgeMetaString reads a string-valued edge meta key, "" when absent.
func phpEdgeMetaString(e *graph.Edge, key string) string {
if e == nil || e.Meta == nil {
return ""
}
if s, ok := e.Meta[key].(string); ok {
return s
}
return ""
}
// phpEnsureMeta returns the edge's meta map, allocating it when nil.
func phpEnsureMeta(e *graph.Edge) map[string]any {
if e.Meta == nil {
e.Meta = map[string]any{}
}
return e.Meta
}