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,520 @@
|
|
|
1
|
+
"""`NodeIndex` and `EdgeSink` over graphify's in-memory `all_nodes` / `all_edges`.
|
|
2
|
+
|
|
3
|
+
This module is the only place that knows graphify's node and edge schema. Everything else in
|
|
4
|
+
the package talks to the two protocols in `ports`, so a schema move between graphify releases
|
|
5
|
+
is repaired here and nowhere else.
|
|
6
|
+
|
|
7
|
+
The schema this is written against, read from a real build of the reference application
|
|
8
|
+
(6302 nodes / 17886 links, 4012 of them from `.php` files):
|
|
9
|
+
|
|
10
|
+
- a class node carries `_callable_class: true` and a `label` that is the SHORT class name
|
|
11
|
+
(`AbstractFrontController`), never the FQN, so an FQN has to be matched down to its last
|
|
12
|
+
segment and then re-checked against the file path;
|
|
13
|
+
- a method node hangs off its class by a `method` edge and its label is `.methodName()`;
|
|
14
|
+
- a class node hangs off its file node by a `contains` edge;
|
|
15
|
+
- `_origin` is `ast` or `semantic` on both nodes and edges.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
from .ports import CallTarget, Confidence
|
|
24
|
+
|
|
25
|
+
# The marker this package stamps on every edge it adds, so a reader can select ours, count them,
|
|
26
|
+
# or drop them from a published graph without re-running the build.
|
|
27
|
+
#
|
|
28
|
+
# Deliberately NOT `_origin`: graphify overwrites that on every edge with `"ast"` after the
|
|
29
|
+
# resolvers run (extract.py:8285-8289), and it means the eviction tier, not the author. Our
|
|
30
|
+
# edges are regenerated by the AST pass, so `ast` is the right tier for them and claiming that
|
|
31
|
+
# key would both be lost and be wrong. A key of our own survives untouched.
|
|
32
|
+
EDGE_MARKER = "_resolver"
|
|
33
|
+
EDGE_ORIGIN = "graphify-php"
|
|
34
|
+
|
|
35
|
+
# graphify's own scores for the two confidence levels (extract.py:3534). Matching them keeps
|
|
36
|
+
# our edges comparable with graphify's in any consumer that ranks by score.
|
|
37
|
+
_CONFIDENCE_SCORE = {Confidence.EXTRACTED: 1.0, Confidence.INFERRED: 0.8}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _key(label: str) -> str:
|
|
41
|
+
"""graphify's own label normalisation (extract.py), reproduced so keys agree.
|
|
42
|
+
|
|
43
|
+
`.getCurrentUser()` and `getcurrentuser` collapse to the same key, which is what makes a
|
|
44
|
+
method label from the graph comparable with a method name from PHP source.
|
|
45
|
+
"""
|
|
46
|
+
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fqn_parts(class_fqn: str) -> list[str]:
|
|
50
|
+
return [p for p in str(class_fqn).replace("/", "\\").split("\\") if p]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _path_parts(source_file: str) -> list[str]:
|
|
54
|
+
stripped = re.sub(r"\.[^./\\]+$", "", str(source_file or ""))
|
|
55
|
+
return [p for p in stripped.replace("\\", "/").split("/") if p]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tail_overlap(a: list[str], b: list[str]) -> int:
|
|
59
|
+
"""How many trailing segments the two paths share, compared normalised."""
|
|
60
|
+
count = 0
|
|
61
|
+
# Paths differ in length on purpose; the overlap stops at the shorter one.
|
|
62
|
+
for left, right in zip(reversed(a), reversed(b), strict=False):
|
|
63
|
+
if _key(left) != _key(right):
|
|
64
|
+
break
|
|
65
|
+
count += 1
|
|
66
|
+
return count
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GraphNodeIndex:
|
|
70
|
+
"""Answers "which node is method M of class C" and nothing else.
|
|
71
|
+
|
|
72
|
+
Built once per resolver run: the walk is linear in nodes plus edges, and the resolver asks
|
|
73
|
+
this question once per eligible call site, of which the reference application has thousands.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
|
|
77
|
+
node_by_id: dict[str, dict] = {}
|
|
78
|
+
for node in all_nodes:
|
|
79
|
+
nid = node.get("id")
|
|
80
|
+
if nid:
|
|
81
|
+
node_by_id[nid] = node
|
|
82
|
+
|
|
83
|
+
# Only a class a file actually contains is a candidate. graphify also carries nodes
|
|
84
|
+
# that no file contains (synthesised, external, semantic); binding a call to one of
|
|
85
|
+
# those invents a target that has no definition to jump to.
|
|
86
|
+
contained: set[str] = set()
|
|
87
|
+
for edge in all_edges:
|
|
88
|
+
if edge.get("relation") == "contains":
|
|
89
|
+
target = edge.get("target")
|
|
90
|
+
if target:
|
|
91
|
+
contained.add(target)
|
|
92
|
+
|
|
93
|
+
self._classes_by_name: dict[str, list[dict]] = {}
|
|
94
|
+
for node in all_nodes:
|
|
95
|
+
if not node.get("_callable_class") or node.get("id") not in contained:
|
|
96
|
+
continue
|
|
97
|
+
self._classes_by_name.setdefault(_key(node.get("label", "")), []).append(node)
|
|
98
|
+
|
|
99
|
+
self._methods: dict[tuple[str, str], str] = {}
|
|
100
|
+
# Method NAMES per class, and the class each method belongs to. Both exist for scoped
|
|
101
|
+
# calls: graphify records `Helper::format()` with the SCOPE as the callee and drops the
|
|
102
|
+
# method name entirely, so the only way back to `format` without re-parsing PHP here is
|
|
103
|
+
# to ask the graph which methods that class declares and let the type source check each
|
|
104
|
+
# candidate against the line it really came from.
|
|
105
|
+
self._method_names: dict[str, list[str]] = {}
|
|
106
|
+
self._owner_of: dict[str, str] = {}
|
|
107
|
+
for edge in all_edges:
|
|
108
|
+
if edge.get("relation") != "method":
|
|
109
|
+
continue
|
|
110
|
+
owner, member = edge.get("source"), edge.get("target")
|
|
111
|
+
member_node = node_by_id.get(member)
|
|
112
|
+
if owner and member_node is not None:
|
|
113
|
+
label = member_node.get("label", "")
|
|
114
|
+
self._methods[(owner, _key(label))] = member
|
|
115
|
+
self._owner_of[member] = owner
|
|
116
|
+
# `.format()` is the label; the bare name is what a call site writes.
|
|
117
|
+
self._method_names.setdefault(owner, []).append(str(label).strip(". ()"))
|
|
118
|
+
|
|
119
|
+
self._parents: dict[str, list[str]] = {}
|
|
120
|
+
for edge in all_edges:
|
|
121
|
+
if edge.get("relation") == "inherits":
|
|
122
|
+
child, parent = edge.get("source"), edge.get("target")
|
|
123
|
+
if child and parent:
|
|
124
|
+
self._parents.setdefault(child, []).append(parent)
|
|
125
|
+
|
|
126
|
+
# Origin maps. `contains` puts a class in its file; a method hangs off its class, so it
|
|
127
|
+
# is folded through to the owning class's file. `imports` targets the imported CLASS
|
|
128
|
+
# node directly for PHP `use` statements, which is what makes "can the caller see this
|
|
129
|
+
# class" answerable from the graph rather than guessed from a path.
|
|
130
|
+
self._file_of: dict[str, str] = {}
|
|
131
|
+
for edge in all_edges:
|
|
132
|
+
if edge.get("relation") == "contains":
|
|
133
|
+
self._file_of[edge.get("target")] = edge.get("source")
|
|
134
|
+
for method_nid, owner in self._owner_of.items():
|
|
135
|
+
owner_file = self._file_of.get(owner)
|
|
136
|
+
if owner_file is not None:
|
|
137
|
+
self._file_of.setdefault(method_nid, owner_file)
|
|
138
|
+
|
|
139
|
+
self._imported_by: dict[str, set[str]] = {}
|
|
140
|
+
for edge in all_edges:
|
|
141
|
+
if edge.get("relation") in ("imports", "imports_from"):
|
|
142
|
+
self._imported_by.setdefault(edge.get("source"), set()).add(edge.get("target"))
|
|
143
|
+
|
|
144
|
+
def method_node(self, class_fqn: str, method: str) -> str | None:
|
|
145
|
+
class_node = self._class_node(class_fqn)
|
|
146
|
+
if class_node is None:
|
|
147
|
+
return None
|
|
148
|
+
return self._methods.get((class_node["id"], _key(method)))
|
|
149
|
+
|
|
150
|
+
def method_node_for(self, caller_nid: str, class_fqn: str, method: str) -> str | None:
|
|
151
|
+
"""`method_node`, with the caller's own visibility as the tie-breaker and the gate.
|
|
152
|
+
|
|
153
|
+
A short-name match is not evidence the caller can see the matched class: a call that a
|
|
154
|
+
type source resolved to `Vendor\\Lib\\Emails` must not bind to a project class that
|
|
155
|
+
merely spells `Emails` the same way. graphify records enough to check rather than guess
|
|
156
|
+
— `use` statements arrive as `imports` edges pointing straight at the class node — so a
|
|
157
|
+
candidate is accepted when the caller's file imports it, when it is in the caller's own
|
|
158
|
+
file, or when it is an ancestor of the caller's class (`parent::`).
|
|
159
|
+
|
|
160
|
+
A PSR-4 path match is kept as a second, independent signal, because a class in the
|
|
161
|
+
caller's own namespace needs no `use` and so produces no import edge. Refuse only when
|
|
162
|
+
neither signal fires: the bar is graphify's own, an absent edge over a wrong one.
|
|
163
|
+
"""
|
|
164
|
+
parts = _fqn_parts(class_fqn)
|
|
165
|
+
if not parts:
|
|
166
|
+
return None
|
|
167
|
+
candidates = self._classes_by_name.get(_key(parts[-1]), [])
|
|
168
|
+
if not candidates:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
visible = [c for c in candidates if self._visible_to(caller_nid, c["id"])]
|
|
172
|
+
chosen = visible[0] if len(visible) == 1 else None
|
|
173
|
+
if chosen is not None and self._outscored(chosen, candidates, parts[:-1]):
|
|
174
|
+
# Visibility picked one class, but the namespace the type source proved points
|
|
175
|
+
# at a different candidate more precisely (a parent `App\Legacy\Emails` is
|
|
176
|
+
# visible; the property is typed `App\Mail\Emails`). Binding the visible one
|
|
177
|
+
# would contradict the proof, so the call gets no edge.
|
|
178
|
+
return None
|
|
179
|
+
if chosen is None and not visible:
|
|
180
|
+
chosen = self._class_node(class_fqn)
|
|
181
|
+
if chosen is None:
|
|
182
|
+
return None
|
|
183
|
+
return self._methods.get((chosen["id"], _key(method)))
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _outscored(chosen: dict, candidates: list[dict], namespace: list[str]) -> bool:
|
|
187
|
+
def overlap(node: dict) -> int:
|
|
188
|
+
return _tail_overlap(namespace, _path_parts(node.get("source_file", ""))[:-1])
|
|
189
|
+
|
|
190
|
+
own = overlap(chosen)
|
|
191
|
+
return any(c is not chosen and overlap(c) > own for c in candidates)
|
|
192
|
+
|
|
193
|
+
def _visible_to(self, caller_nid: str, class_nid: str) -> bool:
|
|
194
|
+
caller_class = self._owner_of.get(caller_nid, caller_nid)
|
|
195
|
+
if class_nid == caller_class:
|
|
196
|
+
return True
|
|
197
|
+
caller_file = self._file_of.get(caller_nid) or self._file_of.get(caller_class)
|
|
198
|
+
if caller_file is not None:
|
|
199
|
+
if self._file_of.get(class_nid) == caller_file:
|
|
200
|
+
return True
|
|
201
|
+
if class_nid in self._imported_by.get(caller_file, ()):
|
|
202
|
+
return True
|
|
203
|
+
return class_nid in self._with_ancestors([caller_class])
|
|
204
|
+
|
|
205
|
+
def method_node_in_file(self, source_file: str, class_fqn: str, method: str) -> str | None:
|
|
206
|
+
"""`method_node`, resolved against a class declared in a known file.
|
|
207
|
+
|
|
208
|
+
A route attribute sits ON the controller class, so the file it was read from names the
|
|
209
|
+
class exactly — stronger evidence than any namespace-to-path guess, and it is what makes
|
|
210
|
+
the lookup work for the many real projects whose directory layout does not mirror their
|
|
211
|
+
namespace. Falls back to the namespace match when the file holds no such class.
|
|
212
|
+
"""
|
|
213
|
+
parts = _fqn_parts(class_fqn)
|
|
214
|
+
if not parts:
|
|
215
|
+
return None
|
|
216
|
+
in_file = [
|
|
217
|
+
c
|
|
218
|
+
for c in self._classes_by_name.get(_key(parts[-1]), [])
|
|
219
|
+
if c.get("source_file") == source_file
|
|
220
|
+
]
|
|
221
|
+
chosen = in_file[0] if len(in_file) == 1 else self._class_node(class_fqn)
|
|
222
|
+
if chosen is None:
|
|
223
|
+
return None
|
|
224
|
+
return self._methods.get((chosen["id"], _key(method)))
|
|
225
|
+
|
|
226
|
+
def owner_class(self, method_nid: str) -> str | None:
|
|
227
|
+
"""The class node a method node hangs off, by its `method` edge."""
|
|
228
|
+
return self._owner_of.get(method_nid)
|
|
229
|
+
|
|
230
|
+
def scope_methods(self, caller_nid: str, scope: str) -> list[str]:
|
|
231
|
+
"""Method names a scoped call `<scope>::?()` from this caller could be naming.
|
|
232
|
+
|
|
233
|
+
Candidates, not answers. Each one is offered to a type source, which reads the call
|
|
234
|
+
site's own line and refuses every name that is not written there, so a wrong candidate
|
|
235
|
+
costs a lookup and produces nothing. An empty list means the scope names no class this
|
|
236
|
+
graph knows, which is how an ordinary function call (`trim`, `error_log`) — recorded by
|
|
237
|
+
graphify in the same shape as a scoped call — is filtered out without special-casing.
|
|
238
|
+
"""
|
|
239
|
+
classes = self._scope_classes(caller_nid, scope)
|
|
240
|
+
names: list[str] = []
|
|
241
|
+
seen: set[str] = set()
|
|
242
|
+
for class_nid in classes:
|
|
243
|
+
for name in self._method_names.get(class_nid, ()):
|
|
244
|
+
if name and name not in seen:
|
|
245
|
+
seen.add(name)
|
|
246
|
+
names.append(name)
|
|
247
|
+
return names
|
|
248
|
+
|
|
249
|
+
def _scope_classes(self, caller_nid: str, scope: str) -> list[str]:
|
|
250
|
+
key = _key(scope)
|
|
251
|
+
if key in ("self", "static"):
|
|
252
|
+
owner = self._owner_of.get(caller_nid)
|
|
253
|
+
# Inherited methods are callable through `self::` too, so the ancestry is included.
|
|
254
|
+
return self._with_ancestors([owner]) if owner else []
|
|
255
|
+
if key == "parent":
|
|
256
|
+
owner = self._owner_of.get(caller_nid)
|
|
257
|
+
return self._with_ancestors(self._parents.get(owner, [])) if owner else []
|
|
258
|
+
# A written class name: every class that spells it. Ambiguity is harmless here because
|
|
259
|
+
# the candidate is a method NAME, and the source resolves the class itself from the
|
|
260
|
+
# file's own namespace and `use` statements.
|
|
261
|
+
return self._with_ancestors(
|
|
262
|
+
[
|
|
263
|
+
n["id"]
|
|
264
|
+
for n in self._classes_by_name.get(
|
|
265
|
+
_key(_fqn_parts(scope)[-1] if _fqn_parts(scope) else ""), []
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _with_ancestors(self, class_nids: list[str]) -> list[str]:
|
|
271
|
+
out: list[str] = []
|
|
272
|
+
queue = list(class_nids)
|
|
273
|
+
seen: set[str] = set()
|
|
274
|
+
while queue:
|
|
275
|
+
nid = queue.pop(0)
|
|
276
|
+
if not nid or nid in seen:
|
|
277
|
+
continue
|
|
278
|
+
seen.add(nid)
|
|
279
|
+
out.append(nid)
|
|
280
|
+
queue.extend(self._parents.get(nid, ()))
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
def _class_node(self, class_fqn: str) -> dict | None:
|
|
284
|
+
parts = _fqn_parts(class_fqn)
|
|
285
|
+
if not parts:
|
|
286
|
+
return None
|
|
287
|
+
namespace, short = parts[:-1], parts[-1]
|
|
288
|
+
candidates = self._classes_by_name.get(_key(short), [])
|
|
289
|
+
if not candidates:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
# Directories only: the file stem is the class name and matching it against the
|
|
293
|
+
# namespace would score every candidate the same.
|
|
294
|
+
scored = [
|
|
295
|
+
(_tail_overlap(namespace, _path_parts(c.get("source_file", ""))[:-1]), c)
|
|
296
|
+
for c in candidates
|
|
297
|
+
]
|
|
298
|
+
best = max(score for score, _ in scored)
|
|
299
|
+
winners = [c for score, c in scored if score == best]
|
|
300
|
+
if len(winners) != 1:
|
|
301
|
+
# Two project classes share a short name and the namespace did not separate them.
|
|
302
|
+
# Picking either is a coin flip, and a wrong call edge is worse than a missing one.
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
# A namespace of two or more segments must show up in the file path, because a FQN
|
|
306
|
+
# from outside the indexed tree (`Doctrine\ORM\EntityManager`) otherwise binds to
|
|
307
|
+
# whatever project class happens to share its last segment — a unique match, and
|
|
308
|
+
# wrong. A single-segment namespace is exempt: the PSR-4 root (`App\`) maps to the
|
|
309
|
+
# source directory (`src/`) and never appears as a directory itself.
|
|
310
|
+
# One shared segment is not enough either: `Vendor\Lib\Emails` against
|
|
311
|
+
# `src/Lib/Emails.php` overlaps by one and is still foreign. Every segment but the
|
|
312
|
+
# PSR-4 root has to be on the path.
|
|
313
|
+
if len(namespace) >= 2 and best < len(namespace) - 1:
|
|
314
|
+
return None
|
|
315
|
+
return winners[0]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class GraphEdgeSink:
|
|
319
|
+
"""Appends `calls` edges to graphify's edge list.
|
|
320
|
+
|
|
321
|
+
Deduplicates against the pairs already in the graph, the way graphify's own member-call
|
|
322
|
+
resolvers do: the shared cross-file pass may already have connected the same two nodes by
|
|
323
|
+
another route, and a second edge would double that pair's weight in every consumer.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(self, all_edges: list[dict], index: GraphNodeIndex | None = None) -> None:
|
|
327
|
+
self._all_edges = all_edges
|
|
328
|
+
self._index = index
|
|
329
|
+
self._pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
|
330
|
+
self._class_calls = {
|
|
331
|
+
(e.get("source"), e.get("target")) for e in all_edges if e.get("relation") == "calls"
|
|
332
|
+
}
|
|
333
|
+
self.added = 0
|
|
334
|
+
|
|
335
|
+
def add_call(self, caller_nid: str, callee_nid: str, target: CallTarget) -> None:
|
|
336
|
+
if (caller_nid, callee_nid) in self._pairs:
|
|
337
|
+
return
|
|
338
|
+
self._pairs.add((caller_nid, callee_nid))
|
|
339
|
+
edge = {
|
|
340
|
+
"source": caller_nid,
|
|
341
|
+
"target": callee_nid,
|
|
342
|
+
"relation": "calls",
|
|
343
|
+
"context": "call",
|
|
344
|
+
"confidence": target.confidence.value,
|
|
345
|
+
"confidence_score": _CONFIDENCE_SCORE[target.confidence],
|
|
346
|
+
"source_file": target.site.source_file,
|
|
347
|
+
"source_location": target.site.source_location,
|
|
348
|
+
"weight": 1.0,
|
|
349
|
+
EDGE_MARKER: EDGE_ORIGIN,
|
|
350
|
+
"_via": target.via,
|
|
351
|
+
}
|
|
352
|
+
# `Helper::format()` already produced a coarse caller -> Helper-the-CLASS edge, because
|
|
353
|
+
# graphify records the scope as the callee and resolves that name to the class node. Our
|
|
354
|
+
# method-level edge is a second, narrower edge between different endpoints, so neither
|
|
355
|
+
# dedup nor removal applies — a consumer counting `calls` edges will see both. Naming
|
|
356
|
+
# the coarse edge here is what lets it collapse the pair instead of double-counting it.
|
|
357
|
+
owner = self._index.owner_class(callee_nid) if self._index is not None else None
|
|
358
|
+
if owner is not None and (caller_nid, owner) in self._class_calls:
|
|
359
|
+
edge["_refines"] = owner
|
|
360
|
+
self._all_edges.append(edge)
|
|
361
|
+
self.added += 1
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
STUB_ID_PREFIX = "php_external_"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _stub_nid(class_fqn: str) -> str:
|
|
368
|
+
"""One id per external class, derived from the FQN alone so every build agrees.
|
|
369
|
+
|
|
370
|
+
`external: True` keeps the id global in `merge-graphs`, so the prefix must not collide with
|
|
371
|
+
graphify's own project-class ids, which never start this way.
|
|
372
|
+
"""
|
|
373
|
+
return STUB_ID_PREFIX + re.sub(r"[^a-z0-9_]", "_", class_fqn.lower().replace("\\", "_"))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class GraphStubSink:
|
|
377
|
+
"""Mints a body-less node per external declaring class and edges proven calls to it.
|
|
378
|
+
|
|
379
|
+
The opt-in graphify core refuses for `calls` (phantom hubs): a `Doctrine\\ORM\\EntityManager`
|
|
380
|
+
stub will carry hundreds of edges. One stub per CLASS, never per method, so the node count
|
|
381
|
+
stays at the number of vendor classes the project touches; the method survives on the edge.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
|
|
385
|
+
self._all_nodes = all_nodes
|
|
386
|
+
self._all_edges = all_edges
|
|
387
|
+
self._by_id = {n.get("id") for n in all_nodes}
|
|
388
|
+
self._pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
|
389
|
+
self.nodes_added = 0
|
|
390
|
+
self.edges_added = 0
|
|
391
|
+
|
|
392
|
+
def node_for(self, class_fqn: str) -> str:
|
|
393
|
+
nid = _stub_nid(class_fqn)
|
|
394
|
+
if nid not in self._by_id:
|
|
395
|
+
self._by_id.add(nid)
|
|
396
|
+
# The same shape graphify core gives an import stub (build.py), so every consumer
|
|
397
|
+
# that already handles those handles these.
|
|
398
|
+
self._all_nodes.append(
|
|
399
|
+
{
|
|
400
|
+
"id": nid,
|
|
401
|
+
"label": class_fqn,
|
|
402
|
+
"file_type": "concept",
|
|
403
|
+
"type": "external",
|
|
404
|
+
"external": True,
|
|
405
|
+
"source_file": "",
|
|
406
|
+
EDGE_MARKER: EDGE_ORIGIN,
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
self.nodes_added += 1
|
|
410
|
+
return nid
|
|
411
|
+
|
|
412
|
+
def add_call(self, caller_nid: str, stub_nid: str, target: CallTarget) -> None:
|
|
413
|
+
if (caller_nid, stub_nid) in self._pairs:
|
|
414
|
+
return
|
|
415
|
+
self._pairs.add((caller_nid, stub_nid))
|
|
416
|
+
self._all_edges.append(
|
|
417
|
+
{
|
|
418
|
+
"source": caller_nid,
|
|
419
|
+
"target": stub_nid,
|
|
420
|
+
"relation": "calls",
|
|
421
|
+
"context": "call",
|
|
422
|
+
"confidence": target.confidence.value,
|
|
423
|
+
"confidence_score": _CONFIDENCE_SCORE[target.confidence],
|
|
424
|
+
"source_file": target.site.source_file,
|
|
425
|
+
"source_location": target.site.source_location,
|
|
426
|
+
"weight": 1.0,
|
|
427
|
+
EDGE_MARKER: EDGE_ORIGIN,
|
|
428
|
+
"_via": target.via,
|
|
429
|
+
"_external_target": f"{target.class_fqn}::{target.method}",
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
self.edges_added += 1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# A route declaration is not a call, so it needs a relation of its own. `routed_to` is already
|
|
436
|
+
# taken by messenger transport routing (config/messaging.py:22) and means something else
|
|
437
|
+
# entirely; reusing it would merge HTTP paths and message transports into one query.
|
|
438
|
+
ROUTE_RELATION = "handled_by"
|
|
439
|
+
ROUTE_NODE_KIND = "http_route"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _route_nid(node_key: str) -> str:
|
|
443
|
+
"""A stable, readable id for a route declaration.
|
|
444
|
+
|
|
445
|
+
The slug is for a human reading the graph; the digest is what guarantees identity, because
|
|
446
|
+
two declarations differing only in punctuation (`/a-b` and `/a_b`) slug to the same string.
|
|
447
|
+
`hashlib`, not `hash()`, so the id is the same in every process and every build.
|
|
448
|
+
"""
|
|
449
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", node_key).strip("_").lower()[:80]
|
|
450
|
+
digest = hashlib.blake2b(node_key.encode("utf-8"), digest_size=4).hexdigest()
|
|
451
|
+
return f"route_{slug}_{digest}"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class GraphRouteSink:
|
|
455
|
+
"""Publishes one node and one edge per route declaration.
|
|
456
|
+
|
|
457
|
+
Separate from `GraphEdgeSink` because it adds NODES, which no call-target path ever does:
|
|
458
|
+
a route is a thing the graph did not previously contain, not a connection between two
|
|
459
|
+
things it already had. That is also why it is worth a status record of its own — a layer
|
|
460
|
+
that adds nothing looks exactly like a codebase with no routes.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
|
|
464
|
+
self._all_nodes = all_nodes
|
|
465
|
+
self._all_edges = all_edges
|
|
466
|
+
self._by_id = {n.get("id") for n in all_nodes}
|
|
467
|
+
self._pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
|
468
|
+
self.nodes_added = 0
|
|
469
|
+
self.edges_added = 0
|
|
470
|
+
|
|
471
|
+
def add_route(self, declaration, controller_nid: str | None) -> str | None:
|
|
472
|
+
"""Emit the declaration's node, and its edge when the controller method is in the graph.
|
|
473
|
+
|
|
474
|
+
Returns the node id, or None when the declaration carries no path at all and so has
|
|
475
|
+
nothing to label a node with.
|
|
476
|
+
"""
|
|
477
|
+
path = declaration.path or declaration.method_path
|
|
478
|
+
if not path:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
nid = _route_nid(declaration.node_key)
|
|
482
|
+
if nid not in self._by_id:
|
|
483
|
+
self._by_id.add(nid)
|
|
484
|
+
self._all_nodes.append(
|
|
485
|
+
{
|
|
486
|
+
"id": nid,
|
|
487
|
+
"label": path,
|
|
488
|
+
"source_file": declaration.source_file,
|
|
489
|
+
"source_location": f"L{declaration.line}",
|
|
490
|
+
"file_type": "code",
|
|
491
|
+
EDGE_MARKER: EDGE_ORIGIN,
|
|
492
|
+
"_kind": ROUTE_NODE_KIND,
|
|
493
|
+
"route_name": declaration.name or "",
|
|
494
|
+
"http_methods": list(declaration.methods),
|
|
495
|
+
# A path composed from only one half is a usable partial, not a whole answer,
|
|
496
|
+
# and a reader ranking routes needs to know which it is looking at.
|
|
497
|
+
"route_complete": declaration.path is not None,
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
self.nodes_added += 1
|
|
501
|
+
|
|
502
|
+
if controller_nid and (nid, controller_nid) not in self._pairs:
|
|
503
|
+
self._pairs.add((nid, controller_nid))
|
|
504
|
+
self._all_edges.append(
|
|
505
|
+
{
|
|
506
|
+
"source": nid,
|
|
507
|
+
"target": controller_nid,
|
|
508
|
+
"relation": ROUTE_RELATION,
|
|
509
|
+
"context": "route",
|
|
510
|
+
"confidence": Confidence.EXTRACTED.value,
|
|
511
|
+
"confidence_score": 1.0,
|
|
512
|
+
"source_file": declaration.source_file,
|
|
513
|
+
"source_location": f"L{declaration.line}",
|
|
514
|
+
"weight": 1.0,
|
|
515
|
+
EDGE_MARKER: EDGE_ORIGIN,
|
|
516
|
+
"_via": "attribute",
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
self.edges_added += 1
|
|
520
|
+
return nid
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# PHPStan call-graph extension
|
|
2
|
+
|
|
3
|
+
A PHPStan collector and rule that write one JSON record per resolved method call. It knows
|
|
4
|
+
nothing about whatever consumes the file — no node ids, no graph schema — so it can be adopted
|
|
5
|
+
by anything that wants a call graph out of PHPStan.
|
|
6
|
+
|
|
7
|
+
Nothing is installed. There is no composer package and no dependency to resolve; the extension
|
|
8
|
+
is analysed inside somebody else's project, whose lockfile is not ours to move.
|
|
9
|
+
|
|
10
|
+
## Wiring
|
|
11
|
+
|
|
12
|
+
Add this to the project's `phpstan.neon`:
|
|
13
|
+
|
|
14
|
+
```neon
|
|
15
|
+
services:
|
|
16
|
+
-
|
|
17
|
+
class: CallGraph\JsonLinesWriter
|
|
18
|
+
-
|
|
19
|
+
class: CallGraph\MethodCallCollector
|
|
20
|
+
tags:
|
|
21
|
+
- phpstan.collector
|
|
22
|
+
-
|
|
23
|
+
class: CallGraph\CallEdgeRule
|
|
24
|
+
tags:
|
|
25
|
+
- phpstan.rules.rule
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then make the `CallGraph\` classes loadable. PHPStan builds its DI container before
|
|
29
|
+
`parameters.bootstrapFiles` run, so a bootstrap file is too late and fails with
|
|
30
|
+
`Class 'CallGraph\JsonLinesWriter' not found`. Use the `--autoload-file` option instead:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
PHP_CALL_GRAPH_OUT=/tmp/calls.jsonl \
|
|
34
|
+
vendor/bin/phpstan analyse --autoload-file=/path/to/graphify-php/php/autoload.php
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A project that would rather own the autoloading can add `"CallGraph\\": "php/src/CallGraph/"`
|
|
38
|
+
to its composer `autoload-dev` and drop the option.
|
|
39
|
+
|
|
40
|
+
`graphify-php`'s `phpstan` source needs neither step from the adopter: it passes
|
|
41
|
+
`--autoload-file` itself and writes the `services:` block into a wrapper config in its own
|
|
42
|
+
temporary directory that `includes:` the project's `phpstan.neon`. The block is deliberately not
|
|
43
|
+
committed to the project, because the `CallGraph\` classes arrive only through that option and
|
|
44
|
+
a plain `vendor/bin/phpstan` — the one CI runs — would fail on
|
|
45
|
+
`Class 'CallGraph\JsonLinesWriter' not found` before analysing anything. A project that adds
|
|
46
|
+
the block to its own config anyway is run on that config untouched; registering the collector
|
|
47
|
+
twice duplicates every record.
|
|
48
|
+
|
|
49
|
+
Verified against PHPStan 2.2.8 on PHP 8.4.
|
|
50
|
+
|
|
51
|
+
## Output
|
|
52
|
+
|
|
53
|
+
`PHP_CALL_GRAPH_OUT` names the destination; without it the file lands at `php-call-graph.jsonl`
|
|
54
|
+
in the working directory. One JSON object per line:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{"callerClass":"App\\Controller","callerMethod":"run","calleeClass":"App\\Mailer","calleeMethod":"send","calleeKind":"interface","line":21,"file":"/srv/app/src/Controller.php","callPos":148,"calleeFile":"/srv/app/src/Mailer.php"}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`callPos` is the token offset of the called method's name, `-1` when the parser did not record
|
|
61
|
+
one. A union receiver (`Emails|Cache`) yields one record per arm with the same `callPos`; two
|
|
62
|
+
calls of one method on one line yield different values, so a consumer can tell the two apart.
|
|
63
|
+
|
|
64
|
+
`calleeKind` is `interface` when the method is declared on an interface. That edge names a
|
|
65
|
+
contract, not a body: there is no code behind `App\Mailer::send`, and a consumer that treats it
|
|
66
|
+
as a call into an implementation acquires a path that was never executed.
|
|
67
|
+
|
|
68
|
+
`calleeClass` is the *declaring* class, not the receiver. An inherited method lives in the
|
|
69
|
+
parent, and an edge to the child would point at a body that does not exist there. When the
|
|
70
|
+
body lives in a trait, `calleeClass` is the trait: PHPStan's declaring class for a trait
|
|
71
|
+
method is the class using it, whose file holds no such body.
|
|
72
|
+
|
|
73
|
+
`calleeFile` is the file that declares `calleeClass`, `''` when PHPStan has no file for it (a
|
|
74
|
+
built-in class). A consumer uses it to tell a `vendor/` class from project code the graph left
|
|
75
|
+
out, without guessing from the namespace.
|
|
76
|
+
|
|
77
|
+
## What it refuses to report
|
|
78
|
+
|
|
79
|
+
Nothing is emitted unless PHPStan produced a class reflection for the receiver. A name-only
|
|
80
|
+
fallback — matching `->get()` to any class that happens to own a `get` — manufactures edges
|
|
81
|
+
indistinguishable from real ones, and a wrong edge is worse than a missing one. So these
|
|
82
|
+
produce no record:
|
|
83
|
+
|
|
84
|
+
- a receiver PHPStan types as `mixed`, which includes an **untyped property**, even one assigned
|
|
85
|
+
from a typed constructor parameter: PHPStan reports `missingType.property` rather than
|
|
86
|
+
inferring it. Give the property a native type or a `@var` docblock and the call resolves.
|
|
87
|
+
- a dynamic method name, `$obj->$method()`;
|
|
88
|
+
- a receiver on a class the analysis cannot load;
|
|
89
|
+
- an intersection receiver (`A&B`) whose constituents resolve to more than one declaring class
|
|
90
|
+
for the method. One object, two declarations: the type does not say which body runs, so
|
|
91
|
+
neither is emitted. This matches the parser layer's `ambiguous-intersection` refusal. A union
|
|
92
|
+
receiver (`A|B`) is different: one of two objects, so every arm is emitted, and each arm is
|
|
93
|
+
decided on its own — `(A&B)|C` emits `C` and drops the ambiguous intersection, `(A&B)|null`
|
|
94
|
+
emits nothing. The parser layer is stricter there and refuses `(A&B)|C` wholesale as
|
|
95
|
+
`union-type`.
|
|
96
|
+
|
|
97
|
+
Also out of scope in this version, by omission rather than by refusal: `?->` nullsafe calls,
|
|
98
|
+
`Foo::bar()` static calls, and `new Foo` construction edges. Each is a different node type and
|
|
99
|
+
would be a second collector.
|
|
100
|
+
|
|
101
|
+
## Cost
|
|
102
|
+
|
|
103
|
+
PHPStan loads project classes through the composer autoloader, so an analysis executes some
|
|
104
|
+
project code at class-load time — attribute constructors, static initialisers, and anything the
|
|
105
|
+
project's own bootstrap does. Run it the way any analysis is run: with a timeout, a memory
|
|
106
|
+
limit, and no terminal to prompt at.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PSR-4 registrar for the CallGraph namespace.
|
|
7
|
+
*
|
|
8
|
+
* Shipped instead of a composer package so adopting this costs a `bootstrapFiles` line and no
|
|
9
|
+
* dependency resolution: it is analysed inside somebody else's project, whose lockfile is not
|
|
10
|
+
* ours to move.
|
|
11
|
+
*/
|
|
12
|
+
spl_autoload_register(static function (string $class): void {
|
|
13
|
+
$prefix = 'CallGraph\\';
|
|
14
|
+
|
|
15
|
+
if (!str_starts_with($class, $prefix)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$relative = substr($class, strlen($prefix));
|
|
20
|
+
$path = __DIR__ . '/src/CallGraph/' . str_replace('\\', '/', $relative) . '.php';
|
|
21
|
+
|
|
22
|
+
if (!is_file($path)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
require_once $path;
|
|
27
|
+
});
|