diff --git a/internal/parser/languages/helpers_complexity.go b/internal/parser/languages/helpers_complexity.go
index bc2d7760..a2ba9cd6 100644
--- a/internal/parser/languages/helpers_complexity.go
+++ b/internal/parser/languages/helpers_complexity.go
@@ -295,6 +295,46 @@ func MaxLoopDepth(body *sitter.Node, loopTypes, skipDescent map[string]bool) int
return maxDepth
}
+// PHP. `else_if_clause` is a decision point but a bare `else_clause` is not;
+// a `match` arm (match_conditional_expression) counts like a switch case.
+// PHP has no separate ternary node — `conditional_expression` covers both
+// `?:` and the short form.
+var phpComplexityNodes = map[string]bool{
+ "if_statement": true,
+ "else_if_clause": true,
+ "for_statement": true,
+ "foreach_statement": true,
+ "while_statement": true,
+ "do_statement": true,
+ "case_statement": true,
+ "match_conditional_expression": true,
+ "catch_clause": true,
+ "conditional_expression": true,
+}
+
+var phpNestingTypes = map[string]bool{
+ "if_statement": true, "for_statement": true, "foreach_statement": true,
+ "while_statement": true, "do_statement": true, "switch_statement": true,
+ "match_expression": true, "catch_clause": true, "try_statement": true,
+}
+
+var phpLoopTypes = map[string]bool{
+ "for_statement": true, "foreach_statement": true,
+ "while_statement": true, "do_statement": true,
+}
+
+// A nested declaration owns its own metrics, so the walk stops there rather
+// than folding a closure's branches into its enclosing function's score.
+var phpComplexitySkip = map[string]bool{
+ "function_definition": true,
+ "method_declaration": true,
+ "anonymous_function": true,
+ "anonymous_function_creation_expression": true,
+ "arrow_function": true,
+ "class_declaration": true,
+ "anonymous_class": true,
+}
+
// complexityTables bundles the per-language node-type tables so a single
// stamping helper can serve every extractor.
type complexityTables struct {
@@ -313,6 +353,7 @@ var langComplexityTables = map[string]complexityTables{
"python": {pyComplexityNodes, pyNestingTypes, pyLoopTypes, pyComplexitySkip},
"rust": {rustComplexityNodes, rustNestingTypes, rustLoopTypes, rustComplexitySkip},
"java": {javaComplexityNodes, javaNestingTypes, javaLoopTypes, javaComplexitySkip},
+ "php": {phpComplexityNodes, phpNestingTypes, phpLoopTypes, phpComplexitySkip},
}
// StampFunctionMetrics computes cyclomatic + cognitive complexity and max
diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go
index 8f87258c..9c200f45 100644
--- a/internal/parser/languages/php.go
+++ b/internal/parser/languages/php.go
@@ -23,8 +23,13 @@ func NewPHPExtractor() *PHPExtractor {
func (e *PHPExtractor) Language() string { return "php" }
func (e *PHPExtractor) Extensions() []string {
// Drupal module files (.module/.install/.inc/.theme/.profile/.engine) are
- // PHP source whose function names follow the hook convention.
- return []string{".php", ".module", ".install", ".inc", ".theme", ".profile", ".engine"}
+ // PHP source whose function names follow the hook convention. `.phtml` is
+ // the PHP-template extension Zend / Laminas / Magento use, and the grammar
+ // is the HTML-framing one, so a template parses as PHP already.
+ return []string{
+ ".php", ".phtml",
+ ".module", ".install", ".inc", ".theme", ".profile", ".engine",
+ }
}
func (e *PHPExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
@@ -49,6 +54,10 @@ func (e *PHPExtractor) Extract(filePath string, src []byte) (*parser.ExtractionR
// Walk the AST manually since PHP tree-sitter queries can be tricky.
e.walkNode(root, src, filePath, fileNode, result, seen, "")
+ // Statements outside every declaration — a script body, a bootstrap file,
+ // a routes file, a fixture returning a closure — belong to the file node.
+ e.extractPHPFileScope(root, src, filePath, fileNode, result)
+
captureValueRefCandidates(result, root, filePath, src)
captureFnValueCandidates(result, root, filePath, src)
e.capturePHPStringCallables(result, root, filePath, src)
@@ -93,9 +102,25 @@ func (e *PHPExtractor) walkNode(
case "function_definition":
e.extractFunction(node, src, filePath, fileNode, result, seen)
+ case "anonymous_class":
+ e.extractAnonymousClass(node, src, filePath, fileNode, result, seen)
+
case "namespace_use_declaration":
e.extractUseImport(node, src, filePath, fileNode, result)
+ case "const_declaration":
+ // Reached only at file scope: a class body's const_declaration is
+ // consumed by extractPhpMembers, which never recurses back here.
+ e.extractPHPFileConstant(node, src, filePath, fileNode, result, seen)
+ return
+
+ case "function_call_expression":
+ // `define('NAME', …)` is PHP's other constant declaration, and it is
+ // an ordinary call with no declaration node of its own.
+ e.extractPHPDefineConstant(node, src, filePath, fileNode, result, seen)
+ e.walkChildren(node, src, filePath, fileNode, result, seen, currentClass)
+ return
+
case "expression_statement":
// Check for require/include calls.
e.extractRequireInclude(node, src, filePath, fileNode, result)
@@ -292,14 +317,25 @@ func (e *PHPExtractor) extractPhpMembers(
ownerName, ownerID string,
) map[string]*sitter.Node {
methodNodes := make(map[string]*sitter.Node)
+ // Property types are read once for the whole body so every method's call
+ // sites can type a `$this->prop->m()` receiver, including properties
+ // declared after the method that uses them.
+ props := phpClassPropertyTypes(body, src)
for i, _nc := 0, int(body.NamedChildCount()); i < _nc; i++ {
child := body.NamedChild(i)
switch child.Type() {
case "method_declaration":
+ name := ""
if n := e.findChildByFieldName(child, "name"); n != nil {
- methodNodes[n.Content(src)] = child
+ name = n.Content(src)
+ methodNodes[name] = child
+ }
+ e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName, props)
+ // PHP 8 promotion declares properties in the constructor's
+ // parameter list, so there is no property_declaration to find.
+ if strings.EqualFold(name, "__construct") {
+ e.extractPHPPromotedProperties(child, src, filePath, fileNode, result, seen, ownerName, ownerID)
}
- e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName)
case "const_declaration":
e.extractPhpClassConst(child, src, filePath, fileNode, result, seen, ownerName, ownerID)
case "property_declaration":
@@ -373,6 +409,12 @@ func (e *PHPExtractor) extractEnum(
if bt := e.findChildByType(node, "primitive_type"); bt != nil {
meta["backing_type"] = strings.TrimSpace(bt.Content(src))
}
+ // A PHP enum may implement interfaces. Without scope_interfaces the
+ // dispatch hierarchy cannot see the enum as an implementor, so a call
+ // through the interface never reaches the enum's methods.
+ if ifaces := phpTypeClauseNames(node, src, "class_interface_clause"); len(ifaces) > 0 {
+ meta["scope_interfaces"] = strings.Join(ifaces, ",")
+ }
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
@@ -456,6 +498,7 @@ func (e *PHPExtractor) extractPhpProperty(
continue
}
meta := map[string]any{"receiver": ownerName, "visibility": vis}
+ phpMemberModifiers(node, meta)
if propType != "" {
meta["field_type"] = propType
}
@@ -531,7 +574,7 @@ func phpPropertyType(node *sitter.Node, src []byte) string {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
switch c.Type() {
- case "primitive_type", "named_type", "union_type", "nullable_type", "intersection_type", "optional_type", "qualified_name":
+ case "primitive_type", "named_type", "union_type", "disjunctive_normal_form_type", "intersection_type", "optional_type", "qualified_name":
return strings.TrimSpace(c.Content(src))
case "property_element":
return ""
@@ -581,7 +624,11 @@ func emitPHPTypeUseEdges(ownerID, typeText, filePath string, line int, result *p
return
}
seen := map[string]bool{}
- for _, atom := range strings.FieldsFunc(typeText, func(r rune) bool { return r == '|' || r == '&' }) {
+ // PHP 8.2 disjunctive normal form — `(Countable&Traversable)|null` —
+ // parenthesises its intersection groups, so the parens are separators too;
+ // without them an atom reads as `(Countable` and mints a bogus target.
+ isSep := func(r rune) bool { return r == '|' || r == '&' || r == '(' || r == ')' }
+ for _, atom := range strings.FieldsFunc(typeText, isSep) {
t := canonicalizePHPTypeRef(atom)
if t == "" || phpBuiltinType(t) || seen[t] {
continue
@@ -613,7 +660,7 @@ func phpReturnType(node *sitter.Node, src []byte) string {
continue
}
switch t {
- case "primitive_type", "named_type", "union_type", "nullable_type", "intersection_type", "optional_type", "qualified_name", "bottom_type":
+ case "primitive_type", "named_type", "union_type", "disjunctive_normal_form_type", "intersection_type", "optional_type", "qualified_name", "bottom_type":
return strings.TrimSpace(c.Content(src))
case "compound_statement":
return ""
@@ -653,7 +700,7 @@ func phpParameterType(node *sitter.Node, src []byte) string {
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
c := node.NamedChild(i)
switch c.Type() {
- case "primitive_type", "named_type", "union_type", "nullable_type",
+ case "primitive_type", "named_type", "union_type", "disjunctive_normal_form_type",
"intersection_type", "optional_type", "qualified_name":
return strings.TrimSpace(c.Content(src))
case "variable_name":
@@ -705,10 +752,15 @@ func (e *PHPExtractor) extractFunction(
// Typed parameters → EdgeTypedAs usage edges on the function.
e.emitPHPParamTypeUseEdges(node, src, id, filePath, result)
- // Extract call sites within the function body.
+ // Extract call sites within the function body. A free function has no
+ // `$this`, so only its typed parameters and local `new` bindings type a
+ // receiver.
body := e.findChildByType(node, "compound_statement")
if body != nil {
- e.extractCallSites(body, src, filePath, id, result)
+ cyc, cog, loop := BodyComplexityMetrics(body, "php")
+ ApplyComplexityMeta(meta, cyc, cog, loop)
+ e.extractCallSitesInScope(body, src, filePath, id, result,
+ newPHPReceiverEnv(node, src, "", nil))
}
}
@@ -716,7 +768,7 @@ func (e *PHPExtractor) extractMethod(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult, seen map[string]bool,
- className string,
+ className string, props map[string]string,
) {
nameNode := e.findChildByFieldName(node, "name")
if nameNode == nil {
@@ -738,6 +790,7 @@ func (e *PHPExtractor) extractMethod(
"scope_class": className,
"visibility": phpMemberVisibility(node, src),
}
+ phpMemberModifiers(node, meta)
if doc := ExtractDocAbove(src, int(node.StartPoint().Row), DocLangBlockStar); doc != "" {
meta["doc"] = doc
}
@@ -771,10 +824,15 @@ func (e *PHPExtractor) extractMethod(
From: id, To: classID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine,
})
- // Extract call sites within the method body.
+ // Extract call sites within the method body. `$this` is the declaring
+ // class, so every unqualified member call in the body carries a receiver
+ // type the resolver can bind exactly.
body := e.findChildByType(node, "compound_statement")
if body != nil {
- e.extractCallSites(body, src, filePath, id, result)
+ cyc, cog, loop := BodyComplexityMetrics(body, "php")
+ ApplyComplexityMeta(meta, cyc, cog, loop)
+ e.extractCallSitesInScope(body, src, filePath, id, result,
+ newPHPReceiverEnv(node, src, className, props))
}
}
@@ -783,39 +841,118 @@ func (e *PHPExtractor) extractUseImport(
filePath string, fileNode *graph.Node,
result *parser.ExtractionResult,
) {
- // use_declaration children can be namespace_use_clause or namespace_name.
+ // A `use` statement carries an optional `function` / `const` qualifier and
+ // is either a flat list of clauses or a braced group sharing one prefix.
+ declKind := phpUseQualifier(node, src)
+
+ // Grouped form: `use App\Log\{Handler, Formatter as F, function make};`.
+ // The group's prefix is the declaration's own namespace_name; without
+ // joining the two, every imported member was dropped and only the shared
+ // prefix survived as an import.
+ if group := node.ChildByFieldName("body"); group != nil {
+ prefix := ""
+ if nn := e.findChildByType(node, "namespace_name"); nn != nil {
+ prefix = nn.Content(src)
+ }
+ for i, n := 0, int(group.NamedChildCount()); i < n; i++ {
+ clause := group.NamedChild(i)
+ if clause.Type() != "namespace_use_clause" {
+ continue
+ }
+ e.emitPHPUseClause(clause, src, filePath, fileNode, result, prefix, declKind)
+ }
+ return
+ }
+
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
child := node.NamedChild(i)
- var importPath string
switch child.Type() {
case "namespace_use_clause":
- nameNode := e.findChildByType(child, "qualified_name")
- if nameNode == nil {
- nameNode = e.findChildByType(child, "namespace_name")
- }
- if nameNode != nil {
- importPath = nameNode.Content(src)
- } else {
- importPath = child.Content(src)
- }
+ e.emitPHPUseClause(child, src, filePath, fileNode, result, "", declKind)
case "qualified_name", "namespace_name":
- importPath = child.Content(src)
- default:
- continue
+ e.emitPHPImportEdge(child.Content(src), "", declKind, filePath, fileNode,
+ int(child.StartPoint().Row)+1, result)
}
- if importPath == "" {
- continue
- }
- importPath = strings.TrimLeft(importPath, "\\")
- importPath = strings.ReplaceAll(importPath, "\\", "/")
- line := int(child.StartPoint().Row) + 1
- result.Edges = append(result.Edges, &graph.Edge{
- From: fileNode.ID, To: "unresolved::import::" + importPath,
- Kind: graph.EdgeImports, FilePath: filePath, Line: line,
- })
}
}
+// phpUseQualifier returns "function" / "const" for `use function ...` and
+// `use const ...`, or "" for an ordinary class import.
+func phpUseQualifier(node *sitter.Node, src []byte) string {
+ t := node.ChildByFieldName("type")
+ if t == nil {
+ return ""
+ }
+ return strings.ToLower(strings.TrimSpace(t.Content(src)))
+}
+
+// emitPHPUseClause records one imported name, joining the group prefix when the
+// clause came from a braced group and preferring the clause's own
+// function/const qualifier over the declaration's.
+func (e *PHPExtractor) emitPHPUseClause(
+ clause *sitter.Node, src []byte,
+ filePath string, fileNode *graph.Node,
+ result *parser.ExtractionResult,
+ prefix, declKind string,
+) {
+ nameNode := e.findChildByType(clause, "qualified_name")
+ if nameNode == nil {
+ nameNode = e.findChildByType(clause, "namespace_name")
+ }
+ if nameNode == nil {
+ nameNode = e.findChildByType(clause, "name")
+ }
+ name := ""
+ if nameNode != nil {
+ name = nameNode.Content(src)
+ }
+ if name == "" {
+ return
+ }
+ if prefix != "" {
+ name = strings.TrimRight(prefix, `\`) + `\` + strings.TrimLeft(name, `\`)
+ }
+ kind := declKind
+ if k := phpUseQualifier(clause, src); k != "" {
+ kind = k
+ }
+ alias := ""
+ if a := clause.ChildByFieldName("alias"); a != nil {
+ alias = strings.TrimSpace(a.Content(src))
+ }
+ e.emitPHPImportEdge(name, alias, kind, filePath, fileNode,
+ int(clause.StartPoint().Row)+1, result)
+}
+
+// emitPHPImportEdge records one import. The target keeps the historical
+// slash-separated path shape; the imported symbol, its alias and whether the
+// import names a class, function or constant ride on the edge meta so a
+// consumer can recover the fully-qualified name the source wrote.
+func (e *PHPExtractor) emitPHPImportEdge(
+ name, alias, kind string,
+ filePath string, fileNode *graph.Node,
+ line int, result *parser.ExtractionResult,
+) {
+ name = strings.TrimLeft(strings.TrimSpace(name), `\`)
+ if name == "" {
+ return
+ }
+ edge := &graph.Edge{
+ From: fileNode.ID, To: "unresolved::import::" + strings.ReplaceAll(name, `\`, "/"),
+ Kind: graph.EdgeImports, FilePath: filePath, Line: line,
+ }
+ meta := map[string]any{"fqn": name, "symbol": canonicalizePHPTypeRef(name)}
+ if alias != "" {
+ meta["alias"] = alias
+ }
+ switch kind {
+ case "function", "const":
+ meta["import_kind"] = kind
+ }
+ edge.Meta = meta
+ result.Edges = append(result.Edges, edge)
+}
+
func (e *PHPExtractor) extractRequireInclude(
node *sitter.Node, src []byte,
filePath string, fileNode *graph.Node,
@@ -840,15 +977,51 @@ func (e *PHPExtractor) extractRequireInclude(
}
}
-func (e *PHPExtractor) extractCallSites(
+// extractCallSitesInScope walks a function body emitting its call and access
+// edges, carrying the receiver-typing environment of that body. Descending into
+// a closure derives a child scope rather than reusing the parent's, so a
+// closure parameter shadows an outer variable of the same name instead of
+// inheriting its type.
+func (e *PHPExtractor) extractCallSitesInScope(
node *sitter.Node, src []byte,
filePath string, callerID string,
result *parser.ExtractionResult,
+ env phpReceiverEnv,
+) {
+ if node.Type() == "anonymous_function" || node.Type() == "anonymous_function_creation_expression" ||
+ node.Type() == "arrow_function" {
+ env = env.childScope(node, src)
+ }
+ e.emitPHPCallSiteEdges(node, src, filePath, callerID, result, env)
+
+ // Recurse into children.
+ for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
+ child := node.NamedChild(i)
+ e.extractCallSitesInScope(child, src, filePath, callerID, result, env)
+ }
+}
+
+// emitPHPCallSiteEdges emits the call / access edges for one node, without
+// recursing. Both the in-function walker and the file-scope walker share it;
+// they differ only in where they stop descending.
+func (e *PHPExtractor) emitPHPCallSiteEdges(
+ node *sitter.Node, src []byte,
+ filePath string, callerID string,
+ result *parser.ExtractionResult,
+ env phpReceiverEnv,
) {
switch node.Type() {
+ case "member_access_expression", "nullsafe_member_access_expression",
+ "scoped_property_access_expression", "class_constant_access_expression":
+ e.emitPHPMemberAccess(node, src, filePath, callerID, result, env)
case "function_call_expression":
funcNode := node.ChildByFieldName("function")
- if funcNode != nil {
+ // A computed callee — `$fn()`, `($this->resolver)()`, `$handlers[0]()`
+ // — names no symbol. Emitting its source text produced targets like
+ // `unresolved::*.$fn` and `unresolved::*.($this->resolver)` that can
+ // never bind (371 of them in guzzle alone); the receiver-typed member
+ // call inside such an expression is captured by the walk regardless.
+ if funcNode != nil && phpNamesACallee(funcNode) {
name := funcNode.Content(src)
if idx := strings.LastIndex(name, "\\"); idx >= 0 {
name = name[idx+1:]
@@ -859,9 +1032,9 @@ func (e *PHPExtractor) extractCallSites(
Kind: graph.EdgeCalls, FilePath: filePath, Line: line,
})
}
- case "member_call_expression", "scoped_call_expression":
+ case "member_call_expression", "nullsafe_member_call_expression", "scoped_call_expression":
nameNode := node.ChildByFieldName("name")
- if nameNode != nil {
+ if nameNode != nil && phpNamesACallee(nameNode) {
name := nameNode.Content(src)
line := int(node.StartPoint().Row) + 1
edge := &graph.Edge{
@@ -905,34 +1078,38 @@ func (e *PHPExtractor) extractCallSites(
}
edge.Meta["eloquent_model"] = model
edge.Meta["eloquent_method"] = name
+ } else if rt := env.phpScopeReceiverType(scope, src); rt != "" {
+ // `Utils::pick()` / `$cls::make()` — the scope names
+ // the class the static method belongs to, which is
+ // the strongest receiver evidence PHP offers.
+ edge.Meta = map[string]any{"receiver_type": rt}
}
}
}
}
- // Chained-factory receiver: `make()->with()->build()` -- carry the
- // receiver object (`->` normalised to `.`) so the shared walker can
- // type the chain or preserve it for the graph-aware resolver.
- if node.Type() == "member_call_expression" {
+ if node.Type() == "member_call_expression" || node.Type() == "nullsafe_member_call_expression" {
obj := node.ChildByFieldName("object")
if obj == nil && node.NamedChildCount() > 0 {
obj = node.NamedChild(0)
}
if obj != nil {
- recv := strings.ReplaceAll(strings.TrimSpace(obj.Content(src)), "->", ".")
- if strings.Contains(recv, ".") || strings.Contains(recv, "(") {
- stampFactoryChainReceiver(edge, recv, resolveChainType(recv, nil, result))
+ if rt := env.phpReceiverTypeFor(obj, src); rt != "" {
+ edge.Meta = map[string]any{"receiver_type": rt}
+ } else {
+ // Chained-factory receiver: `make()->with()->build()` --
+ // carry the receiver object (`->` normalised to `.`) so
+ // the shared walker can type the chain or preserve it
+ // for the graph-aware resolver.
+ recv := strings.ReplaceAll(strings.TrimSpace(obj.Content(src)), "->", ".")
+ if strings.Contains(recv, ".") || strings.Contains(recv, "(") {
+ stampFactoryChainReceiver(edge, recv, resolveChainType(recv, env.vars, result))
+ }
}
}
}
result.Edges = append(result.Edges, edge)
}
}
-
- // Recurse into children.
- for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
- child := node.NamedChild(i)
- e.extractCallSites(child, src, filePath, callerID, result)
- }
}
// findChildByType finds the first named child with the given type.
diff --git a/internal/parser/languages/php_call_hygiene_test.go b/internal/parser/languages/php_call_hygiene_test.go
new file mode 100644
index 00000000..83845deb
--- /dev/null
+++ b/internal/parser/languages/php_call_hygiene_test.go
@@ -0,0 +1,124 @@
+package languages
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zzet/gortex/internal/graph"
+)
+
+// A computed callee names no symbol; emitting its source text produced targets
+// that can never bind, such as `unresolved::*.$fn`.
+func TestPHPCallHygiene_DynamicCalleesEmitNothing(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("hyg.php", []byte(`$method();
+ static::$method();
+ ($this->resolver)();
+ $handlers[0]();
+ }
+}
+`))
+ require.NoError(t, err)
+ for _, e := range res.Edges {
+ if e.Kind != graph.EdgeCalls {
+ continue
+ }
+ assert.NotContains(t, e.To, "$", "unbindable dynamic callee target %q", e.To)
+ assert.NotContains(t, e.To, "(", "unbindable dynamic callee target %q", e.To)
+ assert.NotContains(t, e.To, "[", "unbindable dynamic callee target %q", e.To)
+ }
+}
+
+// A static callee alongside dynamic ones must still be recorded.
+func TestPHPCallHygiene_StaticCalleesStillEmitted(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("hyg.php", []byte(`tick();
+ strlen('x');
+ }
+}
+`))
+ require.NoError(t, err)
+ got := map[string]bool{}
+ for _, e := range res.Edges {
+ if e.Kind == graph.EdgeCalls {
+ got[e.To] = true
+ }
+ }
+ assert.True(t, got["unresolved::*.tick"])
+ assert.True(t, got["unresolved::*.strlen"])
+}
+
+// Instantiating a class is the only way to call its constructor, and the
+// instantiates edge points at the class rather than at the method that runs —
+// so without this lowering a PHP constructor had no callers at all.
+func TestPHPCallHygiene_NewLowersToConstructorCall(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("hyg.php", []byte(`$method()`, `Foo::$m()`, `($resolver)()`, `$handlers[0]()` — and the
+// extractor used to emit its raw source text as the edge target, minting
+// unbindable placeholders like `unresolved::*.$fn`.
+func phpNamesACallee(n *sitter.Node) bool {
+ if n == nil {
+ return false
+ }
+ switch n.Type() {
+ case "name", "qualified_name", "relative_name":
+ return true
+ }
+ return false
+}
diff --git a/internal/parser/languages/php_declarations_test.go b/internal/parser/languages/php_declarations_test.go
new file mode 100644
index 00000000..e98f8328
--- /dev/null
+++ b/internal/parser/languages/php_declarations_test.go
@@ -0,0 +1,151 @@
+package languages
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zzet/gortex/internal/graph"
+)
+
+// phpNodesByName indexes an extraction's nodes by name.
+func phpNodesByName(t *testing.T, src string) map[string]*graph.Node {
+ t.Helper()
+ res, err := NewPHPExtractor().Extract("decl.php", []byte(src))
+ require.NoError(t, err)
+ out := map[string]*graph.Node{}
+ for _, n := range res.Nodes {
+ out[n.Name] = n
+ }
+ return out
+}
+
+func TestPHPDeclarations_PromotedConstructorPropertiesAreFields(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("decl.php", []byte(`run();`
+// in a bootstrap script types its receiver exactly as it would inside a
+// function.
+func (e *PHPExtractor) extractPHPFileScope(
+ root *sitter.Node, src []byte,
+ filePath string, fileNode *graph.Node,
+ result *parser.ExtractionResult,
+) {
+ if root == nil {
+ return
+ }
+ env := phpReceiverEnv{vars: typeEnv{}}
+ collectPHPFileScopeTypes(root, src, env.vars)
+ e.walkPHPFileScope(root, src, filePath, fileNode, result, env)
+}
+
+// collectPHPFileScopeTypes records `$v = new Foo` bindings made in top-level
+// statements, skipping any subtree a declaration owns.
+func collectPHPFileScopeTypes(node *sitter.Node, src []byte, vars typeEnv) {
+ if node == nil || phpDeclarationBoundary(node.Type()) {
+ return
+ }
+ if node.Type() == "assignment_expression" {
+ left := node.ChildByFieldName("left")
+ right := node.ChildByFieldName("right")
+ if left != nil && right != nil && left.Type() == "variable_name" {
+ if cls := phpNewExpressionClass(right, src); cls != "" {
+ name := strings.TrimPrefix(strings.TrimSpace(left.Content(src)), "$")
+ if prev, ok := vars[name]; ok && prev != cls {
+ delete(vars, name)
+ } else {
+ vars[name] = cls
+ }
+ }
+ }
+ }
+ for i, n := 0, int(node.NamedChildCount()); i < n; i++ {
+ collectPHPFileScopeTypes(node.NamedChild(i), src, vars)
+ }
+}
+
+// extractAnonymousClass mints a type node for `new class (...) extends B
+// implements I { ... }` and extracts its members. Without a node the class's
+// methods are not symbols at all, so calls into them resolve nowhere and calls
+// out of them are attributed to whatever function happened to enclose the
+// expression.
+//
+// The name is line-qualified (`class@anonymous:12`) because PHP allows several
+// anonymous classes in one file and the members' `receiver` meta must tell them
+// apart for receiver-directed resolution to stay precise.
+func (e *PHPExtractor) extractAnonymousClass(
+ node *sitter.Node, src []byte,
+ filePath string, fileNode *graph.Node,
+ result *parser.ExtractionResult, seen map[string]bool,
+) {
+ startLine := int(node.StartPoint().Row) + 1
+ name := fmt.Sprintf("class@anonymous:%d", startLine)
+ id := filePath + "::" + name
+ if seen[id] {
+ return
+ }
+ seen[id] = true
+
+ meta := map[string]any{
+ "visibility": VisibilityPublic,
+ "type_flavor": "class",
+ "anonymous": true,
+ }
+ if parent := extractPhpParentClass(node, src); parent != "" {
+ meta["scope_parent"] = parent
+ }
+ if ifaces := phpTypeClauseNames(node, src, "class_interface_clause"); len(ifaces) > 0 {
+ meta["scope_interfaces"] = strings.Join(ifaces, ",")
+ }
+ result.Nodes = append(result.Nodes, &graph.Node{
+ ID: id, Kind: graph.KindType, Name: name,
+ FilePath: filePath, StartLine: startLine, EndLine: int(node.EndPoint().Row) + 1,
+ Language: "php",
+ Meta: meta,
+ })
+ result.Edges = append(result.Edges, &graph.Edge{
+ From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
+ })
+
+ if body := node.ChildByFieldName("body"); body != nil {
+ e.extractPhpMembers(body, src, filePath, fileNode, result, seen, name, id)
+ }
+}
diff --git a/internal/parser/languages/php_file_scope_test.go b/internal/parser/languages/php_file_scope_test.go
new file mode 100644
index 00000000..bd16a45c
--- /dev/null
+++ b/internal/parser/languages/php_file_scope_test.go
@@ -0,0 +1,142 @@
+package languages
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zzet/gortex/internal/graph"
+)
+
+// phpEdgesFrom returns every edge of the given kind grouped by source node id.
+func phpEdgesFrom(t *testing.T, src string, kind graph.EdgeKind) map[string][]string {
+ t.Helper()
+ res, err := NewPHPExtractor().Extract("scope.php", []byte(src))
+ require.NoError(t, err)
+ out := map[string][]string{}
+ for _, e := range res.Edges {
+ if e.Kind == kind {
+ out[e.From] = append(out[e.From], e.To)
+ }
+ }
+ return out
+}
+
+func TestPHPFileScope_TopLevelCallsAttributedToFile(t *testing.T) {
+ calls := phpEdgesFrom(t, `setName('demo');
+bootstrap();
+`, graph.EdgeCalls)
+ require.Contains(t, calls, "scope.php", "top-level calls must be attributed to the file node")
+ assert.Contains(t, calls["scope.php"], "unresolved::*.setName")
+ assert.Contains(t, calls["scope.php"], "unresolved::*.bootstrap")
+}
+
+// A `$v = new Foo` binding made at file scope types the receiver of a later
+// top-level call exactly as it would inside a function.
+func TestPHPFileScope_TopLevelNewBindingTypesReceiver(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("scope.php", []byte(`run();
+`))
+ require.NoError(t, err)
+ var found bool
+ for _, e := range res.Edges {
+ if e.Kind == graph.EdgeCalls && e.To == "unresolved::*.run" {
+ found = true
+ require.NotNil(t, e.Meta)
+ assert.Equal(t, "Application", e.Meta["receiver_type"])
+ }
+ }
+ assert.True(t, found, "no call edge for $app->run()")
+}
+
+// A file whose whole body is `return function (...) {...}` — the shape every
+// symfony/console style fixture uses — has no symbol to own the closure, so
+// its calls belong to the file.
+func TestPHPFileScope_ReturnedClosureBodyIsWalked(t *testing.T) {
+ calls := phpEdgesFrom(t, `caution('boom');
+ $output->writeln('done');
+};
+`, graph.EdgeCalls)
+ assert.Contains(t, calls["scope.php"], "unresolved::*.caution")
+ assert.Contains(t, calls["scope.php"], "unresolved::*.writeln")
+}
+
+// The file-scope walk must stop at every declaration, otherwise a method body
+// is counted once for its method and again for the file.
+func TestPHPFileScope_DeclarationBodiesNotDoubleCounted(t *testing.T) {
+ calls := phpEdgesFrom(t, `tick(); }
+}
+function helper(): void { sideEffect(); }
+kickOff();
+`, graph.EdgeCalls)
+
+ assert.Equal(t, []string{"unresolved::*.tick"}, calls["scope.php::Service.run"])
+ assert.Equal(t, []string{"unresolved::*.sideEffect"}, calls["scope.php::helper"])
+ assert.Equal(t, []string{"unresolved::*.kickOff"}, calls["scope.php"],
+ "the file node must own only the top-level call")
+}
+
+func TestPHPAnonymousClass_MintsTypeAndMembers(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("scope.php", []byte(`isHandling($record);
+ }
+};
+`))
+ require.NoError(t, err)
+
+ var cls, method *graph.Node
+ for _, n := range res.Nodes {
+ switch {
+ case n.Kind == graph.KindType && n.Meta != nil && n.Meta["anonymous"] == true:
+ cls = n
+ case n.Kind == graph.KindMethod && n.Name == "handle":
+ method = n
+ }
+ }
+ require.NotNil(t, cls, "anonymous class must mint a type node")
+ assert.Equal(t, "AbstractHandler", cls.Meta["scope_parent"])
+ assert.Equal(t, "HandlerInterface", cls.Meta["scope_interfaces"])
+
+ require.NotNil(t, method, "anonymous class methods must be symbols")
+ assert.Equal(t, cls.Name, method.Meta["receiver"],
+ "the method's receiver names its anonymous class, so receiver-directed resolution stays precise")
+
+ // The call inside the anonymous method belongs to that method, not to the
+ // file or to whatever expression the class was written in.
+ calls := map[string][]string{}
+ for _, e := range res.Edges {
+ if e.Kind == graph.EdgeCalls {
+ calls[e.From] = append(calls[e.From], e.To)
+ }
+ }
+ assert.Contains(t, calls[method.ID], "unresolved::*.isHandling")
+ assert.NotContains(t, calls["scope.php"], "unresolved::*.isHandling")
+}
+
+// Two anonymous classes in one file must not collapse into one node — their
+// members' receivers have to stay distinguishable.
+func TestPHPAnonymousClass_DistinctPerLine(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("scope.php", []byte(` $handler) {
+ if (str_starts_with($path, $prefix)) {
+ foreach ($handler as $h) {
+ if ($h !== null) {
+ return $h;
+ }
+ }
+ } elseif ($prefix === '*') {
+ return $handler;
+ }
+ }
+ return null;
+ }
+}
+`)
+ m := nodes["dispatch"]
+ require.NotNil(t, m)
+ assert.Greater(t, m.Meta["complexity"], 1, "cyclomatic complexity must be stamped: %v", m.Meta)
+ assert.Greater(t, m.Meta["cognitive"], 1, "cognitive complexity must be stamped: %v", m.Meta)
+ assert.Equal(t, 2, m.Meta["loop_depth"], "nested foreach is loop depth 2: %v", m.Meta)
+}
+
+func TestPHPMetrics_TrivialFunctionUnstamped(t *testing.T) {
+ nodes := phpNodesByName(t, `Title
+
+
= $item->label() ?>
+
+`))
+ require.NoError(t, err)
+ var sawCall bool
+ for _, e := range res.Edges {
+ if e.Kind == graph.EdgeCalls && e.To == "unresolved::*.label" {
+ sawCall = true
+ }
+ }
+ assert.True(t, sawCall, "calls inside a .phtml template must reach the graph")
+}
diff --git a/internal/parser/languages/php_property_access.go b/internal/parser/languages/php_property_access.go
new file mode 100644
index 00000000..509c03a3
--- /dev/null
+++ b/internal/parser/languages/php_property_access.go
@@ -0,0 +1,139 @@
+package languages
+
+import (
+ "strings"
+
+ "github.com/zzet/gortex/internal/graph"
+ "github.com/zzet/gortex/internal/parser"
+ sitter "github.com/zzet/gortex/internal/parser/tsitter"
+)
+
+// PHP property and class-constant usage edges.
+//
+// The extractor recorded calls but never accesses: `$this->handlers`,
+// `$obj->name`, `Foo::$registry` and `Foo::VERSION` produced no edge of any
+// kind, so find_usages on a PHP property or class constant returned its
+// declaration and nothing else — the member looked unused no matter how often
+// the code read or wrote it.
+//
+// Each access emits an EdgeReads (EdgeWrites in assignment position) to
+// `unresolved::*.`, which is the shape resolveFieldRef already
+// consumes: with a receiver_type it binds to that type's field exactly, and
+// without one it falls back through the same locality cascade every other
+// language uses.
+
+// emitPHPMemberAccess records one property / static-property / class-constant
+// access. Dynamic members (`$obj->$name`, `Foo::$$var`, `{$expr}`) name no
+// symbol and are skipped rather than emitted as an unresolvable target.
+func (e *PHPExtractor) emitPHPMemberAccess(
+ node *sitter.Node, src []byte,
+ filePath, callerID string,
+ result *parser.ExtractionResult,
+ env phpReceiverEnv,
+) {
+ var member, receiver string
+ switch node.Type() {
+ case "member_access_expression", "nullsafe_member_access_expression":
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil || nameNode.Type() != "name" {
+ return // `$obj->$prop` / `$obj->{$expr}`
+ }
+ member = nameNode.Content(src)
+ receiver = env.phpReceiverTypeFor(node.ChildByFieldName("object"), src)
+
+ case "scoped_property_access_expression":
+ nameNode := node.ChildByFieldName("name")
+ if nameNode == nil || nameNode.Type() != "variable_name" {
+ return // `Foo::$$dynamic`
+ }
+ member = strings.TrimPrefix(strings.TrimSpace(nameNode.Content(src)), "$")
+ receiver = env.phpScopeReceiverType(node.ChildByFieldName("scope"), src)
+
+ case "class_constant_access_expression":
+ name := phpClassConstantName(node, src)
+ // `Foo::class` is the class-name literal, not a constant member; the
+ // type reference it carries is already emitted by
+ // emitPHPReferenceForms.
+ if name == "" || name == "class" {
+ return
+ }
+ member = name
+ receiver = env.phpScopeReceiverType(phpClassConstantScope(node), src)
+ }
+
+ if member == "" {
+ return
+ }
+ kind := graph.EdgeReads
+ if phpAccessIsWrite(node) {
+ kind = graph.EdgeWrites
+ }
+ edge := &graph.Edge{
+ From: callerID, To: "unresolved::*." + member,
+ Kind: kind, FilePath: filePath, Line: int(node.StartPoint().Row) + 1,
+ }
+ if receiver != "" {
+ edge.Meta = map[string]any{"receiver_type": receiver}
+ }
+ result.Edges = append(result.Edges, edge)
+}
+
+// phpClassConstantScope returns the scope child of a class_constant_access
+// expression. The grammar exposes no field names on this node, so the scope is
+// its first named child and the constant name its second.
+func phpClassConstantScope(node *sitter.Node) *sitter.Node {
+ if node == nil || node.NamedChildCount() == 0 {
+ return nil
+ }
+ return node.NamedChild(0)
+}
+
+// phpClassConstantName returns the constant identifier of `Foo::BAR`, or "" for
+// `Foo::class` (whose `class` keyword is an anonymous token) and for a dynamic
+// constant fetch.
+func phpClassConstantName(node *sitter.Node, src []byte) string {
+ if node == nil || node.NamedChildCount() < 2 {
+ return ""
+ }
+ name := node.NamedChild(1)
+ if name == nil || name.Type() != "name" {
+ return ""
+ }
+ return name.Content(src)
+}
+
+// phpAccessIsWrite reports whether an access sits in a position that stores
+// into the member: an assignment / augmented-assignment / reference-assignment
+// left-hand side, the operand of `++` / `--`, or an argument of `unset()`.
+//
+// Subscripts are climbed first: `$this->handlers[] = $h` and
+// `$this->map['k'] = $v` mutate the property, so they count as writes of
+// `handlers` / `map`. Only the container operand is climbed — a member access
+// used as the *index* (`$a[$this->key] = 1`) is a read of `key`.
+func phpAccessIsWrite(node *sitter.Node) bool {
+ for {
+ parent := node.Parent()
+ if parent == nil || parent.Type() != "subscript_expression" {
+ break
+ }
+ if parent.NamedChildCount() == 0 || !parent.NamedChild(0).Equal(node) {
+ return false
+ }
+ node = parent
+ }
+ parent := node.Parent()
+ if parent == nil {
+ return false
+ }
+ switch parent.Type() {
+ case "assignment_expression", "augmented_assignment_expression", "reference_assignment_expression":
+ left := parent.ChildByFieldName("left")
+ return left != nil && left.Equal(node)
+ case "update_expression":
+ arg := parent.ChildByFieldName("argument")
+ return arg != nil && arg.Equal(node)
+ case "unset_statement":
+ return true
+ }
+ return false
+}
diff --git a/internal/parser/languages/php_property_access_test.go b/internal/parser/languages/php_property_access_test.go
new file mode 100644
index 00000000..88b14af3
--- /dev/null
+++ b/internal/parser/languages/php_property_access_test.go
@@ -0,0 +1,204 @@
+package languages
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zzet/gortex/internal/graph"
+)
+
+// phpAccessEdges returns every read/write edge in the extraction, keyed by
+// " " with the stamped receiver_type as the value.
+func phpAccessEdges(t *testing.T, src string) map[string]string {
+ t.Helper()
+ res, err := NewPHPExtractor().Extract("acc.php", []byte(src))
+ require.NoError(t, err)
+ out := map[string]string{}
+ for _, e := range res.Edges {
+ if e.Kind != graph.EdgeReads && e.Kind != graph.EdgeWrites {
+ continue
+ }
+ rt := ""
+ if e.Meta != nil {
+ rt, _ = e.Meta["receiver_type"].(string)
+ }
+ out[string(e.Kind)+" "+e.To] = rt
+ }
+ return out
+}
+
+func TestPHPPropertyAccess_ReadAndWriteOnThis(t *testing.T) {
+ got := phpAccessEdges(t, `handlers[] = $h;
+ }
+
+ public function all(): array {
+ return $this->handlers;
+ }
+}
+`)
+ rt, ok := got["reads unresolved::*.handlers"]
+ require.True(t, ok, "no read edge for $this->handlers, got %v", got)
+ assert.Equal(t, "Logger", rt)
+
+ // `$this->handlers[] = $h` mutates the property, so the subscripted
+ // assignment is a write of `handlers`, not a read of it.
+ rt, ok = got["writes unresolved::*.handlers"]
+ require.True(t, ok, "no write edge for $this->handlers[] = ..., got %v", got)
+ assert.Equal(t, "Logger", rt)
+}
+
+// A member access used as a subscript INDEX is read, not written — only the
+// container operand of `$a[...] = v` is the assignment target.
+func TestPHPPropertyAccess_SubscriptIndexIsARead(t *testing.T) {
+ got := phpAccessEdges(t, `key] = $v;
+ }
+}
+`)
+ assert.Contains(t, got, "reads unresolved::*.key")
+ assert.NotContains(t, got, "writes unresolved::*.key")
+}
+
+func TestPHPPropertyAccess_AssignmentIsAWrite(t *testing.T) {
+ got := phpAccessEdges(t, `formatter = $f;
+ }
+}
+`)
+ rt, ok := got["writes unresolved::*.formatter"]
+ require.True(t, ok, "assignment must emit a write, got %v", got)
+ assert.Equal(t, "Logger", rt)
+ _, alsoRead := got["reads unresolved::*.formatter"]
+ assert.False(t, alsoRead, "an assignment target must not also read")
+}
+
+func TestPHPPropertyAccess_CompoundAndUpdateAreWrites(t *testing.T) {
+ got := phpAccessEdges(t, `hits++;
+ $this->log .= 'x';
+ unset($this->log);
+ }
+}
+`)
+ assert.Contains(t, got, "writes unresolved::*.hits", "++ is a write")
+ assert.Contains(t, got, "writes unresolved::*.log", "compound assign / unset is a write")
+}
+
+func TestPHPPropertyAccess_TypedPropertyChainTypesTheInnerRead(t *testing.T) {
+ got := phpAccessEdges(t, `logger->warning('x');
+ }
+}
+`)
+ rt, ok := got["reads unresolved::*.logger"]
+ require.True(t, ok, "the receiver of a chained call is itself a property read")
+ assert.Equal(t, "Service", rt)
+}
+
+func TestPHPPropertyAccess_StaticPropertyAndClassConstant(t *testing.T) {
+ got := phpAccessEdges(t, `$k;
+ $y = $o->{$k . '_id'};
+ }
+}
+`)
+ assert.Empty(t, got, "dynamic property fetches must emit nothing, got %v", got)
+}
+
+func TestPHPPropertyAccess_NullsafePropertyRead(t *testing.T) {
+ got := phpAccessEdges(t, `config?->timeout;
+ }
+}
+`)
+ assert.Contains(t, got, "reads unresolved::*.config")
+ rt, ok := got["reads unresolved::*.timeout"]
+ require.True(t, ok, "nullsafe property read must emit an edge, got %v", got)
+ assert.Equal(t, "Config", rt)
+}
+
+// `self::` and `static::` name the enclosing class, so a static property or
+// class constant reached through them binds like any other typed access.
+// `parent::` deliberately stays untyped — the scope_kind path walks up from the
+// enclosing class rather than pinning to it.
+func TestPHPPropertyAccess_RelativeScopeTypedByEnclosingClass(t *testing.T) {
+ got := phpAccessEdges(t, `foo()` / `$obj->foo()` / `Foo::foo()`, which left the resolver
+// with nothing but a repo-wide name match. Because a name-only pick lands at
+// the text_matched tier, the cross-package guard then reverts most of them —
+// so the calls were not merely imprecise, they were dropped.
+//
+// phpReceiverEnv is the static approximation of what a receiver expression
+// evaluates to, built once per function body from declarations only:
+//
+// - `$this` → the enclosing class;
+// - `$this->prop` → the property's declared type, including
+// PHP 8 constructor-promoted properties;
+// - a typed parameter → its declared type;
+// - `$v = new Foo(...)` → Foo, but only when every `new` assignment
+// to $v in the body agrees (a variable reassigned to a different class is
+// dropped rather than guessed);
+// - `catch (FooException $e)` → the caught type, when it is unambiguous;
+// - `/** @var Foo $v */` → Foo.
+//
+// Everything else stays untyped. The environment never guesses: an entry is
+// only recorded from a declaration the source states outright, so a stamped
+// receiver_type is evidence the resolver can bind at the ast_resolved tier.
+type phpReceiverEnv struct {
+ // class is the simple name of the enclosing class, or "" inside a free
+ // function or a `static` closure (which does not bind $this).
+ class string
+ // vars maps a bare variable name (no leading `$`) to its simple type name.
+ vars typeEnv
+ // props maps a bare property name to its declared simple type name.
+ props map[string]string
+}
+
+// lookupVar returns the recorded type of $name, or "".
+func (env phpReceiverEnv) lookupVar(name string) string {
+ if env.vars == nil {
+ return ""
+ }
+ return env.vars[name]
+}
+
+// lookupProp returns the recorded type of $this->name, or "".
+func (env phpReceiverEnv) lookupProp(name string) string {
+ if env.props == nil {
+ return ""
+ }
+ return env.props[name]
+}
+
+// childScope returns an environment for a nested closure / arrow function: the
+// enclosing bindings stay visible (PHP closures inherit $this, and `use (...)`
+// imports outer variables), extended with the closure's own parameters and
+// local `new` assignments. A `static` closure drops $this.
+func (env phpReceiverEnv) childScope(fn *sitter.Node, src []byte) phpReceiverEnv {
+ child := phpReceiverEnv{class: env.class, props: env.props, vars: typeEnv{}}
+ for k, v := range env.vars {
+ child.vars[k] = v
+ }
+ if firstChildOfType(fn, "static_modifier") != nil {
+ child.class = ""
+ }
+ collectPHPParamTypes(fn, src, child.vars)
+ collectPHPLocalTypes(phpFunctionBody(fn), src, child.vars)
+ return child
+}
+
+// newPHPReceiverEnv builds the environment for one function / method body.
+// class is the enclosing class simple name ("" for a free function) and props
+// its declared property types (nil for a free function).
+func newPHPReceiverEnv(fn *sitter.Node, src []byte, class string, props map[string]string) phpReceiverEnv {
+ env := phpReceiverEnv{class: class, props: props, vars: typeEnv{}}
+ collectPHPParamTypes(fn, src, env.vars)
+ collectPHPLocalTypes(phpFunctionBody(fn), src, env.vars)
+ return env
+}
+
+// phpFunctionBody returns the body of a function / method / closure. An arrow
+// function's body is a bare expression, not a compound_statement.
+func phpFunctionBody(fn *sitter.Node) *sitter.Node {
+ if fn == nil {
+ return nil
+ }
+ if b := fn.ChildByFieldName("body"); b != nil {
+ return b
+ }
+ return firstChildOfType(fn, "compound_statement")
+}
+
+// collectPHPParamTypes records every typed parameter of fn into vars. Covers
+// simple_parameter, variadic_parameter, and property_promotion_parameter (the
+// promoted constructor property is also an ordinary parameter inside the
+// constructor body).
+func collectPHPParamTypes(fn *sitter.Node, src []byte, vars typeEnv) {
+ if fn == nil || vars == nil {
+ return
+ }
+ params := fn.ChildByFieldName("parameters")
+ if params == nil {
+ return
+ }
+ for i, n := 0, int(params.NamedChildCount()); i < n; i++ {
+ p := params.NamedChild(i)
+ switch p.Type() {
+ case "simple_parameter", "variadic_parameter", "property_promotion_parameter":
+ default:
+ continue
+ }
+ name := phpParamVarName(p, src)
+ if name == "" {
+ continue
+ }
+ // A variadic parameter is an array of the declared type, not an
+ // instance of it — typing it would misbind `$items->foo()`.
+ if p.Type() == "variadic_parameter" {
+ continue
+ }
+ if t := phpSoleTypeAtom(phpParameterType(p, src)); t != "" {
+ vars[name] = t
+ }
+ }
+}
+
+// phpParamVarName returns a parameter's variable name without the `$`.
+func phpParamVarName(p *sitter.Node, src []byte) string {
+ name := p.ChildByFieldName("name")
+ if name == nil {
+ return ""
+ }
+ // `&$ref` wraps the variable_name in a by_ref node.
+ if name.Type() == "by_ref" {
+ if inner := firstChildOfType(name, "variable_name"); inner != nil {
+ name = inner
+ }
+ }
+ return strings.TrimPrefix(strings.TrimSpace(name.Content(src)), "$")
+}
+
+var phpDocVarRe = regexp.MustCompile(`@var\s+([^\s*]+)\s+\$(\w+)`)
+
+// collectPHPLocalTypes walks a function body recording the type of locals the
+// source declares outright: `$v = new Foo`, `catch (Foo $e)`, and
+// `/** @var Foo $v */`. A variable assigned two different classes is removed —
+// a flow-insensitive environment cannot say which one reaches a given call, and
+// a wrong receiver_type is worse than none (it binds the call to the wrong
+// method at the ast_resolved tier instead of leaving it for the name-match
+// fallback). Nested closures are skipped: they get their own scope.
+func collectPHPLocalTypes(body *sitter.Node, src []byte, vars typeEnv) {
+ if body == nil || vars == nil {
+ return
+ }
+ conflicted := map[string]bool{}
+ record := func(name, typ string) {
+ if name == "" || typ == "" || conflicted[name] {
+ return
+ }
+ if prev, ok := vars[name]; ok && prev != typ {
+ delete(vars, name)
+ conflicted[name] = true
+ return
+ }
+ vars[name] = typ
+ }
+
+ var walk func(n *sitter.Node)
+ walk = func(n *sitter.Node) {
+ if n == nil {
+ return
+ }
+ switch n.Type() {
+ case "anonymous_function", "anonymous_function_creation_expression", "arrow_function":
+ return // its own scope
+ case "assignment_expression":
+ left := n.ChildByFieldName("left")
+ right := n.ChildByFieldName("right")
+ if left != nil && right != nil && left.Type() == "variable_name" {
+ if cls := phpNewExpressionClass(right, src); cls != "" {
+ record(strings.TrimPrefix(strings.TrimSpace(left.Content(src)), "$"), cls)
+ }
+ }
+ case "catch_clause":
+ name := n.ChildByFieldName("name")
+ list := n.ChildByFieldName("type")
+ if name != nil && list != nil && list.NamedChildCount() == 1 {
+ if t := phpSoleTypeAtom(list.NamedChild(0).Content(src)); t != "" {
+ record(strings.TrimPrefix(strings.TrimSpace(name.Content(src)), "$"), t)
+ }
+ }
+ case "comment":
+ for _, m := range phpDocVarRe.FindAllStringSubmatch(n.Content(src), -1) {
+ if t := phpSoleTypeAtom(m[1]); t != "" {
+ record(m[2], t)
+ }
+ }
+ }
+ for i, c := 0, int(n.NamedChildCount()); i < c; i++ {
+ walk(n.NamedChild(i))
+ }
+ }
+ walk(body)
+}
+
+// phpNewExpressionClass returns the class named by an `new Foo(...)`
+// expression, or "" when the expression is not a `new` of a statically known
+// class (`new $cls`, `new class {}`, and `new ($expr)` all return "").
+func phpNewExpressionClass(expr *sitter.Node, src []byte) string {
+ if expr == nil || expr.Type() != "object_creation_expression" {
+ return ""
+ }
+ for i, n := 0, int(expr.NamedChildCount()); i < n; i++ {
+ c := expr.NamedChild(i)
+ switch c.Type() {
+ case "name", "qualified_name":
+ return canonicalizePHPTypeRef(c.Content(src))
+ case "arguments", "attribute_list":
+ continue
+ default:
+ return ""
+ }
+ }
+ return ""
+}
+
+// phpSoleTypeAtom reduces a declared type to a single bindable class name. A
+// union / intersection / nullable-of-union declaration names more than one
+// possible runtime class, so it yields "" rather than an arbitrary branch;
+// `?Foo` is Foo (the null branch has no members to call). Builtin scalar and
+// pseudo types never name a graph node.
+func phpSoleTypeAtom(decl string) string {
+ decl = strings.TrimSpace(decl)
+ if decl == "" || strings.ContainsAny(decl, "|&") {
+ return ""
+ }
+ t := canonicalizePHPTypeRef(decl)
+ if t == "" || phpBuiltinType(t) {
+ return ""
+ }
+ return t
+}
+
+// phpClassPropertyTypes maps each declared property of a class / trait / enum
+// body to its simple type name — both classic typed properties and PHP 8
+// constructor-promoted ones, which are declared in the constructor's parameter
+// list and have no property_declaration of their own.
+func phpClassPropertyTypes(body *sitter.Node, src []byte) map[string]string {
+ if body == nil {
+ return nil
+ }
+ props := map[string]string{}
+ for i, n := 0, int(body.NamedChildCount()); i < n; i++ {
+ member := body.NamedChild(i)
+ switch member.Type() {
+ case "property_declaration":
+ t := phpSoleTypeAtom(phpPropertyType(member, src))
+ if t == "" {
+ continue
+ }
+ for j, m := 0, int(member.NamedChildCount()); j < m; j++ {
+ el := member.NamedChild(j)
+ if el.Type() != "property_element" {
+ continue
+ }
+ if nameNode := el.ChildByFieldName("name"); nameNode != nil {
+ props[strings.TrimPrefix(strings.TrimSpace(nameNode.Content(src)), "$")] = t
+ }
+ }
+ case "method_declaration":
+ nameNode := member.ChildByFieldName("name")
+ if nameNode == nil || !strings.EqualFold(nameNode.Content(src), "__construct") {
+ continue
+ }
+ params := member.ChildByFieldName("parameters")
+ if params == nil {
+ continue
+ }
+ for j, m := 0, int(params.NamedChildCount()); j < m; j++ {
+ p := params.NamedChild(j)
+ if p.Type() != "property_promotion_parameter" {
+ continue
+ }
+ name := phpParamVarName(p, src)
+ t := phpSoleTypeAtom(phpParameterType(p, src))
+ if name != "" && t != "" {
+ props[name] = t
+ }
+ }
+ }
+ }
+ if len(props) == 0 {
+ return nil
+ }
+ return props
+}
+
+// phpReceiverTypeFor types the receiver expression of a member call. Returns
+// "" when the receiver is not one of the statically known shapes.
+func (env phpReceiverEnv) phpReceiverTypeFor(obj *sitter.Node, src []byte) string {
+ if obj == nil {
+ return ""
+ }
+ // `(new Foo())->m()` and `($x)->m()` wrap the receiver in parentheses.
+ for obj.Type() == "parenthesized_expression" && obj.NamedChildCount() == 1 {
+ obj = obj.NamedChild(0)
+ }
+ switch obj.Type() {
+ case "variable_name":
+ v := strings.TrimPrefix(strings.TrimSpace(obj.Content(src)), "$")
+ if v == "this" {
+ return env.class
+ }
+ return env.lookupVar(v)
+ case "member_access_expression", "nullsafe_member_access_expression":
+ // `$this->prop->m()` — only a property of the enclosing class is
+ // typed; a deeper chain is left to the chain walker.
+ inner := obj.ChildByFieldName("object")
+ nameNode := obj.ChildByFieldName("name")
+ if inner == nil || nameNode == nil || inner.Type() != "variable_name" {
+ return ""
+ }
+ if strings.TrimSpace(inner.Content(src)) != "$this" {
+ return ""
+ }
+ return env.lookupProp(strings.TrimSpace(nameNode.Content(src)))
+ case "object_creation_expression":
+ // `(new Foo)->m()`
+ return phpNewExpressionClass(obj, src)
+ }
+ return ""
+}
+
+// phpScopeReceiverType types the scope of a `Foo::m()` scoped call or a
+// `Foo::$prop` / `Foo::CONST` access. A `$var::m()` scope is typed through the
+// environment.
+//
+// `self` / `static` resolve to the enclosing class — late static binding may
+// pick a subclass at runtime, but the enclosing class is where the member is
+// declared or inherited from, which is what the hierarchy walk needs. `parent`
+// returns "" so the existing scope_kind path walks up from the enclosing class
+// instead of pinning to it.
+func (env phpReceiverEnv) phpScopeReceiverType(scope *sitter.Node, src []byte) string {
+ if scope == nil {
+ return ""
+ }
+ if scope.Type() == "relative_scope" {
+ switch strings.ToLower(strings.TrimSpace(scope.Content(src))) {
+ case "self", "static":
+ return env.class
+ }
+ return ""
+ }
+ switch scope.Type() {
+ case "name", "qualified_name", "relative_name":
+ text := strings.TrimSpace(scope.Content(src))
+ switch strings.ToLower(text) {
+ case "parent", "self", "static":
+ return ""
+ }
+ return phpSoleTypeAtom(text)
+ case "variable_name":
+ return env.lookupVar(strings.TrimPrefix(strings.TrimSpace(scope.Content(src)), "$"))
+ }
+ return ""
+}
diff --git a/internal/parser/languages/php_receiver_test.go b/internal/parser/languages/php_receiver_test.go
new file mode 100644
index 00000000..9adbc342
--- /dev/null
+++ b/internal/parser/languages/php_receiver_test.go
@@ -0,0 +1,337 @@
+package languages
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zzet/gortex/internal/graph"
+)
+
+// phpCallReceiver returns the receiver_type stamped on the call edge for the
+// named method, plus whether such a call edge exists at all.
+func phpCallReceiver(t *testing.T, src string, method string) (string, bool) {
+ t.Helper()
+ res, err := NewPHPExtractor().Extract("recv.php", []byte(src))
+ require.NoError(t, err)
+ for _, e := range res.Edges {
+ if e.Kind != graph.EdgeCalls || e.To != "unresolved::*."+method {
+ continue
+ }
+ if e.Meta == nil {
+ return "", true
+ }
+ rt, _ := e.Meta["receiver_type"].(string)
+ return rt, true
+ }
+ return "", false
+}
+
+func TestPHPReceiver_ThisIsEnclosingClass(t *testing.T) {
+ rt, found := phpCallReceiver(t, `getFormatter();
+ }
+}
+`, "getFormatter")
+ require.True(t, found, "no call edge for $this->getFormatter()")
+ assert.Equal(t, "StreamHandler", rt)
+}
+
+func TestPHPReceiver_TypedParameter(t *testing.T) {
+ rt, found := phpCallReceiver(t, `handle();
+ }
+}
+`, "handle")
+ require.True(t, found)
+ assert.Equal(t, "HandlerInterface", rt)
+}
+
+func TestPHPReceiver_NullableTypedParameterDropsQuestionMark(t *testing.T) {
+ rt, found := phpCallReceiver(t, `format();
+}
+`, "format")
+ require.True(t, found)
+ assert.Equal(t, "Formatter", rt)
+}
+
+func TestPHPReceiver_NamespacedTypeReducedToSimpleName(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close();
+}
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "StreamHandler", rt)
+}
+
+func TestPHPReceiver_LocalNewAssignment(t *testing.T) {
+ rt, found := phpCallReceiver(t, `pushHandler();
+}
+`, "pushHandler")
+ require.True(t, found)
+ assert.Equal(t, "Logger", rt)
+}
+
+// A variable reassigned to a different class cannot be typed flow-insensitively;
+// guessing one branch would bind the call to the wrong method at the resolved
+// tier, so the environment drops the variable entirely.
+func TestPHPReceiver_ConflictingNewAssignmentsStayUntyped(t *testing.T) {
+ rt, found := phpCallReceiver(t, `handle();
+}
+`, "handle")
+ require.True(t, found)
+ assert.Equal(t, "", rt, "conflicting assignments must not type the receiver")
+}
+
+func TestPHPReceiver_TypedProperty(t *testing.T) {
+ rt, found := phpCallReceiver(t, `logger->warning('x');
+ }
+}
+`, "warning")
+ require.True(t, found)
+ assert.Equal(t, "LoggerInterface", rt)
+}
+
+// PHP 8 constructor property promotion declares the property in the parameter
+// list, so the property type must be read from there too.
+func TestPHPReceiver_PromotedConstructorProperty(t *testing.T) {
+ rt, found := phpCallReceiver(t, `logger->warning('x');
+ }
+}
+`, "warning")
+ require.True(t, found)
+ assert.Equal(t, "LoggerInterface", rt)
+}
+
+func TestPHPReceiver_StaticScopeClass(t *testing.T) {
+ rt, found := phpCallReceiver(t, `formatter?->format($record);
+ }
+}
+`, "format")
+ require.True(t, found, "nullsafe call must emit a call edge")
+ assert.Equal(t, "Formatter", rt)
+}
+
+func TestPHPReceiver_NullsafeChainEmitsEveryHop(t *testing.T) {
+ res, err := NewPHPExtractor().Extract("recv.php", []byte(`client?->request()?->getBody();
+ }
+}
+`))
+ require.NoError(t, err)
+ got := map[string]bool{}
+ for _, e := range res.Edges {
+ if e.Kind == graph.EdgeCalls {
+ got[e.To] = true
+ }
+ }
+ assert.True(t, got["unresolved::*.request"], "missing ?->request() edge")
+ assert.True(t, got["unresolved::*.getBody"], "missing ?->getBody() edge")
+}
+
+// A closure parameter shadows an outer variable of the same name; typing the
+// call from the outer binding would attribute it to the wrong class.
+func TestPHPReceiver_ClosureParameterShadowsOuterVariable(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close();
+ };
+}
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "RotatingFileHandler", rt)
+}
+
+// A closure inherits $this from the enclosing method (PHP binds it unless the
+// closure is declared static), so calls inside it still type against the class.
+func TestPHPReceiver_ClosureInheritsThis(t *testing.T) {
+ rt, found := phpCallReceiver(t, `tick();
+ };
+ }
+}
+`, "tick")
+ require.True(t, found)
+ assert.Equal(t, "Runner", rt)
+}
+
+func TestPHPReceiver_StaticClosureDropsThis(t *testing.T) {
+ rt, found := phpCallReceiver(t, `tick();
+ };
+ }
+}
+`, "tick")
+ require.True(t, found)
+ assert.Equal(t, "", rt, "a static closure does not bind $this")
+}
+
+func TestPHPReceiver_CatchClauseTypesVariable(t *testing.T) {
+ rt, found := phpCallReceiver(t, `getContext();
+ }
+}
+`, "getContext")
+ require.True(t, found)
+ assert.Equal(t, "ConnectionException", rt)
+}
+
+func TestPHPReceiver_DocVarComment(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close();
+}
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "StreamHandler", rt)
+}
+
+// A union or intersection declaration names more than one possible runtime
+// class, so no single receiver_type is defensible.
+func TestPHPReceiver_UnionAndIntersectionStayUntyped(t *testing.T) {
+ for name, src := range map[string]string{
+ "union": `close(); }
+`,
+ "intersection": `close(); }
+`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ rt, found := phpCallReceiver(t, src, "close")
+ require.True(t, found)
+ assert.Equal(t, "", rt)
+ })
+ }
+}
+
+// A builtin scalar type names no graph node, so it must not be stamped.
+func TestPHPReceiver_BuiltinTypesNotStamped(t *testing.T) {
+ rt, found := phpCallReceiver(t, `format(); }
+`, "format")
+ require.True(t, found)
+ assert.Equal(t, "", rt)
+}
+
+// A variadic parameter is an array of the declared type, not an instance, so
+// typing it would misbind calls on the array itself.
+func TestPHPReceiver_VariadicParameterNotStamped(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close(); }
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "", rt)
+}
+
+func TestPHPReceiver_NewExpressionReceiver(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close();
+}
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "StreamHandler", rt)
+}
+
+// `new $cls` / `new class {}` name no static class, so nothing is stamped.
+func TestPHPReceiver_DynamicNewStaysUntyped(t *testing.T) {
+ rt, found := phpCallReceiver(t, `close();
+}
+`, "close")
+ require.True(t, found)
+ assert.Equal(t, "", rt)
+}
diff --git a/internal/parser/languages/php_reffrm.go b/internal/parser/languages/php_reffrm.go
index 35a3bc85..d1d8150b 100644
--- a/internal/parser/languages/php_reffrm.go
+++ b/internal/parser/languages/php_reffrm.go
@@ -121,6 +121,18 @@ func emitPHPReferenceForms(root *sitter.Node, src []byte, filePath, fileID strin
// variable) and `new class {}` (anonymous) have no name child.
if name := phpCreationTypeName(n, src); name != "" {
emit(name, line, "", graph.EdgeInstantiates, "")
+ emitPHPConstructorCall(name, line, ownerFor(line), filePath, result, seen)
+ }
+
+ case "catch_clause":
+ // `catch (IOException | NotFoundException $e)` names each caught
+ // type. Without this a class used only as an exception type looked
+ // unreferenced, and the handler was invisible to error-surface
+ // analysis.
+ if list := n.ChildByFieldName("type"); list != nil {
+ for i, c := 0, int(list.NamedChildCount()); i < c; i++ {
+ emit(list.NamedChild(i).Content(src), line, "", graph.EdgeReferences, graph.RefContextType)
+ }
}
case "base_clause":
@@ -309,3 +321,30 @@ func phpAttributeRefName(n *sitter.Node, src []byte) string {
}
return ""
}
+
+// emitPHPConstructorCall lowers `new Foo(...)` to a call of Foo's constructor,
+// alongside the instantiation edge. Without it a PHP constructor showed no
+// callers at all — instantiating a class is the only way to call __construct,
+// and the instantiates edge points at the class, not at the method that runs.
+// The receiver type is stamped so the resolver binds it to Foo's own
+// __construct, or to the nearest ancestor's when Foo inherits one.
+func emitPHPConstructorCall(
+ typeName string, line int, owner, filePath string,
+ result *parser.ExtractionResult, seen map[string]bool,
+) {
+ canon := canonicalizePHPTypeRef(typeName)
+ if owner == "" || canon == "" || phpBuiltinType(canon) || !isPHPTypeNameCapitalized(canon) ||
+ isPHPRelativeScope(canon) {
+ return
+ }
+ key := owner + "\x00ctor\x00" + canon + "\x00" + strconv.Itoa(line)
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ result.Edges = append(result.Edges, &graph.Edge{
+ From: owner, To: "unresolved::*.__construct",
+ Kind: graph.EdgeCalls, FilePath: filePath, Line: line,
+ Meta: map[string]any{"receiver_type": canon, "via": "constructor"},
+ })
+}
diff --git a/internal/resolver/php_override_dispatch.go b/internal/resolver/php_override_dispatch.go
index 774e8866..f0c469f5 100644
--- a/internal/resolver/php_override_dispatch.go
+++ b/internal/resolver/php_override_dispatch.go
@@ -92,6 +92,26 @@ func (r *Resolver) resolvePHPOverrideDispatch() int {
continue
}
+ // Receiver-directed path: the extractor typed the receiver
+ // (`$this->m()`, a typed property / parameter, `$v = new Foo`,
+ // `Foo::m()`), so the call binds to the method that type declares —
+ // or, when the type inherits it, to the nearest ancestor declaring
+ // it. This is a single precise bind, not a fan-out: the receiver's
+ // static type is stated by the source, so there is nothing to guess.
+ // The generic resolver already binds the case where the receiver type
+ // declares the method itself; this pass exists for the inherited case,
+ // which a same-receiver name match cannot see.
+ if rt := edgeReceiverType(e); rt != "" {
+ if base := phpBaseTypeName(rt); base != "" {
+ if target := r.nearestPHPMethod(name, []string{base}, direct, repo); target != nil && target.ID != caller.ID {
+ jobs = append(jobs, job{edge: e, single: target})
+ }
+ }
+ // A typed receiver that resolved to nothing stays unresolved: a
+ // name-only fan-out would contradict the type the source declared.
+ 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))
@@ -116,7 +136,11 @@ func (r *Resolver) resolvePHPOverrideDispatch() int {
if j.edge.Confidence < phpDispatchConfidence {
j.edge.Confidence = phpDispatchConfidence
}
- phpEnsureMeta(j.edge)["dispatch"] = "scope"
+ if edgeReceiverType(j.edge) != "" {
+ phpEnsureMeta(j.edge)["dispatch"] = "receiver"
+ } else {
+ phpEnsureMeta(j.edge)["dispatch"] = "scope"
+ }
g.ReindexEdges([]graph.EdgeReindex{{Edge: j.edge, OldTo: oldTo}})
n++
continue
@@ -351,3 +375,12 @@ func phpEnsureMeta(e *graph.Edge) map[string]any {
}
return e.Meta
}
+
+// phpTypedReceiverCaller reports whether a call came from PHP source, so the
+// resolver knows a stated receiver_type should be honoured by the hierarchy
+// walk instead of guessed at by directory adjacency. Scoped to PHP because the
+// locality fallback is correct for languages whose directory is a real package
+// boundary; PHP's is not.
+func phpTypedReceiverCaller(caller *graph.Node) bool {
+ return caller != nil && caller.Language == "php"
+}
diff --git a/internal/resolver/php_override_dispatch_test.go b/internal/resolver/php_override_dispatch_test.go
index fd7804bc..b9ec55fc 100644
--- a/internal/resolver/php_override_dispatch_test.go
+++ b/internal/resolver/php_override_dispatch_test.go
@@ -230,3 +230,184 @@ func TestResolvePHPOverrideDispatch_Idempotent(t *testing.T) {
assert.Equal(t, countAfterFirst, len(s.GetOutEdges(caller)),
"re-running the pass must not duplicate fan-out edges")
}
+
+// A typed receiver whose class does not declare the method binds to the
+// nearest ancestor that does — the case a same-name match cannot see, because
+// the declaring class is not the receiver's class. `$this->getRecord()` inside
+// StreamHandlerTest resolves to TestCase.getRecord two levels up.
+func TestResolvePHPOverrideDispatch_ReceiverTypeBindsInheritedMethod(t *testing.T) {
+ var s graph.Store = graph.New()
+ base := "tests/TestCase.php"
+ mid := "tests/HandlerTestCase.php"
+ leaf := "tests/StreamHandlerTest.php"
+
+ phpType(s, base+"::TestCase", "TestCase", nil)
+ phpMethod(s, base+"::TestCase.getRecord", "getRecord", "TestCase")
+ phpType(s, mid+"::HandlerTestCase", "HandlerTestCase", map[string]any{MetaScopeParentClass: "TestCase"})
+ phpType(s, leaf+"::StreamHandlerTest", "StreamHandlerTest", map[string]any{MetaScopeParentClass: "HandlerTestCase"})
+
+ caller := leaf + "::StreamHandlerTest.testWrite"
+ phpMethod(s, caller, "testWrite", "StreamHandlerTest")
+ s.AddEdge(&graph.Edge{
+ From: caller, To: "unresolved::*.getRecord", Kind: graph.EdgeCalls,
+ FilePath: leaf, Line: 12,
+ Meta: map[string]any{"receiver_type": "StreamHandlerTest"},
+ })
+
+ require.Positive(t, New(s).resolvePHPOverrideDispatch())
+
+ out := s.GetOutEdges(caller)
+ require.Len(t, out, 1)
+ assert.Equal(t, base+"::TestCase.getRecord", out[0].To)
+ assert.Equal(t, "receiver", out[0].Meta["dispatch"])
+ assert.Equal(t, graph.OriginASTInferred, out[0].Origin)
+}
+
+// The receiver's own class wins over an ancestor that also declares the
+// method — an override must not be attributed to the base it overrides.
+func TestResolvePHPOverrideDispatch_ReceiverTypePrefersOwnDeclaration(t *testing.T) {
+ var s graph.Store = graph.New()
+ base := "src/AbstractHandler.php"
+ leaf := "src/StreamHandler.php"
+ app := "src/app.php"
+
+ phpType(s, base+"::AbstractHandler", "AbstractHandler", nil)
+ phpMethod(s, base+"::AbstractHandler.handle", "handle", "AbstractHandler")
+ phpType(s, leaf+"::StreamHandler", "StreamHandler", map[string]any{MetaScopeParentClass: "AbstractHandler"})
+ phpMethod(s, leaf+"::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: 3,
+ Meta: map[string]any{"receiver_type": "StreamHandler"},
+ })
+
+ require.Positive(t, New(s).resolvePHPOverrideDispatch())
+
+ out := s.GetOutEdges(caller)
+ require.Len(t, out, 1, "a typed receiver binds once — it must not fan out")
+ assert.Equal(t, leaf+"::StreamHandler.handle", out[0].To)
+}
+
+// A receiver typed as a class the graph does not know (a vendor dependency)
+// stays unresolved: fanning it out across every same-named method would
+// contradict the type the source states.
+func TestResolvePHPOverrideDispatch_UnknownReceiverTypeStaysUnresolved(t *testing.T) {
+ var s graph.Store = graph.New()
+ a := "src/A.php"
+ b := "src/B.php"
+ app := "src/app.php"
+
+ phpIface(s, a+"::Alpha", "Alpha", nil)
+ phpMethod(s, a+"::Alpha.send", "send", "Alpha")
+ phpType(s, b+"::Beta", "Beta", map[string]any{"scope_interfaces": "Alpha"})
+ phpMethod(s, b+"::Beta.send", "send", "Beta")
+
+ 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::*.send", Kind: graph.EdgeCalls,
+ FilePath: app, Line: 7,
+ Meta: map[string]any{"receiver_type": "GuzzleClient"},
+ })
+
+ New(s).resolvePHPOverrideDispatch()
+
+ out := s.GetOutEdges(caller)
+ require.Len(t, out, 1)
+ assert.Equal(t, "unresolved::*.send", out[0].To, "typed receiver must not fall back to a fan-out")
+}
+
+// A trait's method is reachable from the class that uses it: trait composition
+// is an extends-shaped edge, so the ancestor walk crosses it.
+func TestResolvePHPOverrideDispatch_ReceiverTypeBindsThroughTrait(t *testing.T) {
+ var s graph.Store = graph.New()
+ tr := "src/LogsMessages.php"
+ cls := "src/Worker.php"
+
+ phpType(s, tr+"::LogsMessages", "LogsMessages", nil)
+ phpMethod(s, tr+"::LogsMessages.logInfo", "logInfo", "LogsMessages")
+ phpType(s, cls+"::Worker", "Worker", nil)
+ s.AddEdge(&graph.Edge{From: cls + "::Worker", To: tr + "::LogsMessages", Kind: graph.EdgeExtends, FilePath: cls, Line: 2})
+
+ caller := cls + "::Worker.run"
+ phpMethod(s, caller, "run", "Worker")
+ s.AddEdge(&graph.Edge{
+ From: caller, To: "unresolved::*.logInfo", Kind: graph.EdgeCalls,
+ FilePath: cls, Line: 9,
+ Meta: map[string]any{"receiver_type": "Worker"},
+ })
+
+ require.Positive(t, New(s).resolvePHPOverrideDispatch())
+ assert.Equal(t, tr+"::LogsMessages.logInfo", s.GetOutEdges(caller)[0].To)
+}
+
+// A PHP "package" is one directory holding every sibling implementation, so the
+// generic resolver's locality fallback — pick a same-directory method of the
+// same name — is exactly wrong for an inherited call: `$this->getFormatter()`
+// inside AmqpHandler landed on whichever sibling handler sorted first. When the
+// receiver names a type this repo defines, the locality pick is withheld so the
+// hierarchy walk can bind the declaration the receiver actually inherits.
+func TestResolveAll_PHPTypedReceiverBeatsSameDirectoryGuess(t *testing.T) {
+ var s graph.Store = graph.New()
+ dir := "src/Handler/"
+ iface := dir + "FormattableHandlerInterface.php"
+ sibling := dir + "BufferHandler.php"
+ caller := dir + "AmqpHandler.php"
+
+ phpIface(s, iface+"::FormattableHandlerInterface", "FormattableHandlerInterface", nil)
+ phpMethod(s, iface+"::FormattableHandlerInterface.getFormatter", "getFormatter", "FormattableHandlerInterface")
+ // A same-directory sibling declaring the same name — the decoy the
+ // locality fallback used to pick.
+ phpType(s, sibling+"::BufferHandler", "BufferHandler", nil)
+ phpMethod(s, sibling+"::BufferHandler.getFormatter", "getFormatter", "BufferHandler")
+
+ phpType(s, caller+"::AmqpHandler", "AmqpHandler",
+ map[string]any{"scope_interfaces": "FormattableHandlerInterface"})
+ callerID := caller + "::AmqpHandler.handleBatch"
+ phpMethod(s, callerID, "handleBatch", "AmqpHandler")
+ s.AddEdge(&graph.Edge{
+ From: callerID, To: "unresolved::*.getFormatter", Kind: graph.EdgeCalls,
+ FilePath: caller, Line: 124,
+ Meta: map[string]any{"receiver_type": "AmqpHandler"},
+ })
+
+ New(s).ResolveAll()
+
+ out := s.GetOutEdges(callerID)
+ require.Len(t, out, 1)
+ assert.Equal(t, iface+"::FormattableHandlerInterface.getFormatter", out[0].To,
+ "an inherited call must bind through the hierarchy, not to a same-directory sibling")
+}
+
+// The gate is limited to receivers this repo defines. A vendor-typed receiver
+// has no hierarchy in the graph either, so withholding the locality pick would
+// lose the edge with nowhere better to put it.
+func TestResolveAll_PHPVendorTypedReceiverKeepsLocalityPick(t *testing.T) {
+ var s graph.Store = graph.New()
+ dir := "src/"
+ local := dir + "Local.php"
+ caller := dir + "Client.php"
+
+ phpType(s, local+"::Local", "Local", nil)
+ phpMethod(s, local+"::Local.send", "send", "Local")
+
+ phpType(s, caller+"::Client", "Client", nil)
+ callerID := caller + "::Client.run"
+ phpMethod(s, callerID, "run", "Client")
+ s.AddEdge(&graph.Edge{
+ From: callerID, To: "unresolved::*.send", Kind: graph.EdgeCalls,
+ FilePath: caller, Line: 9,
+ // GuzzleClient is not defined anywhere in this graph.
+ Meta: map[string]any{"receiver_type": "GuzzleClient"},
+ })
+
+ New(s).ResolveAll()
+
+ out := s.GetOutEdges(callerID)
+ require.Len(t, out, 1)
+ assert.Equal(t, local+"::Local.send", out[0].To,
+ "a vendor-typed receiver keeps the pre-existing locality behaviour")
+}
diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go
index 80ee4c83..c97d4266 100644
--- a/internal/resolver/resolver.go
+++ b/internal/resolver/resolver.go
@@ -3709,6 +3709,24 @@ func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *Re
return
}
+ // PHP: a receiver typed as a class this repo DEFINES, whose type declares
+ // no method of this name, is calling something it INHERITS. The locality
+ // fallback below cannot express that — it picks by directory adjacency, and
+ // a PHP package is one directory holding every sibling implementation, so
+ // `$this->getFormatter()` inside AmqpHandler lands on whichever sibling
+ // handler sorts first rather than on the trait or interface that declares
+ // it. Leave the edge for resolvePHPOverrideDispatch, which walks the class
+ // hierarchy from the stated receiver and binds the nearest declaration.
+ //
+ // Gated on the receiver being in-repo: when it names a vendor class the
+ // hierarchy is not in the graph either, so withholding the locality pick
+ // would only lose the edge without anywhere better to put it.
+ if receiverType != "" && phpTypedReceiverCaller(callerNode) &&
+ r.hasInRepoType(phpBaseTypeName(receiverType), r.callerRepoPrefix(e)) {
+ stats.Unresolved++
+ return
+ }
+
// Locality fallback (replaces the previous alphabetical name-only
// pick). At this point candidates have survived Pass 0 — they all
// live in packages reachable from the caller. Prefer in this order: