From df5713cc67eed49a7558009ee40e0a5b3f7602be Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 01:56:44 +0200 Subject: [PATCH 01/10] Type PHP call receivers so member calls bind to the right method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other AST extractor that records call sites stamps `receiver_type` on a member-call edge, which lets the resolver's exact-type passes bind `x.foo()` to the `foo` that belongs to x's type. PHP stamped it only for Laravel facades: every `$this->foo()`, `$obj->foo()` and `Foo::foo()` became a bare `unresolved::*.foo`, leaving the resolver nothing but a repo-wide name match. Because a name-only pick lands at the text_matched tier, the cross-package guard then reverted most of them — the calls were not merely imprecise, they were dropped. php_receiver.go builds a per-body environment from declarations only: `$this`, typed properties (including PHP 8 promoted constructor properties), typed parameters, `$v = new Foo`, `catch (Foo $e)` and `@var` annotations. It never guesses — a variable assigned two different classes is dropped rather than picked, and a union/intersection declaration types nothing, because a wrong receiver_type binds a call to the wrong method at the resolved tier instead of leaving it for the name-match fallback. Closures take a child scope so a closure parameter shadows an outer variable, and a `static` closure loses `$this`. Two shapes were dropped entirely and now emit edges: nullsafe calls (`$x?->m()`, never a case in extractCallSites) and static calls to an ordinary class (`Utils::pick()` was only typed when the scope happened to be a registered facade). On the resolver side a typed receiver binds through the class hierarchy, which is the case a same-name match cannot see: the method a receiver inherits is declared on an ancestor, not on the receiver's own class. A typed receiver that resolves to nothing stays unresolved rather than falling through to the name-only fan-out. In-principle call resolution (calls whose target is defined in-repo, so vendor and builtin calls are excluded), measured over three idiomatic corpora: monolog 32.6% -> 88.2% symfony/console 50.5% -> 79.9% guzzle 61.4% -> 82.9% --- internal/parser/languages/php.go | 66 +++- internal/parser/languages/php_facade_test.go | 17 +- internal/parser/languages/php_receiver.go | 369 ++++++++++++++++++ .../parser/languages/php_receiver_test.go | 337 ++++++++++++++++ internal/resolver/php_override_dispatch.go | 26 +- .../resolver/php_override_dispatch_test.go | 113 ++++++ 6 files changed, 907 insertions(+), 21 deletions(-) create mode 100644 internal/parser/languages/php_receiver.go create mode 100644 internal/parser/languages/php_receiver_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 8f87258cd..22816a8e9 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -292,6 +292,10 @@ 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() { @@ -299,7 +303,7 @@ func (e *PHPExtractor) extractPhpMembers( if n := e.findChildByFieldName(child, "name"); n != nil { methodNodes[n.Content(src)] = child } - e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName) + e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName, props) case "const_declaration": e.extractPhpClassConst(child, src, filePath, fileNode, result, seen, ownerName, ownerID) case "property_declaration": @@ -705,10 +709,13 @@ 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) + e.extractCallSitesInScope(body, src, filePath, id, result, + newPHPReceiverEnv(node, src, "", nil)) } } @@ -716,7 +723,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 { @@ -771,10 +778,13 @@ 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) + e.extractCallSitesInScope(body, src, filePath, id, result, + newPHPReceiverEnv(node, src, className, props)) } } @@ -844,8 +854,24 @@ func (e *PHPExtractor) extractCallSites( node *sitter.Node, src []byte, filePath string, callerID string, result *parser.ExtractionResult, +) { + e.extractCallSitesInScope(node, src, filePath, callerID, result, phpReceiverEnv{}) +} + +// extractCallSitesInScope is extractCallSites carrying the receiver-typing +// environment of the function body being walked. 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, ) { switch node.Type() { + case "anonymous_function", "anonymous_function_creation_expression", "arrow_function": + env = env.childScope(node, src) case "function_call_expression": funcNode := node.ChildByFieldName("function") if funcNode != nil { @@ -859,7 +885,7 @@ 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 { name := nameNode.Content(src) @@ -905,22 +931,32 @@ 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)) + } } } } @@ -931,7 +967,7 @@ func (e *PHPExtractor) extractCallSites( // Recurse into children. for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ { child := node.NamedChild(i) - e.extractCallSites(child, src, filePath, callerID, result) + e.extractCallSitesInScope(child, src, filePath, callerID, result, env) } } diff --git a/internal/parser/languages/php_facade_test.go b/internal/parser/languages/php_facade_test.go index b03554204..b0d6b0e7d 100644 --- a/internal/parser/languages/php_facade_test.go +++ b/internal/parser/languages/php_facade_test.go @@ -47,9 +47,11 @@ class Svc { } } -// TestPHPFacade_NonFacadeStaticCallUnstamped pins that an ordinary static call -// to a class that is not a registered facade carries no receiver_type hint. -func TestPHPFacade_NonFacadeStaticCallUnstamped(t *testing.T) { +// TestPHPFacade_NonFacadeStaticCallUsesScopeClass pins that an ordinary static +// call to a class that is not a registered facade is typed by its own scope — +// `Helper::frobnicate()` receives `Helper`, not a facade's backing class and +// not the empty hint the facade pass leaves behind. +func TestPHPFacade_NonFacadeStaticCallUsesScopeClass(t *testing.T) { src := []byte(`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] +} + +// withClass returns a copy of env bound to a different enclosing class. Used +// when descending into a `static` closure, which does not inherit $this. +func (env phpReceiverEnv) withClass(class string) phpReceiverEnv { + env.class = class + return env +} + +// 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. `parent`, +// `self` and `static` are handled by the scope_kind path in extractCallSites +// and return "" here; a `$var::m()` scope is typed through the environment. +func (env phpReceiverEnv) phpScopeReceiverType(scope *sitter.Node, src []byte) string { + if scope == nil { + 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 000000000..9adbc3422 --- /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/resolver/php_override_dispatch.go b/internal/resolver/php_override_dispatch.go index 774e88664..078087102 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 diff --git a/internal/resolver/php_override_dispatch_test.go b/internal/resolver/php_override_dispatch_test.go index fd7804bcf..0eac2bd70 100644 --- a/internal/resolver/php_override_dispatch_test.go +++ b/internal/resolver/php_override_dispatch_test.go @@ -230,3 +230,116 @@ 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) +} From 4f7159df388ae905bf6ee105dec82b71befd443f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 02:03:23 +0200 Subject: [PATCH 02/10] Emit PHP property and class-constant usage edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Before this change monolog's whole graph held 76 read edges and zero writes against 322 field nodes. Each access now emits EdgeReads, or EdgeWrites in assignment position, to `unresolved::*.` with the receiver typed where the source states it. That is the shape resolveFieldRef already consumes: with a receiver_type it binds to that type's field exactly, and without one it falls through the same locality cascade every other language uses. Write detection climbs subscripts, because `$this->handlers[] = $h` mutates the property — but only through the container operand, so a member access used as an index (`$store[$this->key] = $v`) stays a read. `++`/`--` and `unset()` are writes too. Two shapes deliberately emit nothing: a dynamic member (`$obj->$name`, `$obj->{$expr}`) names no symbol, and `Foo::class` is the class-name literal rather than a constant member — its type reference is already emitted by the reference-form pass. Measured over three corpora (resolved/total): monolog reads 1065/2259, writes 458/528 symfony/console reads 1478/2769, writes 591/618 guzzle reads 930/2329, writes 332/441 --- internal/parser/languages/php.go | 3 + .../parser/languages/php_property_access.go | 139 ++++++++++++++ .../languages/php_property_access_test.go | 180 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 internal/parser/languages/php_property_access.go create mode 100644 internal/parser/languages/php_property_access_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 22816a8e9..a19d2fbe8 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -872,6 +872,9 @@ func (e *PHPExtractor) extractCallSitesInScope( switch node.Type() { case "anonymous_function", "anonymous_function_creation_expression", "arrow_function": env = env.childScope(node, src) + 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 { diff --git a/internal/parser/languages/php_property_access.go b/internal/parser/languages/php_property_access.go new file mode 100644 index 000000000..509c03a36 --- /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 000000000..bfc49652d --- /dev/null +++ b/internal/parser/languages/php_property_access_test.go @@ -0,0 +1,180 @@ +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) +} From 963d0e76cb9e9acb56b06aa78aeebcb2a8605b03 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 02:10:04 +0200 Subject: [PATCH 03/10] Extract PHP file-scope statements and anonymous classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractCallSites was reached only from extractFunction and extractMethod, so every statement outside a named function or method was invisible: a script body, a bootstrap file, a Laravel routes file, a WordPress theme template, a fixture that returns a closure. 35 of symfony/console's 361 files produced a file node, their imports, and nothing else. A second walk now attributes those statements to the file node, stopping at every declaration so a method body is not counted once for its method and again for the file. Its environment is seeded from the top-level `$v = new Foo` bindings, so `$app = new Application(); $app->run();` in a bootstrap script types its receiver exactly as it would inside a function. A closure written at file scope is walked as part of it — the closure is not a symbol, so its body belongs to the file that defines it. Anonymous classes were not extracted at all: `new class extends B { ... }` minted no type node, so its methods were not symbols, calls into them resolved nowhere, and calls out of them were attributed to whichever function enclosed the expression. They now mint a type carrying the extends / implements clauses, with members extracted the same way a named class's are. The name is line-qualified (`class@anonymous:12`) because PHP allows several per file and the members' receiver meta has to tell them apart for receiver-directed resolution to stay precise. --- internal/parser/languages/php.go | 37 ++++- internal/parser/languages/php_file_scope.go | 148 ++++++++++++++++++ .../parser/languages/php_file_scope_test.go | 142 +++++++++++++++++ 3 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 internal/parser/languages/php_file_scope.go create mode 100644 internal/parser/languages/php_file_scope_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index a19d2fbe8..eeb35e7c0 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -49,6 +49,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,6 +97,9 @@ 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) @@ -869,9 +876,29 @@ func (e *PHPExtractor) extractCallSitesInScope( result *parser.ExtractionResult, env phpReceiverEnv, ) { - switch node.Type() { - case "anonymous_function", "anonymous_function_creation_expression", "arrow_function": + 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) @@ -966,12 +993,6 @@ func (e *PHPExtractor) extractCallSitesInScope( result.Edges = append(result.Edges, edge) } } - - // 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) - } } // findChildByType finds the first named child with the given type. diff --git a/internal/parser/languages/php_file_scope.go b/internal/parser/languages/php_file_scope.go new file mode 100644 index 000000000..8facfd866 --- /dev/null +++ b/internal/parser/languages/php_file_scope.go @@ -0,0 +1,148 @@ +package languages + +import ( + "fmt" + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// File-scope PHP. +// +// extractCallSites is reached only from extractFunction / extractMethod, so +// every statement that is not inside a named function or method was invisible: +// a script's whole body, a bootstrap file, a Laravel `routes/web.php`, a +// WordPress theme template, a fixture that returns a closure. Those files +// produced a file node and nothing else — 35 of symfony/console's 361 files +// yielded zero symbols and zero call edges. +// +// walkPHPFileScope re-walks the tree attributing those statements to the file +// node, stopping wherever a declaration owns its own subtree so nothing is +// counted twice. + +// phpDeclarationBoundary reports whether a node's subtree is extracted by its +// own declaration handler, and so must not be walked again at file scope. +func phpDeclarationBoundary(t string) bool { + switch t { + case "function_definition", "method_declaration", "class_declaration", + "interface_declaration", "trait_declaration", "enum_declaration", + "anonymous_class": + return true + } + return false +} + +// walkPHPFileScope attributes calls and member accesses in top-level +// statements to the file node. A closure written at file scope is walked as +// part of it — the closure is not a symbol, so its body belongs to the file +// that defines it. +func (e *PHPExtractor) walkPHPFileScope( + node *sitter.Node, src []byte, + filePath string, fileNode *graph.Node, + result *parser.ExtractionResult, + env phpReceiverEnv, +) { + if node == nil || phpDeclarationBoundary(node.Type()) { + return + } + switch node.Type() { + case "anonymous_function", "anonymous_function_creation_expression", "arrow_function": + env = env.childScope(node, src) + } + e.emitPHPCallSiteEdges(node, src, filePath, fileNode.ID, result, env) + for i, n := 0, int(node.NamedChildCount()); i < n; i++ { + e.walkPHPFileScope(node.NamedChild(i), src, filePath, fileNode, result, env) + } +} + +// extractPHPFileScope seeds the file-scope walk. The environment is built from +// the top-level statements themselves, so `$app = new Application(); $app->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 000000000..bd16a45cf --- /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(` Date: Tue, 28 Jul 2026 02:13:56 +0200 Subject: [PATCH 04/10] Expand PHP grouped use declarations and record import metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `use Monolog\Handler\{StreamHandler, NullHandler};` emitted one import of the shared prefix `Monolog/Handler` and dropped every member — the grammar puts the braced members under a `body` field the walk never looked at, so a group-importing file appeared to import nothing it actually named. Each member now emits its own import, joined to the prefix. Import edges also carry what the source wrote: the fully-qualified name, the short symbol, the `as` alias, and whether `use function` / `use const` qualified it — including a group that qualifies members individually (`use Monolog\{Logger, function Utils\jsonEncode}`). The edge target keeps its historical slash-separated shape, so nothing downstream has to change to benefit from the extra members. --- internal/parser/languages/php.go | 127 +++++++++++++++---- internal/parser/languages/php_import_test.go | 97 ++++++++++++++ 2 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 internal/parser/languages/php_import_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index eeb35e7c0..6d5318e72 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -800,39 +800,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 - } - if importPath == "" { - continue + e.emitPHPImportEdge(child.Content(src), "", declKind, filePath, fileNode, + int(child.StartPoint().Row)+1, result) } - 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, diff --git a/internal/parser/languages/php_import_test.go b/internal/parser/languages/php_import_test.go new file mode 100644 index 000000000..437924233 --- /dev/null +++ b/internal/parser/languages/php_import_test.go @@ -0,0 +1,97 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// phpImports returns every import edge keyed by target, with its meta. +func phpImports(t *testing.T, src string) map[string]map[string]any { + t.Helper() + res, err := NewPHPExtractor().Extract("imp.php", []byte(src)) + require.NoError(t, err) + out := map[string]map[string]any{} + for _, e := range res.Edges { + if e.Kind == graph.EdgeImports { + out[e.To] = e.Meta + } + } + return out +} + +func TestPHPImport_PlainUseCarriesFQNAndSymbol(t *testing.T) { + got := phpImports(t, ` Date: Tue, 28 Jul 2026 02:18:33 +0200 Subject: [PATCH 05/10] Mint the PHP declaration forms the member walk skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four declaration forms produced no symbol or no metadata: Constructor property promotion declares a property in the constructor's parameter list, so the class body holds no property_declaration and the member walk minted nothing. On a modern PHP codebase, where promotion is the idiomatic way to declare dependencies, a class's fields were simply absent from the graph. They now mint fields carrying visibility, readonly, declared type and a member_of edge, like any other property. Only `visibility` was stamped on members, so `static`, `abstract`, `final` and `readonly` were invisible — an analyzer could not tell a static method from an instance one, and readonly, the marker that a property is never written after construction, was lost. A file-scope `const NAME = ...;` and `define('NAME', ...)` defined no symbol at all: const_declaration was only reached inside a class body, and define() is an ordinary call with no declaration node. A constants file indexed as zero symbols. A computed `define($k, ...)` still mints nothing, since it names nothing statically. A PHP enum may implement interfaces, but the clause was not stamped into scope_interfaces, so the dispatch hierarchy could not see the enum as an implementor and a call through the interface never reached its methods. Also: `nullable_type` is not a node in tree-sitter-php v0.24.2 — the three switches naming it were dead, while PHP 8.2 disjunctive normal form (`(Countable&Traversable)|null`) fell through untyped. Swapping the dead case for the real one required teaching the atom split about parentheses, which otherwise leak into a target name as `unresolved::(Countable`. --- internal/parser/languages/php.go | 37 +++- internal/parser/languages/php_declarations.go | 163 ++++++++++++++++++ .../parser/languages/php_declarations_test.go | 151 ++++++++++++++++ 3 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 internal/parser/languages/php_declarations.go create mode 100644 internal/parser/languages/php_declarations_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 6d5318e72..551477534 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -103,6 +103,19 @@ func (e *PHPExtractor) walkNode( 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) @@ -311,6 +324,10 @@ func (e *PHPExtractor) extractPhpMembers( methodNodes[n.Content(src)] = child } e.extractMethod(child, src, filePath, fileNode, result, seen, ownerName, props) + if n := e.findChildByFieldName(child, "name"); n != nil && + strings.EqualFold(n.Content(src), "__construct") { + e.extractPHPPromotedProperties(child, src, filePath, fileNode, result, seen, ownerName, ownerID) + } case "const_declaration": e.extractPhpClassConst(child, src, filePath, fileNode, result, seen, ownerName, ownerID) case "property_declaration": @@ -384,6 +401,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 } @@ -467,6 +490,7 @@ func (e *PHPExtractor) extractPhpProperty( continue } meta := map[string]any{"receiver": ownerName, "visibility": vis} + phpMemberModifiers(node, meta) if propType != "" { meta["field_type"] = propType } @@ -542,7 +566,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 "" @@ -592,7 +616,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 @@ -624,7 +652,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 "" @@ -664,7 +692,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": @@ -752,6 +780,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 } diff --git a/internal/parser/languages/php_declarations.go b/internal/parser/languages/php_declarations.go new file mode 100644 index 000000000..9c843811f --- /dev/null +++ b/internal/parser/languages/php_declarations.go @@ -0,0 +1,163 @@ +package languages + +import ( + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// Declaration forms the member walk did not mint. + +// phpMemberModifiers records the declaration modifiers PHP allows on a class +// member. Only `visibility` was stamped before, so `static`, `abstract`, +// `final` and `readonly` were invisible: an analyzer could not tell a static +// method from an instance one, and `readonly` — the marker that a property is +// never written after construction — was lost entirely. +func phpMemberModifiers(member *sitter.Node, meta map[string]any) { + if member == nil || meta == nil { + return + } + for i, n := 0, int(member.NamedChildCount()); i < n; i++ { + switch member.NamedChild(i).Type() { + case "static_modifier": + meta["static"] = true + case "abstract_modifier": + meta["abstract"] = true + case "final_modifier": + meta["final"] = true + case "readonly_modifier": + meta["readonly"] = true + } + } +} + +// extractPHPPromotedProperties mints a field for every PHP 8 constructor +// promoted property. Promotion declares the property in the constructor's +// parameter list, so the class body holds no property_declaration for it and +// the member walk minted nothing — on a modern PHP codebase, where promotion is +// the idiomatic way to declare dependencies, the class's fields were simply +// absent from the graph. +func (e *PHPExtractor) extractPHPPromotedProperties( + ctor *sitter.Node, src []byte, + filePath string, fileNode *graph.Node, + result *parser.ExtractionResult, seen map[string]bool, + ownerName, ownerID string, +) { + if ctor == nil { + return + } + params := ctor.ChildByFieldName("parameters") + if params == nil { + return + } + for i, n := 0, int(params.NamedChildCount()); i < n; i++ { + p := params.NamedChild(i) + if p.Type() != "property_promotion_parameter" { + continue + } + name := phpParamVarName(p, src) + if name == "" { + continue + } + line := int(p.StartPoint().Row) + 1 + id, ok := disambiguateID(seen, filePath+"::"+ownerName+"."+name, line) + if !ok { + continue + } + vis := VisibilityPublic + if v := p.ChildByFieldName("visibility"); v != nil { + vis = strings.ToLower(strings.TrimSpace(v.Content(src))) + } + meta := map[string]any{ + "receiver": ownerName, "visibility": vis, "promoted": true, + } + if p.ChildByFieldName("readonly") != nil { + meta["readonly"] = true + } + propType := phpParameterType(p, src) + if propType != "" { + meta["field_type"] = propType + } + result.Nodes = append(result.Nodes, &graph.Node{ + ID: id, Kind: graph.KindField, Name: name, + FilePath: filePath, StartLine: line, EndLine: line, Language: "php", Meta: meta, + }) + result.Edges = append(result.Edges, &graph.Edge{ + From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line, + }) + result.Edges = append(result.Edges, &graph.Edge{ + From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line, + }) + emitPHPTypeUseEdges(id, propType, filePath, line, result) + } +} + +// extractPHPFileConstant mints a file-scope `const NAME = ...;`. The member +// walk only reached const_declaration inside a class body, so a constants file +// — a common PHP idiom — defined no symbols at all. +func (e *PHPExtractor) extractPHPFileConstant( + node *sitter.Node, src []byte, + filePath string, fileNode *graph.Node, + result *parser.ExtractionResult, seen map[string]bool, +) { + for i, n := 0, int(node.NamedChildCount()); i < n; i++ { + ce := node.NamedChild(i) + if ce.Type() != "const_element" { + continue + } + nameNode := e.findChildByType(ce, "name") + if nameNode == nil { + continue + } + e.emitPHPFileConstantNode(nameNode.Content(src), int(ce.StartPoint().Row)+1, + filePath, fileNode, result, seen) + } +} + +// extractPHPDefineConstant mints the constant declared by `define('NAME', ...)`. +// PHP's other constant-declaration form is an ordinary function call, so no +// declaration node exists for it. +func (e *PHPExtractor) extractPHPDefineConstant( + call *sitter.Node, src []byte, + filePath string, fileNode *graph.Node, + result *parser.ExtractionResult, seen map[string]bool, +) { + fn := call.ChildByFieldName("function") + if fn == nil || !strings.EqualFold(strings.TrimLeft(fn.Content(src), `\`), "define") { + return + } + args := call.ChildByFieldName("arguments") + if args == nil || args.NamedChildCount() == 0 { + return + } + name := e.extractStringContent(args.NamedChild(0), src) + // A computed name (`define($k, …)`) declares nothing statically knowable. + if name == "" || strings.ContainsAny(name, "$ ") { + return + } + e.emitPHPFileConstantNode(name, int(call.StartPoint().Row)+1, filePath, fileNode, result, seen) +} + +func (e *PHPExtractor) emitPHPFileConstantNode( + name string, line int, + filePath string, fileNode *graph.Node, + result *parser.ExtractionResult, seen map[string]bool, +) { + if name == "" { + return + } + id, ok := disambiguateID(seen, filePath+"::"+name, line) + if !ok { + return + } + result.Nodes = append(result.Nodes, &graph.Node{ + ID: id, Kind: graph.KindConstant, Name: name, + FilePath: filePath, StartLine: line, EndLine: line, Language: "php", + Meta: map[string]any{"visibility": VisibilityPublic}, + }) + result.Edges = append(result.Edges, &graph.Edge{ + From: fileNode.ID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line, + }) +} diff --git a/internal/parser/languages/php_declarations_test.go b/internal/parser/languages/php_declarations_test.go new file mode 100644 index 000000000..e98f83286 --- /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(` Date: Tue, 28 Jul 2026 02:22:59 +0200 Subject: [PATCH 06/10] Stop minting unbindable PHP call targets; lower new to a constructor call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP lets a callee be any expression — `$fn()`, `$obj->$method()`, `Foo::$m()`, `($resolver)()`, `$handlers[0]()` — and the extractor emitted the callee's raw source text as the edge target. That produced placeholders like `unresolved::*.$fn` and `unresolved::*.($this->resolver)` which can never bind to anything: 371 of them in guzzle alone, where `*.$handler` was the third most common unresolved target in the whole repo. A computed callee now emits nothing; the receiver-typed member calls inside such an expression are still captured by the walk. `new Foo()` emitted only an instantiation edge, which points at the class rather than at the code that runs — so a PHP constructor showed no callers at all, no matter how often the class was instantiated. It now also lowers to a call of `__construct` with the receiver typed, the same shape Java uses, so the resolver binds it to Foo's own constructor or to the nearest ancestor's when Foo inherits one. `new $cls` and `new class {}` name no constructible type and still emit nothing. Catch clauses named no types, so a class used only as an exception type looked unreferenced and the handler was invisible to error-surface analysis. Each caught type in `catch (A | B $e)` is now a reference. --- internal/parser/languages/php.go | 9 +- .../parser/languages/php_call_hygiene_test.go | 124 ++++++++++++++++++ internal/parser/languages/php_declarations.go | 16 +++ internal/parser/languages/php_reffrm.go | 39 ++++++ 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 internal/parser/languages/php_call_hygiene_test.go diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 551477534..f3db85008 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -1012,7 +1012,12 @@ func (e *PHPExtractor) emitPHPCallSiteEdges( 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:] @@ -1025,7 +1030,7 @@ func (e *PHPExtractor) emitPHPCallSiteEdges( } 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{ 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 000000000..83845deba --- /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_reffrm.go b/internal/parser/languages/php_reffrm.go index 35a3bc853..d1d8150bd 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"}, + }) +} From 36dc6a00136a6ec6b2b5a9e75eb72adc5f73c5e4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 02:27:40 +0200 Subject: [PATCH 07/10] Give PHP complexity metrics and index .phtml templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit langComplexityTables had no PHP entry, so StampFunctionMetrics was a no-op for the language and no PHP function or method carried complexity, cognitive or loop_depth meta. Everything reading those keys — analyze bottlenecks, health_score, impact — saw a PHP repo as uniformly trivial. The table follows the grammar: `else_if_clause` is a decision point while a bare `else_clause` is not, a `match` arm counts like a switch case, and `conditional_expression` covers both ternary forms. The skip set stops the walk at every nested declaration, so a closure's branches are scored against the closure rather than folded into the function returning it. `.phtml` — the PHP template extension Zend, Laminas and Magento use — was not in Extensions(), so those files were not indexed at all. The binding is the HTML-framing grammar, so a template already parses as PHP; the extension list was the only thing keeping it out. --- .../parser/languages/helpers_complexity.go | 41 +++++++++ internal/parser/languages/php.go | 13 ++- internal/parser/languages/php_metrics_test.go | 87 +++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 internal/parser/languages/php_metrics_test.go diff --git a/internal/parser/languages/helpers_complexity.go b/internal/parser/languages/helpers_complexity.go index bc2d7760e..a2ba9cd65 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 f3db85008..c3966e51c 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) { @@ -749,6 +754,8 @@ func (e *PHPExtractor) extractFunction( // receiver. body := e.findChildByType(node, "compound_statement") if body != nil { + cyc, cog, loop := BodyComplexityMetrics(body, "php") + ApplyComplexityMeta(meta, cyc, cog, loop) e.extractCallSitesInScope(body, src, filePath, id, result, newPHPReceiverEnv(node, src, "", nil)) } @@ -819,6 +826,8 @@ func (e *PHPExtractor) extractMethod( // type the resolver can bind exactly. body := e.findChildByType(node, "compound_statement") if body != nil { + cyc, cog, loop := BodyComplexityMetrics(body, "php") + ApplyComplexityMeta(meta, cyc, cog, loop) e.extractCallSitesInScope(body, src, filePath, id, result, newPHPReceiverEnv(node, src, className, props)) } diff --git a/internal/parser/languages/php_metrics_test.go b/internal/parser/languages/php_metrics_test.go new file mode 100644 index 000000000..a5718493e --- /dev/null +++ b/internal/parser/languages/php_metrics_test.go @@ -0,0 +1,87 @@ +package languages + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func TestPHPMetrics_ComplexityStampedOnMethods(t *testing.T) { + nodes := phpNodesByName(t, ` $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") +} From b624092c3aebeffa00905e4d4d16dd3db3824c70 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 02:32:30 +0200 Subject: [PATCH 08/10] Drop the PHP call-walk helpers left unused by the rewrite extractCallSites became a wrapper with no callers once the walk split into emitPHPCallSiteEdges plus its two drivers, and phpReceiverEnv.withClass was superseded by childScope handling the static-closure case directly. Both trip golangci-lint's unused check. --- internal/parser/languages/php.go | 18 +++++------------- internal/parser/languages/php_receiver.go | 7 ------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index c3966e51c..04ea4c8ff 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -974,19 +974,11 @@ func (e *PHPExtractor) extractRequireInclude( } } -func (e *PHPExtractor) extractCallSites( - node *sitter.Node, src []byte, - filePath string, callerID string, - result *parser.ExtractionResult, -) { - e.extractCallSitesInScope(node, src, filePath, callerID, result, phpReceiverEnv{}) -} - -// extractCallSitesInScope is extractCallSites carrying the receiver-typing -// environment of the function body being walked. 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. +// 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, diff --git a/internal/parser/languages/php_receiver.go b/internal/parser/languages/php_receiver.go index c770c5bff..f9c8d3b0b 100644 --- a/internal/parser/languages/php_receiver.go +++ b/internal/parser/languages/php_receiver.go @@ -60,13 +60,6 @@ func (env phpReceiverEnv) lookupProp(name string) string { return env.props[name] } -// withClass returns a copy of env bound to a different enclosing class. Used -// when descending into a `static` closure, which does not inherit $this. -func (env phpReceiverEnv) withClass(class string) phpReceiverEnv { - env.class = class - return env -} - // 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 From f4e2346bfe24dcb08c4f8c20d43070b5514b05bc Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Tue, 28 Jul 2026 02:47:28 +0200 Subject: [PATCH 09/10] Type self:: and static:: member accesses by the enclosing class A static property or class constant reached through a relative scope (`self::$items`, `static::LIMIT`) fell through untyped, because the scope is a relative_scope node rather than a name. 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::` stays untyped on purpose so the existing scope_kind path walks up from the enclosing class instead of pinning to it. --- .../languages/php_property_access_test.go | 24 +++++++++++++++++++ internal/parser/languages/php_receiver.go | 19 ++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/parser/languages/php_property_access_test.go b/internal/parser/languages/php_property_access_test.go index bfc49652d..88b14af38 100644 --- a/internal/parser/languages/php_property_access_test.go +++ b/internal/parser/languages/php_property_access_test.go @@ -178,3 +178,27 @@ class Service { 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, ` Date: Tue, 28 Jul 2026 02:54:35 +0200 Subject: [PATCH 10/10] Stop a same-directory guess from stealing an inherited PHP call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once receivers carry a type, `$this->getFormatter()` inside AmqpHandler still bound to BufferHandler.getFormatter — a sibling class that happens to sort first. The exact-type passes cannot fire, because the method is inherited rather than declared on the receiver, so resolveMethodCall fell through to its locality fallback: prefer a same-name method in the caller's directory. That heuristic is sound where a directory is a package boundary, and wrong for PHP, where one directory holds every sibling implementation of an interface. When the receiver names a type this repo defines, the locality pick is now withheld and the edge left for resolvePHPOverrideDispatch, which walks the hierarchy from the stated receiver and binds the declaration it actually inherits. Verified on monolog: the calls above now land on FormattableHandlerInterface / FormattableHandlerTrait. The gate stops at in-repo receivers on purpose. 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 — that case keeps its existing behaviour. Gating this way costs 0.3-1.9pp of in-principle call resolution versus letting the guesses stand, in exchange for the wrong bindings. --- internal/parser/languages/php.go | 9 ++- internal/resolver/php_override_dispatch.go | 9 +++ .../resolver/php_override_dispatch_test.go | 68 +++++++++++++++++++ internal/resolver/resolver.go | 18 +++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/internal/parser/languages/php.go b/internal/parser/languages/php.go index 04ea4c8ff..9c200f455 100644 --- a/internal/parser/languages/php.go +++ b/internal/parser/languages/php.go @@ -325,12 +325,15 @@ func (e *PHPExtractor) extractPhpMembers( 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) - if n := e.findChildByFieldName(child, "name"); n != nil && - strings.EqualFold(n.Content(src), "__construct") { + // 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) } case "const_declaration": diff --git a/internal/resolver/php_override_dispatch.go b/internal/resolver/php_override_dispatch.go index 078087102..f0c469f51 100644 --- a/internal/resolver/php_override_dispatch.go +++ b/internal/resolver/php_override_dispatch.go @@ -375,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 0eac2bd70..b9ec55fce 100644 --- a/internal/resolver/php_override_dispatch_test.go +++ b/internal/resolver/php_override_dispatch_test.go @@ -343,3 +343,71 @@ func TestResolvePHPOverrideDispatch_ReceiverTypeBindsThroughTrait(t *testing.T) 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 80ee4c83a..c97d42667 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: