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.
Files changed (50) hide show
  1. graphify_php/__init__.py +144 -0
  2. graphify_php/cli.py +355 -0
  3. graphify_php/config/__init__.py +47 -0
  4. graphify_php/config/class_lookup.py +85 -0
  5. graphify_php/config/discovery.py +55 -0
  6. graphify_php/config/document.py +29 -0
  7. graphify_php/config/emit.py +117 -0
  8. graphify_php/config/environment.py +108 -0
  9. graphify_php/config/facts.py +97 -0
  10. graphify_php/config/messaging.py +142 -0
  11. graphify_php/config/ports.py +23 -0
  12. graphify_php/config/safe_yaml.py +127 -0
  13. graphify_php/config_resolver.py +164 -0
  14. graphify_php/container/__init__.py +50 -0
  15. graphify_php/container/facts.py +178 -0
  16. graphify_php/container/layer.py +71 -0
  17. graphify_php/container/locator.py +74 -0
  18. graphify_php/container/overlay.py +149 -0
  19. graphify_php/container/ports.py +40 -0
  20. graphify_php/container/reader.py +330 -0
  21. graphify_php/container/reasons.py +31 -0
  22. graphify_php/graph_adapter.py +520 -0
  23. graphify_php/php/README.md +106 -0
  24. graphify_php/php/autoload.php +27 -0
  25. graphify_php/php/src/CallGraph/CallEdge.php +91 -0
  26. graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
  27. graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
  28. graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
  29. graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
  30. graphify_php/ports.py +174 -0
  31. graphify_php/resolver.py +519 -0
  32. graphify_php/shrink.py +413 -0
  33. graphify_php/shrink_cli.py +150 -0
  34. graphify_php/sources/phpstan_source.py +641 -0
  35. graphify_php/sources/route_source.py +105 -0
  36. graphify_php/sources/tree_sitter_source.py +428 -0
  37. graphify_php/status.py +136 -0
  38. graphify_php/syntax/call_sites.py +117 -0
  39. graphify_php/syntax/class_index.py +121 -0
  40. graphify_php/syntax/collectors.py +351 -0
  41. graphify_php/syntax/facts.py +171 -0
  42. graphify_php/syntax/file_scan.py +158 -0
  43. graphify_php/syntax/names.py +49 -0
  44. graphify_php/syntax/nodes.py +209 -0
  45. graphify_php/syntax/routes.py +288 -0
  46. graphify_php-0.0.5.dist-info/METADATA +218 -0
  47. graphify_php-0.0.5.dist-info/RECORD +50 -0
  48. graphify_php-0.0.5.dist-info/WHEEL +4 -0
  49. graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
  50. 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