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 + +
  • 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: