Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.8). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not.
- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the same exposure in the Java, C#, C++, Swift and TypeScript resolvers is untouched and left as a follow-up.
- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused; and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk is worth naming: a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfies the single-definition guard and mints a wrong INFERRED edge. Java has the identical exposure; closing it needs per-file `use` maps threaded into the resolver.
- Known open items tracked against this work, unfixed in this release: union- and intersection-typed receivers still mint a bare-name edge when the candidate methods live in the SAME file as the call (the refusal above holds across files but the legacy in-file matcher does not see a refused type as different from an untyped one, `lawnstarter/graphify#9`); the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`).
- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched.
- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one.
- Known open items tracked against this work, unfixed in this release: the untagged member-call resolvers still consume each other's raw calls, so a TypeScript receiver can mint a Python edge (`lawnstarter/graphify#10`); and a PHP 8.1 first-class callable (`$obj->method(...)`) emits `calls` even though it only references the method, where the existing `indirect_call` relation may be the more faithful label (`lawnstarter/graphify#15`).
- The package version bump rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`), so a file dispatched for extraction after upgrading is re-parsed instead of being served a cached entry whose `raw_calls` predate the receiver fields. The bump does not by itself force a re-extraction: `graphify extract` on a corpus with an unchanged stat index reports every file cached and never consults the AST cache, so it replays the pre-upgrade graph. To pick up the new PHP edges on an existing graph, run `graphify update .`, or delete `graphify-out/manifest.json` — either re-dispatches the corpus, and the cache namespace then does its job.

## 0.9.33 (unreleased)
Expand Down
116 changes: 95 additions & 21 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,32 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None:
return None


# Type expressions that declare MORE THAN ONE possible class for a receiver:
# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form
# (`(A&B)|C`, a union at top level) — node names probe-verified against
# tree-sitter-php 0.24.1.
_PHP_MULTI_TYPE_NODES = frozenset({
"union_type", "intersection_type", "disjunctive_normal_form_type",
})


def _php_multi_typed_annotation(type_node) -> bool:
"""True when a PHP type annotation names more than one candidate class (#9).

`_php_concrete_type_name` refuses several shapes, and the two reasons for
refusal must be told apart at the call site (user story 11): a receiver whose
annotation is a UNION or INTERSECTION provably has several possible classes,
so an in-file bare-name match can only pick one of them by file order and
must be suppressed. The policy's other refusals — `self`/`static`/`parent`,
primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more
than one type — declare no such multiplicity, so they deliberately keep
today's in-file edge, exactly like a genuinely untyped receiver (#2's
accepted deviation, user story 9). `self` especially: it names the calling
class, whose methods usually ARE the in-file match.
"""
return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES


# Subtrees that are a DIFFERENT binding scope than the method being scanned:
# their assignments must not type the enclosing method's variables. Closures are
# deliberately absent — their calls are attributed to the enclosing method, so
Expand All @@ -761,8 +787,8 @@ def _php_concrete_type_name(type_node, source: bytes) -> str | None:
def _php_method_receiver_types(
method_node,
source: bytes,
field_types: dict[str, str],
) -> dict[str, str]:
field_types: dict[str, str | None],
) -> dict[str, str | None]:
"""Build the receiver type table visible to one PHP method (#1682).

``this.<prop>`` keys come from the declaring class's typed properties and
Expand All @@ -777,9 +803,18 @@ def _php_method_receiver_types(
target, a list-destructuring element, or a `global`/`static` statement
rebinding it to other storage. Poisoning is order-independent, which is why
it can be decided from a single unordered walk.

A key mapped to None is PRESENT but unresolved: its annotation named several
candidate classes (`A|B`, `A&B`), which the call site must tell apart from an
ABSENT key, meaning no annotation at all (#9). Precedence is concrete type >
multi-class refusal > absent, so a union-typed param later assigned a `new T()`
still resolves to T, while a poisoned union-typed one stays refused.
"""
table = {f"this.{name}": type_name for name, type_name in field_types.items()}
table: dict[str, str | None] = {
f"this.{name}": type_name for name, type_name in field_types.items()
}
method_types: dict[str, str] = {}
multi_typed_params: set[str] = set()
ambiguous: set[str] = set()

def poison(name: str) -> None:
Expand Down Expand Up @@ -832,13 +867,17 @@ def new_type_name(node) -> str | None:
for param in params.children:
if param.type not in ("simple_parameter", "property_promotion_parameter"):
continue
type_name = _php_concrete_type_name(
param.child_by_field_name("type"), source
)
type_node = param.child_by_field_name("type")
type_name = _php_concrete_type_name(type_node, source)
name_node = param.child_by_field_name("name")
if name_node is not None and type_name:
# Untyped / union / primitive params simply stay unbound.
# Untyped / primitive params simply stay unbound.
bind(_read_text(name_node, source).lstrip("$"), type_name)
elif name_node is not None and _php_multi_typed_annotation(type_node):
# A union/intersection param is not BOUND — `bind(None)` would
# poison it, and poisoning is indistinguishable from untyped.
# Mark it instead, and let the merge below apply precedence.
multi_typed_params.add(_read_text(name_node, source).lstrip("$"))

body = method_node.child_by_field_name("body")
stack = list(body.children) if body is not None else []
Expand Down Expand Up @@ -884,6 +923,10 @@ def new_type_name(node) -> str | None:
table.update(method_types)
for name in ambiguous:
table.pop(name, None)
for name in multi_typed_params:
# setdefault, so a concrete type learned from a `new` wins; a poisoned
# name (popped just above) falls back to the multi-class refusal.
table.setdefault(name, None)
return table


Expand Down Expand Up @@ -2656,7 +2699,10 @@ def _extract_generic(
csharp_method_scopes: dict[int, tuple[object, str]] = {}
# PHP receiver typing (#1682): typed properties and constructor-promoted
# params of the declaring class, keyed `this.<prop>` per method scope.
php_field_types: dict[str, dict[str, str]] = {}
# `prop -> declared type` per class. A value of None means the annotation was
# PRESENT but named several candidate classes (`A|B`, `A&B`), which the call
# site must tell apart from an ABSENT key = no annotation at all (#9).
php_field_types: dict[str, dict[str, str | None]] = {}
php_method_scopes: dict[int, tuple[object, str]] = {}

csharp_interface_names: set[str] = set()
Expand Down Expand Up @@ -3494,14 +3540,22 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
and parent_class_nid):
for c in node.children:
if c.type not in ("named_type", "primitive_type", "nullable_type",
"union_type", "intersection_type", "optional_type"):
"union_type", "intersection_type", "optional_type",
# PHP 8.2 `(A&B)|C`, absent from this list until
# #9 and so invisible to both the receiver table
# and the type-reference walk below.
"disjunctive_normal_form_type"):
continue
line = node.start_point[0] + 1
# #1682: remember the property's declared type so a later
# `$this->prop->method()` resolves against it. Only a single
# concrete class name counts — unions/primitives are refused.
# A multi-class annotation is recorded as None so the call site
# can defer rather than bare-name match it (#9); every other
# refusal leaves the property out of the table entirely.
type_name = _php_concrete_type_name(c, source)
if type_name:
multi_typed = _php_multi_typed_annotation(c)
if type_name or multi_typed:
fields = php_field_types.setdefault(parent_class_nid, {})
for pe in node.children:
if pe.type != "property_element":
Expand Down Expand Up @@ -3837,15 +3891,19 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
type_node = None
for sub in p.children:
if sub.type in ("named_type", "primitive_type", "nullable_type",
"union_type", "intersection_type", "optional_type"):
"union_type", "intersection_type", "optional_type",
"disjunctive_normal_form_type"):
type_node = sub
break
# #1682: a promoted param IS a typed class property —
# record it in the same `this.<prop>` receiver table.
# record it in the same `this.<prop>` receiver table,
# multi-class annotations included as None (#9).
if is_promoted and parent_class_nid:
promoted_type = _php_concrete_type_name(type_node, source)
v = p.child_by_field_name("name")
if promoted_type and v is not None:
if v is not None and (
promoted_type or _php_multi_typed_annotation(type_node)
):
php_field_types.setdefault(parent_class_nid, {})[
_read_text(v, source).lstrip("$")
] = promoted_type
Expand Down Expand Up @@ -4465,7 +4523,8 @@ def _php_class_const_scope(n) -> str | None:
def walk_calls(
node,
caller_nid: str,
receiver_types: dict[str, str] | None = None,
# PHP entries may map a key to None — see _php_method_receiver_types.
receiver_types: dict[str, str | None] | None = None,
extra_locals: frozenset[str] = frozenset(),
) -> None:
if node.type in config.function_boundary_types:
Expand Down Expand Up @@ -4812,18 +4871,33 @@ def walk_calls(
_java_defer = (
config.ts_module == "tree_sitter_java" and is_member_call
)
# PHP (#1682): defer ONLY when the receiver's type is actually
# known — a typed `$this->prop->m()` must not bare-match an
# unrelated same-named method in this file. Plain `$this->m()`
# and untyped receivers keep today's in-file match, since the
# resolver could add nothing for them anyway.
# PHP (#1682): defer when the receiver's type is actually known —
# a typed `$this->prop->m()` must not bare-match an unrelated
# same-named method in this file. Plain `$this->m()` and untyped
# receivers keep today's in-file match, since the resolver could
# add nothing for them anyway.
#
# ALSO defer when the annotation was PRESENT but named several
# candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a
# receiver stamps no type, so before this the refusal was
# indistinguishable from "untyped" and the in-file arm bound the
# call to whichever same-named method came last in the file, at
# EXTRACTED confidence. The receiver table encodes the difference:
# a PRESENT key mapped to None is a refused multi-class
# annotation, an ABSENT key is no annotation at all.
_php_receiver_type: str | None = None
_php_multi_typed_receiver = False
if config.ts_module == "tree_sitter_php":
if php_inline_new_type:
_php_receiver_type = php_inline_new_type
elif member_receiver and member_receiver != "this":
_php_receiver_type = (receiver_types or {}).get(member_receiver)
_php_defer = bool(_php_receiver_type)
_php_types = receiver_types or {}
_php_receiver_type = _php_types.get(member_receiver)
_php_multi_typed_receiver = (
_php_receiver_type is None
and member_receiver in _php_types
)
_php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver
if _java_defer or _php_defer or (
is_member_call
and member_receiver
Expand Down
Loading