graphify-php 0.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphify_php/__init__.py +144 -0
- graphify_php/cli.py +355 -0
- graphify_php/config/__init__.py +47 -0
- graphify_php/config/class_lookup.py +85 -0
- graphify_php/config/discovery.py +55 -0
- graphify_php/config/document.py +29 -0
- graphify_php/config/emit.py +117 -0
- graphify_php/config/environment.py +108 -0
- graphify_php/config/facts.py +97 -0
- graphify_php/config/messaging.py +142 -0
- graphify_php/config/ports.py +23 -0
- graphify_php/config/safe_yaml.py +127 -0
- graphify_php/config_resolver.py +164 -0
- graphify_php/container/__init__.py +50 -0
- graphify_php/container/facts.py +178 -0
- graphify_php/container/layer.py +71 -0
- graphify_php/container/locator.py +74 -0
- graphify_php/container/overlay.py +149 -0
- graphify_php/container/ports.py +40 -0
- graphify_php/container/reader.py +330 -0
- graphify_php/container/reasons.py +31 -0
- graphify_php/graph_adapter.py +520 -0
- graphify_php/php/README.md +106 -0
- graphify_php/php/autoload.php +27 -0
- graphify_php/php/src/CallGraph/CallEdge.php +91 -0
- graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
- graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
- graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
- graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
- graphify_php/ports.py +174 -0
- graphify_php/resolver.py +519 -0
- graphify_php/shrink.py +413 -0
- graphify_php/shrink_cli.py +150 -0
- graphify_php/sources/phpstan_source.py +641 -0
- graphify_php/sources/route_source.py +105 -0
- graphify_php/sources/tree_sitter_source.py +428 -0
- graphify_php/status.py +136 -0
- graphify_php/syntax/call_sites.py +117 -0
- graphify_php/syntax/class_index.py +121 -0
- graphify_php/syntax/collectors.py +351 -0
- graphify_php/syntax/facts.py +171 -0
- graphify_php/syntax/file_scan.py +158 -0
- graphify_php/syntax/names.py +49 -0
- graphify_php/syntax/nodes.py +209 -0
- graphify_php/syntax/routes.py +288 -0
- graphify_php-0.0.5.dist-info/METADATA +218 -0
- graphify_php-0.0.5.dist-info/RECORD +50 -0
- graphify_php-0.0.5.dist-info/WHEEL +4 -0
- graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
- graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Where the member calls are, and what each one is called on.
|
|
2
|
+
|
|
3
|
+
This exists because graphify does not tell us. Its PHP branch marks a call as a member call
|
|
4
|
+
and records the method name, but never the receiver: `extractors/engine.py:6152-6157` sets
|
|
5
|
+
`is_member_call` and `callee_name` and stops, while the C++, Java, C# and Ruby branches beside
|
|
6
|
+
it all assign `member_receiver`. The entry built at `engine.py:6403` is
|
|
7
|
+
`"receiver": swift_receiver or member_receiver`, so every PHP call site arrives with an empty
|
|
8
|
+
receiver and a source that trusted the field would resolve nothing at all.
|
|
9
|
+
|
|
10
|
+
Reading the receiver out of the tree is better than the string graphify would have handed over
|
|
11
|
+
anyway: the tree gives the exact extent of the receiver expression, so `$this->a->b()` as the
|
|
12
|
+
receiver of `->c()` is visibly a call rather than a name that happens to contain brackets.
|
|
13
|
+
|
|
14
|
+
Indexed by (line, method) because that is the only handle a `CallSite` carries —
|
|
15
|
+
`engine.py:6402` writes the location as `L<line>` off the call node's own start point.
|
|
16
|
+
|
|
17
|
+
Scoped calls are indexed here too, and for a sharper reason than member calls. graphify does
|
|
18
|
+
not leave `self::save()` unresolved, it records the wrong callee: `engine.py:6147-6152` reads
|
|
19
|
+
`scoped_call_expression`'s **scope** as the callee name, so `parent::run()` arrives as a call
|
|
20
|
+
to something named `parent` and the method never reaches the record at all. The method name
|
|
21
|
+
exists only in the source, so the line is the only way back to it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from . import nodes
|
|
29
|
+
|
|
30
|
+
# `$a?->b()` binds to the same class as `$a->b()`; null is not a separate call target.
|
|
31
|
+
CALL_KINDS = ("member_call_expression", "nullsafe_member_call_expression")
|
|
32
|
+
SCOPED_KIND = "scoped_call_expression"
|
|
33
|
+
|
|
34
|
+
# `self`, `parent` and `static` arrive under this node kind; anything else in scope position
|
|
35
|
+
# is either a class name or an expression.
|
|
36
|
+
RELATIVE_SCOPE = "relative_scope"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ScopedCall:
|
|
41
|
+
"""`Scope::method()` — the scope as written, and the grammar's verdict on its shape."""
|
|
42
|
+
|
|
43
|
+
scope: str
|
|
44
|
+
kind: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_dynamic(self) -> bool:
|
|
48
|
+
"""`$class::m()` and `{$expr}::m()`: the class is a runtime value, not a name."""
|
|
49
|
+
return self.kind not in (RELATIVE_SCOPE, "name", "qualified_name")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CallSiteIndex:
|
|
54
|
+
"""Every member call in one file, keyed by the line it starts on and its method name."""
|
|
55
|
+
|
|
56
|
+
receivers: dict[tuple[int, str], list[str]] = field(default_factory=dict)
|
|
57
|
+
scopes: dict[tuple[int, str], list[ScopedCall]] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def receiver_for(self, line: int, method: str) -> tuple[str | None, int]:
|
|
60
|
+
"""The receiver expression for that call, plus how many calls matched.
|
|
61
|
+
|
|
62
|
+
The count is returned rather than resolved here so the caller owns the refusal
|
|
63
|
+
vocabulary: two calls to the same method on one line is a real shape (`$a->run();
|
|
64
|
+
$b->run();`) and picking either one is a coin toss between two different classes.
|
|
65
|
+
"""
|
|
66
|
+
found = self.receivers.get((line, method)) or []
|
|
67
|
+
if len(found) != 1:
|
|
68
|
+
return None, len(found)
|
|
69
|
+
return found[0], 1
|
|
70
|
+
|
|
71
|
+
def scope_for(self, line: int, method: str) -> tuple[ScopedCall | None, int]:
|
|
72
|
+
"""The scope expression for a `Scope::method()` on that line, and how many matched."""
|
|
73
|
+
found = self.scopes.get((line, method)) or []
|
|
74
|
+
if len(found) != 1:
|
|
75
|
+
return None, len(found)
|
|
76
|
+
return found[0], 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def index_calls(root) -> CallSiteIndex:
|
|
80
|
+
index = CallSiteIndex()
|
|
81
|
+
_walk(root, index)
|
|
82
|
+
return index
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _walk(node, index: CallSiteIndex) -> None:
|
|
86
|
+
if node.type in CALL_KINDS:
|
|
87
|
+
_record(node, index)
|
|
88
|
+
elif node.type == SCOPED_KIND:
|
|
89
|
+
_record_scoped(node, index)
|
|
90
|
+
for child in node.children:
|
|
91
|
+
_walk(child, index)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _record_scoped(node, index: CallSiteIndex) -> None:
|
|
95
|
+
name_node = node.child_by_field_name("name")
|
|
96
|
+
scope_node = node.child_by_field_name("scope")
|
|
97
|
+
if name_node is None or scope_node is None:
|
|
98
|
+
return
|
|
99
|
+
method = nodes.node_text(name_node)
|
|
100
|
+
if not method:
|
|
101
|
+
return
|
|
102
|
+
key = (node.start_point[0] + 1, method)
|
|
103
|
+
call = ScopedCall(scope=nodes.node_text(scope_node).strip(), kind=scope_node.type)
|
|
104
|
+
index.scopes.setdefault(key, []).append(call)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _record(node, index: CallSiteIndex) -> None:
|
|
108
|
+
name_node = node.child_by_field_name("name")
|
|
109
|
+
object_node = node.child_by_field_name("object")
|
|
110
|
+
if name_node is None or object_node is None:
|
|
111
|
+
return
|
|
112
|
+
method = nodes.node_text(name_node)
|
|
113
|
+
if not method:
|
|
114
|
+
return
|
|
115
|
+
# The call node's own start line, because that is what graphify records as the location.
|
|
116
|
+
key = (node.start_point[0] + 1, method)
|
|
117
|
+
index.receivers.setdefault(key, []).append(nodes.node_text(object_node))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Which classes exist in the corpus, and what each one declares.
|
|
2
|
+
|
|
3
|
+
The receiver's type is only half of a call target. Resolving `$this->magic` to
|
|
4
|
+
`Eval\\Dynamic\\Magic` and then emitting whatever method name the call site wrote produces an
|
|
5
|
+
edge to a member that does not exist — right class, invented method. That is the softer form
|
|
6
|
+
of the failure the six upstream issues describe, and a consumer cannot tell it from a real
|
|
7
|
+
edge, so it has to be checked rather than assumed.
|
|
8
|
+
|
|
9
|
+
The check has three answers, not two, and keeping them apart is the whole point:
|
|
10
|
+
|
|
11
|
+
- the method is declared, somewhere in the class's own ancestry — emit;
|
|
12
|
+
- the class is in the corpus, its whole ancestry is in the corpus, and none of them declares
|
|
13
|
+
the method — a real refusal;
|
|
14
|
+
- the class or one of its ancestors was never read — absence is not proved, so the caller
|
|
15
|
+
emits the target and lets `GraphNodeIndex.method_node` settle it against the whole corpus.
|
|
16
|
+
|
|
17
|
+
A class that declares `__call` answers every method name at runtime, so a name missing from it
|
|
18
|
+
is dispatched, not absent. That is `indirect-dispatch`, the reason that already exists for it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
# What the lookup concluded. Strings rather than an enum so they read plainly in a debugger.
|
|
26
|
+
DECLARED = "declared"
|
|
27
|
+
NOT_DECLARED = "not-declared"
|
|
28
|
+
UNKNOWN = "unknown"
|
|
29
|
+
MAGIC = "magic"
|
|
30
|
+
|
|
31
|
+
MAGIC_METHODS = ("__call", "__callStatic")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ClassRecord:
|
|
36
|
+
"""One declaration: what it declares itself, and where else to look."""
|
|
37
|
+
|
|
38
|
+
fqn: str
|
|
39
|
+
methods: frozenset[str]
|
|
40
|
+
# extends, implements and `use`d traits together. The lookup does not care which is which:
|
|
41
|
+
# all three are places a method can be declared, and PHP resolves them into one class.
|
|
42
|
+
ancestors: tuple[str, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ClassIndex:
|
|
47
|
+
classes: dict[str, ClassRecord] = field(default_factory=dict)
|
|
48
|
+
# The scopes themselves, for the property walk. Kept beside the records rather than folded
|
|
49
|
+
# into them because a record is a summary and this needs the declarations as parsed.
|
|
50
|
+
scopes: dict[str, object] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def add(self, record: ClassRecord, scope=None) -> None:
|
|
53
|
+
self.classes[record.fqn] = record
|
|
54
|
+
if scope is not None:
|
|
55
|
+
self.scopes[record.fqn] = scope
|
|
56
|
+
|
|
57
|
+
def property_fact(self, fqn: str, name: str):
|
|
58
|
+
"""The nearest declaration of `$name` in the ancestry, as (fact, refusal reason).
|
|
59
|
+
|
|
60
|
+
Breadth-first so the nearest declaration wins: a subclass that redeclares `$emails`
|
|
61
|
+
with a narrower type beats the ancestor's, which is the whole reason PHP allows the
|
|
62
|
+
redeclaration. Within one level the order is `extends`, then `implements`, then traits.
|
|
63
|
+
|
|
64
|
+
An ancestor in a file this run never read simply is not here, so its properties are
|
|
65
|
+
neither found nor disproved and the caller reports the type as unknown. Same principle
|
|
66
|
+
as the method check: disprove only what was seen.
|
|
67
|
+
"""
|
|
68
|
+
level = [fqn]
|
|
69
|
+
seen: set[str] = set()
|
|
70
|
+
while level:
|
|
71
|
+
following: list[str] = []
|
|
72
|
+
for current in level:
|
|
73
|
+
if current in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(current)
|
|
76
|
+
scope = self.scopes.get(current)
|
|
77
|
+
if scope is None:
|
|
78
|
+
continue
|
|
79
|
+
refusal = scope.refusals.get(name)
|
|
80
|
+
if refusal:
|
|
81
|
+
return None, refusal
|
|
82
|
+
fact = scope.properties.get(name)
|
|
83
|
+
if fact is not None:
|
|
84
|
+
return fact, None
|
|
85
|
+
following.extend(scope.ancestors)
|
|
86
|
+
level = following
|
|
87
|
+
return None, None
|
|
88
|
+
|
|
89
|
+
def declares(self, fqn: str, method: str) -> str:
|
|
90
|
+
"""Walk the ancestry for `method` and report which of the three answers applies."""
|
|
91
|
+
seen: set[str] = set()
|
|
92
|
+
pending = [fqn]
|
|
93
|
+
saw_unknown = False
|
|
94
|
+
saw_magic = False
|
|
95
|
+
while pending:
|
|
96
|
+
current = pending.pop()
|
|
97
|
+
if current in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(current)
|
|
100
|
+
record = self.classes.get(current)
|
|
101
|
+
if record is None:
|
|
102
|
+
saw_unknown = True
|
|
103
|
+
continue
|
|
104
|
+
if method in record.methods:
|
|
105
|
+
return DECLARED
|
|
106
|
+
if any(magic in record.methods for magic in MAGIC_METHODS):
|
|
107
|
+
saw_magic = True
|
|
108
|
+
pending.extend(record.ancestors)
|
|
109
|
+
if saw_magic:
|
|
110
|
+
return MAGIC
|
|
111
|
+
return UNKNOWN if saw_unknown else NOT_DECLARED
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def record_for(scope, methods: frozenset[str]) -> ClassRecord:
|
|
115
|
+
return ClassRecord(fqn=scope.fqn, methods=methods, ancestors=tuple(scope.ancestors))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def index_from_facts(index: ClassIndex, facts) -> None:
|
|
119
|
+
"""Fold one file's declarations into the index."""
|
|
120
|
+
for scope in facts.classes:
|
|
121
|
+
index.add(record_for(scope, frozenset(m.name for m in scope.methods if m.name)), scope)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""One class per type source.
|
|
2
|
+
|
|
3
|
+
Each collector handles exactly one grammar node kind and writes exactly one kind of fact.
|
|
4
|
+
They are registered in `COLLECTORS` at the bottom, so a fifth type source — docblocks, an
|
|
5
|
+
attribute, a container definition — is a new class and one list entry, never a new branch in
|
|
6
|
+
the walker. `file_scan` imports the registry and has no idea what is in it.
|
|
7
|
+
|
|
8
|
+
The four here, in the precedence they settle conflicts by (lowest number wins):
|
|
9
|
+
|
|
10
|
+
1. Promoted constructor parameters — `__construct(private readonly Emails $emails)`. Measured
|
|
11
|
+
on one Symfony application: 652 of 715 injected properties carry a concrete class type and
|
|
12
|
+
63 an interface (92 % / 8 %), so this one source covers most of the receivers that matter.
|
|
13
|
+
2. Typed property declarations — `private readonly Emails $legacy;`.
|
|
14
|
+
3. Constructor-body assignment from a typed parameter — `$this->legacy = $x;` where the
|
|
15
|
+
constructor declares `Emails $x`. The case pure declaration reading misses; it predates
|
|
16
|
+
promoted properties, so it is common in older code rather than exotic.
|
|
17
|
+
4. `new` bindings for locals — `$mailer = new Emails();`.
|
|
18
|
+
|
|
19
|
+
Docblocks are not read at all in this version. A `@var` that contradicts a native type is a
|
|
20
|
+
bug in the code, and resolving one correctly needs scope tracking these collectors do not do.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Protocol
|
|
27
|
+
|
|
28
|
+
from ..ports import Confidence
|
|
29
|
+
from . import nodes
|
|
30
|
+
from .facts import TypeFact
|
|
31
|
+
|
|
32
|
+
# Fixed reasons, shared with the resolver so that a refusal recorded while scanning and a
|
|
33
|
+
# refusal decided at the call site cannot drift into two spellings of the same thing.
|
|
34
|
+
UNION_TYPE = "union-type"
|
|
35
|
+
REASSIGNED_LOCAL = "reassigned-local"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ScanContext:
|
|
40
|
+
"""Where the walker currently is. Collectors read it; they never change it."""
|
|
41
|
+
|
|
42
|
+
facts: object # FileFacts, kept untyped here to avoid an import cycle
|
|
43
|
+
names: object # NameResolver
|
|
44
|
+
klass: object | None # ClassScope
|
|
45
|
+
method: object | None # MethodScope
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TypeCollector(Protocol):
|
|
49
|
+
"""A source of receiver types.
|
|
50
|
+
|
|
51
|
+
`node_kind` is what the walker dispatches on, `via` is what lands in `CallTarget.via`, and
|
|
52
|
+
`precedence` settles a conflict with another collector without either knowing the other.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
node_kind: str
|
|
56
|
+
via: str
|
|
57
|
+
confidence: Confidence
|
|
58
|
+
precedence: int
|
|
59
|
+
|
|
60
|
+
def collect(self, node, ctx: ScanContext) -> None: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fact(collector: TypeCollector, fqn: str, members: tuple = ()) -> TypeFact:
|
|
64
|
+
return TypeFact(
|
|
65
|
+
fqn=fqn,
|
|
66
|
+
confidence=collector.confidence,
|
|
67
|
+
via=collector.via,
|
|
68
|
+
precedence=collector.precedence,
|
|
69
|
+
members=members,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _declared_type(
|
|
74
|
+
collector: TypeCollector, node, ctx: ScanContext
|
|
75
|
+
) -> tuple[str | None, tuple, str | None]:
|
|
76
|
+
"""Read a declaration's type annotation as (fqn, intersection members, refusal reason).
|
|
77
|
+
|
|
78
|
+
Four outcomes. A concrete class is a fact. An intersection `A&B` is a fact with several
|
|
79
|
+
constituents, resolved at the call site by which one declares the method. A union is a
|
|
80
|
+
refusal that must be counted, because both alternatives are real targets and this version
|
|
81
|
+
cannot express two. Anything else — untyped, `int`, `self` — is none of them, because it
|
|
82
|
+
names no call target at all.
|
|
83
|
+
"""
|
|
84
|
+
annotation = nodes.type_node(node)
|
|
85
|
+
if nodes.is_union(annotation):
|
|
86
|
+
return None, (), UNION_TYPE
|
|
87
|
+
members = nodes.intersection_members(annotation)
|
|
88
|
+
if members:
|
|
89
|
+
return None, tuple(ctx.names.resolve(m) for m in members), None
|
|
90
|
+
short = nodes.type_name(annotation)
|
|
91
|
+
if not short:
|
|
92
|
+
return None, (), None
|
|
93
|
+
return ctx.names.resolve(short), (), None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PromotedParameterCollector:
|
|
97
|
+
"""`__construct(private readonly Emails $emails)`.
|
|
98
|
+
|
|
99
|
+
The dominant source in modern PHP: constructor promotion states the property's type and
|
|
100
|
+
its injection in one place, which is why 92 % of the reference application's injected
|
|
101
|
+
properties resolve from this collector alone.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
node_kind = "property_promotion_parameter"
|
|
105
|
+
via = "promoted-parameter"
|
|
106
|
+
confidence = Confidence.EXTRACTED
|
|
107
|
+
precedence = 10
|
|
108
|
+
|
|
109
|
+
def collect(self, node, ctx: ScanContext) -> None:
|
|
110
|
+
if ctx.klass is None:
|
|
111
|
+
return
|
|
112
|
+
prop = nodes.variable_of(node)
|
|
113
|
+
if not prop:
|
|
114
|
+
return
|
|
115
|
+
fqn, members, refusal = _declared_type(self, node, ctx)
|
|
116
|
+
if refusal:
|
|
117
|
+
ctx.klass.refuse_property(prop, refusal, self.precedence)
|
|
118
|
+
elif fqn or members:
|
|
119
|
+
ctx.klass.record_property(prop, _fact(self, fqn or "", members))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TypedPropertyCollector:
|
|
123
|
+
"""`private readonly Emails $legacy;`, including the multi-element form."""
|
|
124
|
+
|
|
125
|
+
node_kind = "property_declaration"
|
|
126
|
+
via = "typed-property"
|
|
127
|
+
confidence = Confidence.EXTRACTED
|
|
128
|
+
precedence = 20
|
|
129
|
+
|
|
130
|
+
def collect(self, node, ctx: ScanContext) -> None:
|
|
131
|
+
if ctx.klass is None:
|
|
132
|
+
return
|
|
133
|
+
fqn, members, refusal = _declared_type(self, node, ctx)
|
|
134
|
+
if not fqn and not members and not refusal:
|
|
135
|
+
return
|
|
136
|
+
for element in nodes.children(node, "property_element"):
|
|
137
|
+
prop = nodes.variable_of(element)
|
|
138
|
+
if not prop:
|
|
139
|
+
continue
|
|
140
|
+
if refusal:
|
|
141
|
+
ctx.klass.refuse_property(prop, refusal, self.precedence)
|
|
142
|
+
else:
|
|
143
|
+
ctx.klass.record_property(prop, _fact(self, fqn or "", members))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ConstructorAssignmentCollector:
|
|
147
|
+
"""`$this->legacy = $x;` where the constructor declares `Emails $x`.
|
|
148
|
+
|
|
149
|
+
Registered on the constructor rather than on the assignment so that the parameter types
|
|
150
|
+
and the statements that use them are read in one place; an assignment node on its own
|
|
151
|
+
cannot see the signature it draws from without walking back up the tree.
|
|
152
|
+
|
|
153
|
+
INFERRED, not EXTRACTED: the property itself states no type, and the link from parameter
|
|
154
|
+
to property is a reading of one statement, not a declaration.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
node_kind = "method_declaration"
|
|
158
|
+
via = "constructor-assignment"
|
|
159
|
+
confidence = Confidence.INFERRED
|
|
160
|
+
precedence = 30
|
|
161
|
+
|
|
162
|
+
def collect(self, node, ctx: ScanContext) -> None:
|
|
163
|
+
if ctx.klass is None or nodes.name_of(node) != "__construct":
|
|
164
|
+
return
|
|
165
|
+
params = self._parameter_types(node, ctx)
|
|
166
|
+
if not params:
|
|
167
|
+
return
|
|
168
|
+
body = nodes.child(node, "compound_statement")
|
|
169
|
+
for assignment in self._assignments(body):
|
|
170
|
+
prop, source_var = self._this_assignment(assignment)
|
|
171
|
+
if not prop or source_var not in params:
|
|
172
|
+
continue
|
|
173
|
+
fqn, members, refusal = params[source_var]
|
|
174
|
+
if refusal:
|
|
175
|
+
ctx.klass.refuse_property(prop, refusal, self.precedence)
|
|
176
|
+
elif fqn or members:
|
|
177
|
+
ctx.klass.record_property(prop, _fact(self, fqn or "", members))
|
|
178
|
+
|
|
179
|
+
def _parameter_types(self, ctor, ctx: ScanContext) -> dict[str, tuple]:
|
|
180
|
+
out: dict[str, tuple] = {}
|
|
181
|
+
params = nodes.child(ctor, "formal_parameters")
|
|
182
|
+
for param in nodes.children(params, "simple_parameter", "property_promotion_parameter"):
|
|
183
|
+
name = nodes.variable_of(param)
|
|
184
|
+
if not name:
|
|
185
|
+
continue
|
|
186
|
+
fqn, members, refusal = _declared_type(self, param, ctx)
|
|
187
|
+
if fqn or members or refusal:
|
|
188
|
+
out[name] = (fqn, members, refusal)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
def _assignments(self, node):
|
|
192
|
+
"""Every assignment in the constructor body, nested ones included.
|
|
193
|
+
|
|
194
|
+
A guarded assignment inside an `if` is still the only thing that ever sets the
|
|
195
|
+
property in the overwhelming majority of constructors, so restricting this to
|
|
196
|
+
top-level statements would drop real facts for no gain in precision.
|
|
197
|
+
"""
|
|
198
|
+
if node is None or node.type in nodes.CLOSURE_KINDS:
|
|
199
|
+
# A closure's `$x` is the closure's own parameter or capture, not the
|
|
200
|
+
# constructor's, so an assignment inside one draws from a signature not read here.
|
|
201
|
+
return
|
|
202
|
+
if node.type == "assignment_expression":
|
|
203
|
+
yield node
|
|
204
|
+
for candidate in node.children:
|
|
205
|
+
yield from self._assignments(candidate)
|
|
206
|
+
|
|
207
|
+
def _this_assignment(self, assignment) -> tuple[str | None, str | None]:
|
|
208
|
+
"""`$this->prop = $var` as (prop, var), or (None, None) for anything else.
|
|
209
|
+
|
|
210
|
+
`$this->$prop = ...` falls out here because the property side is a `variable_name`
|
|
211
|
+
rather than a `name`, which is the shape of a dynamic property and not something a
|
|
212
|
+
static read can name.
|
|
213
|
+
"""
|
|
214
|
+
left = assignment.children[0] if assignment.children else None
|
|
215
|
+
right = assignment.children[-1] if len(assignment.children) > 2 else None
|
|
216
|
+
if left is None or right is None:
|
|
217
|
+
return None, None
|
|
218
|
+
if left.type != "member_access_expression" or right.type != "variable_name":
|
|
219
|
+
return None, None
|
|
220
|
+
receiver = left.children[0] if left.children else None
|
|
221
|
+
prop_node = left.children[-1] if len(left.children) > 1 else None
|
|
222
|
+
if receiver is None or prop_node is None or prop_node.type != "name":
|
|
223
|
+
return None, None
|
|
224
|
+
if nodes.node_text(receiver) != "$this":
|
|
225
|
+
return None, None
|
|
226
|
+
return nodes.node_text(prop_node), nodes.node_text(right).lstrip("$")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class LocalConstructionCollector:
|
|
230
|
+
"""`$mailer = new Emails();`, and the reassignment that invalidates it.
|
|
231
|
+
|
|
232
|
+
Aliasing a property into a local (`$e = $this->emails;`) is deliberately not tracked: it
|
|
233
|
+
needs flow analysis to know the local was not reassigned before the call, and a binding
|
|
234
|
+
that survives its own reassignment is exactly how a resolver starts lying. For the same
|
|
235
|
+
reason every other assignment to a bound local revokes the binding rather than being
|
|
236
|
+
ignored — the second write is the evidence that the first no longer holds.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
node_kind = "assignment_expression"
|
|
240
|
+
via = "new-binding"
|
|
241
|
+
confidence = Confidence.INFERRED
|
|
242
|
+
precedence = 40
|
|
243
|
+
|
|
244
|
+
def collect(self, node, ctx: ScanContext) -> None:
|
|
245
|
+
if ctx.method is None:
|
|
246
|
+
return
|
|
247
|
+
left = node.children[0] if node.children else None
|
|
248
|
+
right = node.children[-1] if len(node.children) > 2 else None
|
|
249
|
+
if left is None or right is None or left.type != "variable_name":
|
|
250
|
+
return
|
|
251
|
+
local = nodes.node_text(left).lstrip("$")
|
|
252
|
+
if not local or local == "this":
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Any second write revokes the binding, in either order. Without flow analysis there is
|
|
256
|
+
# no way to tell a call placed before the second write from one placed after it, and
|
|
257
|
+
# only one of those two readings is true.
|
|
258
|
+
if not ctx.method.note_write(local, REASSIGNED_LOCAL, self.precedence):
|
|
259
|
+
return
|
|
260
|
+
if right.type != "object_creation_expression":
|
|
261
|
+
return
|
|
262
|
+
# `new $class()` and `new class {...}` write no class name, so there is none to read.
|
|
263
|
+
created = nodes.type_name(nodes.child(right, "qualified_name", "name"))
|
|
264
|
+
if not created:
|
|
265
|
+
return
|
|
266
|
+
ctx.method.record_local(local, _fact(self, ctx.names.resolve(created)))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class LocalRebindCollector:
|
|
270
|
+
"""A construct that binds a local without an `=`: it revokes a `new` binding all the same.
|
|
271
|
+
|
|
272
|
+
A `foreach` target, a closure's parameters and `use` clause, a `catch` variable, a
|
|
273
|
+
destructuring target and the method's own parameters all write the name. Left uncounted,
|
|
274
|
+
`$m = new Emails(); foreach ($caches as $m) { $m->get(); }` resolves a call on a cache to
|
|
275
|
+
`Emails::get`. One instance per grammar node kind, so the walker dispatches as it does for
|
|
276
|
+
every other collector; none of them records a type, they only revoke.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
via = "rebinding"
|
|
280
|
+
confidence = Confidence.INFERRED
|
|
281
|
+
# The same rank as the `new` binding it revokes: a stronger source's fact survives it.
|
|
282
|
+
precedence = LocalConstructionCollector.precedence
|
|
283
|
+
|
|
284
|
+
def __init__(self, node_kind: str) -> None:
|
|
285
|
+
self.node_kind = node_kind
|
|
286
|
+
|
|
287
|
+
def collect(self, node, ctx: ScanContext) -> None:
|
|
288
|
+
if ctx.method is None:
|
|
289
|
+
return
|
|
290
|
+
for name in _bound_names(node):
|
|
291
|
+
ctx.method.note_write(name, REASSIGNED_LOCAL, self.precedence)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _bound_names(node) -> list[str]:
|
|
295
|
+
"""The locals a binding construct writes, read from its direct children only.
|
|
296
|
+
|
|
297
|
+
Direct children, because a nested `list_literal` is dispatched to its own collector and
|
|
298
|
+
reading it twice would count one write as two. In a `foreach` only the part after `as`
|
|
299
|
+
is a target; the subject before it is read, not written.
|
|
300
|
+
"""
|
|
301
|
+
if node.type == "formal_parameters":
|
|
302
|
+
params = nodes.children(
|
|
303
|
+
node, "simple_parameter", "variadic_parameter", "property_promotion_parameter"
|
|
304
|
+
)
|
|
305
|
+
return [name for name in (nodes.variable_of(p) for p in params) if name]
|
|
306
|
+
if node.type == "foreach_statement":
|
|
307
|
+
seen_as = False
|
|
308
|
+
targets = []
|
|
309
|
+
for child in node.children:
|
|
310
|
+
if not child.is_named and nodes.node_text(child) == "as":
|
|
311
|
+
seen_as = True
|
|
312
|
+
elif seen_as:
|
|
313
|
+
targets.append(child)
|
|
314
|
+
node_list = targets
|
|
315
|
+
else:
|
|
316
|
+
node_list = node.children
|
|
317
|
+
names = []
|
|
318
|
+
for child in node_list:
|
|
319
|
+
if child.type == "pair":
|
|
320
|
+
names.extend(_bound_names(child))
|
|
321
|
+
elif child.type == "by_ref":
|
|
322
|
+
names.extend(_bound_names(child))
|
|
323
|
+
elif child.type == "variable_name":
|
|
324
|
+
name = nodes.node_text(child).lstrip("$")
|
|
325
|
+
if name and name != "this":
|
|
326
|
+
names.append(name)
|
|
327
|
+
return names
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# The registry. Adding a type source means adding a class above and one entry here.
|
|
331
|
+
COLLECTORS: list[TypeCollector] = [
|
|
332
|
+
PromotedParameterCollector(),
|
|
333
|
+
TypedPropertyCollector(),
|
|
334
|
+
ConstructorAssignmentCollector(),
|
|
335
|
+
LocalConstructionCollector(),
|
|
336
|
+
LocalRebindCollector("formal_parameters"),
|
|
337
|
+
LocalRebindCollector("anonymous_function_use_clause"),
|
|
338
|
+
LocalRebindCollector("foreach_statement"),
|
|
339
|
+
LocalRebindCollector("catch_clause"),
|
|
340
|
+
LocalRebindCollector("list_literal"),
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def by_node_kind(collectors: list[TypeCollector] | None = None) -> dict[str, list[TypeCollector]]:
|
|
345
|
+
"""Group the registry so the walker dispatches by node kind instead of testing each one."""
|
|
346
|
+
registry: dict[str, list[TypeCollector]] = {}
|
|
347
|
+
for collector in collectors if collectors is not None else COLLECTORS:
|
|
348
|
+
registry.setdefault(collector.node_kind, []).append(collector)
|
|
349
|
+
for bucket in registry.values():
|
|
350
|
+
bucket.sort(key=lambda c: c.precedence)
|
|
351
|
+
return registry
|