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,288 @@
|
|
|
1
|
+
"""Route declarations read from PHP attributes.
|
|
2
|
+
|
|
3
|
+
Why this is here at all. The call graph answers questions whose subject is a symbol. It cannot
|
|
4
|
+
answer one whose subject is a literal: "which repository owns `/api/v1/storage/upload/small`"
|
|
5
|
+
has no symbol in it, no node label contains a route path, and the ranked search therefore
|
|
6
|
+
returns a frontend caller and an unrelated class above the controller that actually serves it.
|
|
7
|
+
A route declaration puts the literal into the graph, attached to the method that serves it.
|
|
8
|
+
|
|
9
|
+
What a route declaration proves, and what it does not. It proves that this codebase declares
|
|
10
|
+
that path and names this controller method to serve it. It does NOT prove that a deployed
|
|
11
|
+
ingress, load balancer or reverse proxy routes that path here, nor that any environment loads
|
|
12
|
+
the route at all — Symfony filters by `env`, a firewall may block it, and another service may
|
|
13
|
+
answer the same path first. A reader will assume the stronger claim, so it is worth saying
|
|
14
|
+
plainly: this is a declaration in source, not an observation of production traffic.
|
|
15
|
+
|
|
16
|
+
The composition rules are read from Symfony's own loader rather than guessed, in the copy at
|
|
17
|
+
`vendor/symfony/routing/Loader/AttributeClassLoader.php`:
|
|
18
|
+
|
|
19
|
+
- path — `$paths[] = $prefix.$path`, plain string concatenation of the class-level path onto
|
|
20
|
+
the method-level one.
|
|
21
|
+
- name — `$name = $globals['name'].$name`, the class-level name used as a prefix.
|
|
22
|
+
- methods — `array_unique(array_merge($globals['methods'], $attr->methods))`, a UNION. A
|
|
23
|
+
method-level `methods:` does not replace the class-level one, which is the rule most likely
|
|
24
|
+
to be guessed wrong.
|
|
25
|
+
- an abstract class is skipped entirely (`load()` throws on it).
|
|
26
|
+
- a class with `__invoke` and no method-level route takes its route from the class attribute,
|
|
27
|
+
and `resetGlobals()` means no prefix is applied in that case — the class attribute IS the
|
|
28
|
+
route rather than a prefix for one.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
from . import nodes
|
|
36
|
+
|
|
37
|
+
# Matched on the short name, because that is what the attribute is written as. A `use` that
|
|
38
|
+
# renames it is not followed: an alias for `Route` is vanishingly rare next to the cost of
|
|
39
|
+
# resolving every attribute name in the file, and a miss here loses a node rather than
|
|
40
|
+
# inventing one.
|
|
41
|
+
ROUTE_ATTRIBUTE = "Route"
|
|
42
|
+
|
|
43
|
+
STRING_KINDS = ("string", "encapsed_string")
|
|
44
|
+
|
|
45
|
+
# Why an effective route could not be composed. Fixed strings, same discipline as the call
|
|
46
|
+
# refusals: these are a work list, not a histogram of prose.
|
|
47
|
+
PATH_NOT_LITERAL = "path-not-literal"
|
|
48
|
+
PREFIX_NOT_LITERAL = "prefix-not-literal"
|
|
49
|
+
LOCALIZED_PATH = "localized-path"
|
|
50
|
+
NO_PATH = "no-path"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class RouteDeclaration:
|
|
55
|
+
"""One `#[Route]` as declared, with the controller method it sits on.
|
|
56
|
+
|
|
57
|
+
`path` is the template verbatim — `/api/v1/storage/upload/small/{id}` — because the
|
|
58
|
+
placeholder is what makes it searchable and matchable. It is None when composition needed
|
|
59
|
+
something this package cannot evaluate; `class_path` and `method_path` still carry
|
|
60
|
+
whichever halves were literal, so the declaration is a usable partial rather than a hole.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
controller_class: str
|
|
64
|
+
controller_method: str
|
|
65
|
+
source_file: str
|
|
66
|
+
line: int
|
|
67
|
+
path: str | None = None
|
|
68
|
+
name: str | None = None
|
|
69
|
+
methods: tuple[str, ...] = ()
|
|
70
|
+
class_path: str | None = None
|
|
71
|
+
method_path: str | None = None
|
|
72
|
+
reason: str = ""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def resolved(self) -> bool:
|
|
76
|
+
return self.path is not None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def node_key(self) -> str:
|
|
80
|
+
"""Identity of this declaration, not of the path.
|
|
81
|
+
|
|
82
|
+
The declaring method is in the key on purpose. Two repositories that both declare
|
|
83
|
+
`/health` declare two different things — each is served by its own controller — and
|
|
84
|
+
collapsing them onto one node would claim a relationship between two codebases that
|
|
85
|
+
share nothing but a string.
|
|
86
|
+
"""
|
|
87
|
+
return (
|
|
88
|
+
f"{self.controller_class}::{self.controller_method}#"
|
|
89
|
+
f"{self.path or self.method_path or ''}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_routes(root, names, source_file: str) -> list[RouteDeclaration]:
|
|
94
|
+
"""Every route declared in one file.
|
|
95
|
+
|
|
96
|
+
A separate pass over the tree rather than a hook in the walker: routes are not type facts,
|
|
97
|
+
and the walker's one reason to change is scope.
|
|
98
|
+
"""
|
|
99
|
+
found: list[RouteDeclaration] = []
|
|
100
|
+
_walk(root, names, source_file, found)
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _walk(node, names, source_file: str, found: list) -> None:
|
|
105
|
+
if node.type in nodes.CLASS_KINDS:
|
|
106
|
+
_read_class(node, names, source_file, found)
|
|
107
|
+
return
|
|
108
|
+
for child in node.children:
|
|
109
|
+
_walk(child, names, source_file, found)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_class(node, names, source_file: str, found: list) -> None:
|
|
113
|
+
if nodes.child(node, "abstract_modifier") is not None:
|
|
114
|
+
# Symfony's loader throws rather than reading attributes from an abstract class, so a
|
|
115
|
+
# route on one is a prefix for subclasses, never a route in its own right.
|
|
116
|
+
return
|
|
117
|
+
class_fqn = names.qualify_declaration(nodes.name_of(node))
|
|
118
|
+
class_attr = _route_attribute(node)
|
|
119
|
+
prefix = _Prefix.read(class_attr)
|
|
120
|
+
|
|
121
|
+
body = nodes.child(node, "declaration_list", "enum_declaration_list")
|
|
122
|
+
methods = nodes.children(body, "method_declaration") if body is not None else []
|
|
123
|
+
|
|
124
|
+
before = len(found)
|
|
125
|
+
for method in methods:
|
|
126
|
+
attribute = _route_attribute(method)
|
|
127
|
+
if attribute is None:
|
|
128
|
+
continue
|
|
129
|
+
found.append(_compose(attribute, prefix, class_fqn, nodes.name_of(method), source_file))
|
|
130
|
+
|
|
131
|
+
if len(found) == before and class_attr is not None:
|
|
132
|
+
invoke = next((m for m in methods if nodes.name_of(m) == "__invoke"), None)
|
|
133
|
+
if invoke is not None:
|
|
134
|
+
# `resetGlobals()` in the loader: the class attribute is the route itself here, so
|
|
135
|
+
# it composes with nothing.
|
|
136
|
+
found.append(_compose(class_attr, _Prefix(), class_fqn, "__invoke", source_file))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class _Prefix:
|
|
141
|
+
"""The class-level attribute, as the three things it contributes."""
|
|
142
|
+
|
|
143
|
+
path: str | None = None
|
|
144
|
+
name: str = ""
|
|
145
|
+
methods: tuple[str, ...] = ()
|
|
146
|
+
path_literal: bool = True
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def read(cls, attribute) -> _Prefix:
|
|
150
|
+
if attribute is None:
|
|
151
|
+
return cls()
|
|
152
|
+
positional, named = _arguments(attribute)
|
|
153
|
+
path_node = named.get("path") or (positional[0] if positional else None)
|
|
154
|
+
path, literal = _path_of(path_node)
|
|
155
|
+
return cls(
|
|
156
|
+
path=path,
|
|
157
|
+
name=_string_value(named.get("name")) or "",
|
|
158
|
+
methods=_string_list(named.get("methods")),
|
|
159
|
+
path_literal=literal,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _compose(
|
|
164
|
+
attribute, prefix: _Prefix, class_fqn: str, method_name: str, source_file: str
|
|
165
|
+
) -> RouteDeclaration:
|
|
166
|
+
positional, named = _arguments(attribute)
|
|
167
|
+
path_node = named.get("path") or (positional[0] if positional else None)
|
|
168
|
+
method_path, literal = _path_of(path_node)
|
|
169
|
+
|
|
170
|
+
name = _string_value(named.get("name"))
|
|
171
|
+
# `array_unique(array_merge(...))`: a union, with the class-level entries first.
|
|
172
|
+
methods = tuple(dict.fromkeys(prefix.methods + _string_list(named.get("methods"))))
|
|
173
|
+
|
|
174
|
+
effective: str | None = None
|
|
175
|
+
reason = ""
|
|
176
|
+
if path_node is None:
|
|
177
|
+
reason = NO_PATH
|
|
178
|
+
elif not literal:
|
|
179
|
+
reason = (
|
|
180
|
+
LOCALIZED_PATH if method_path is None and _is_array(path_node) else PATH_NOT_LITERAL
|
|
181
|
+
)
|
|
182
|
+
elif not prefix.path_literal:
|
|
183
|
+
reason = PREFIX_NOT_LITERAL
|
|
184
|
+
else:
|
|
185
|
+
effective = (prefix.path or "") + method_path
|
|
186
|
+
|
|
187
|
+
return RouteDeclaration(
|
|
188
|
+
controller_class=class_fqn,
|
|
189
|
+
controller_method=method_name,
|
|
190
|
+
source_file=source_file,
|
|
191
|
+
line=nodes.line_span(attribute)[0],
|
|
192
|
+
path=effective,
|
|
193
|
+
name=(prefix.name + name) if name is not None else (prefix.name or None),
|
|
194
|
+
methods=methods,
|
|
195
|
+
class_path=prefix.path,
|
|
196
|
+
method_path=method_path,
|
|
197
|
+
reason=reason,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _route_attribute(declaration):
|
|
202
|
+
"""The `#[Route(...)]` on a class or method declaration, or None.
|
|
203
|
+
|
|
204
|
+
Only attribute groups directly on this declaration are read, so an attribute on a nested
|
|
205
|
+
declaration cannot be mistaken for one on its parent.
|
|
206
|
+
"""
|
|
207
|
+
attributes = nodes.child(declaration, "attribute_list")
|
|
208
|
+
if attributes is None:
|
|
209
|
+
return None
|
|
210
|
+
for group in nodes.children(attributes, "attribute_group"):
|
|
211
|
+
for attribute in nodes.children(group, "attribute"):
|
|
212
|
+
written = nodes.node_text(nodes.child(attribute, "name", "qualified_name"))
|
|
213
|
+
if written.rsplit("\\", 1)[-1] == ROUTE_ATTRIBUTE:
|
|
214
|
+
return attribute
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _arguments(attribute) -> tuple[list, dict]:
|
|
219
|
+
positional: list = []
|
|
220
|
+
named: dict = {}
|
|
221
|
+
argument_list = nodes.child(attribute, "arguments")
|
|
222
|
+
for argument in nodes.children(argument_list, "argument"):
|
|
223
|
+
label = nodes.child(argument, "name")
|
|
224
|
+
# Named nodes only: `name: "x"` puts a bare `:` token between the label and the value,
|
|
225
|
+
# and taking the first non-label child would take the colon.
|
|
226
|
+
value = next((c for c in argument.children if c.is_named and c is not label), None)
|
|
227
|
+
if value is None:
|
|
228
|
+
continue
|
|
229
|
+
if label is not None:
|
|
230
|
+
named[nodes.node_text(label)] = value
|
|
231
|
+
else:
|
|
232
|
+
positional.append(value)
|
|
233
|
+
return positional, named
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _path_of(node) -> tuple[str | None, bool]:
|
|
237
|
+
"""(path, was-a-literal). A non-literal keeps `None` rather than a guessed value."""
|
|
238
|
+
if node is None:
|
|
239
|
+
return None, True
|
|
240
|
+
value = _string_value(node)
|
|
241
|
+
if value is None:
|
|
242
|
+
return None, False
|
|
243
|
+
return value, True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _is_array(node) -> bool:
|
|
247
|
+
return node is not None and node.type == "array_creation_expression"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _string_value(node) -> str | None:
|
|
251
|
+
"""The text of a string literal, or None for anything that needs evaluating.
|
|
252
|
+
|
|
253
|
+
A concatenation, a constant or a variable all return None. Reading `self::PREFIX . '/x'`
|
|
254
|
+
as `/x` would put a path in the graph that the application never serves.
|
|
255
|
+
"""
|
|
256
|
+
if node is None or node.type not in STRING_KINDS:
|
|
257
|
+
return None
|
|
258
|
+
parts = [c for c in node.children if c.is_named]
|
|
259
|
+
if len(parts) == 1 and parts[0].type == "string_content":
|
|
260
|
+
return nodes.node_text(parts[0])
|
|
261
|
+
if parts:
|
|
262
|
+
# An escape sequence or an interpolation: the value is not the source text, and the
|
|
263
|
+
# first `string_content` of `'/it\'s/{id}'` is `/it`, a path nothing serves.
|
|
264
|
+
return None
|
|
265
|
+
text = nodes.node_text(node)
|
|
266
|
+
return text[1:-1] if len(text) >= 2 and text[0] in "\"'" else text
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _string_list(node) -> tuple[str, ...]:
|
|
270
|
+
"""`methods: 'GET'` and `methods: ['GET', 'POST']` are both written in this codebase."""
|
|
271
|
+
if node is None:
|
|
272
|
+
return ()
|
|
273
|
+
single = _string_value(node)
|
|
274
|
+
if single is not None:
|
|
275
|
+
return (single,)
|
|
276
|
+
if not _is_array(node):
|
|
277
|
+
return ()
|
|
278
|
+
out = []
|
|
279
|
+
for element in node.children:
|
|
280
|
+
value = _string_value(element)
|
|
281
|
+
if value is None:
|
|
282
|
+
for inner in element.children:
|
|
283
|
+
value = _string_value(inner)
|
|
284
|
+
if value is not None:
|
|
285
|
+
break
|
|
286
|
+
if value is not None:
|
|
287
|
+
out.append(value)
|
|
288
|
+
return tuple(out)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: graphify-php
|
|
3
|
+
Version: 0.0.5
|
|
4
|
+
Summary: PHP member-call resolution for graphify: binds $this->service->method() to the method node it calls.
|
|
5
|
+
Author: Andrii Afanasiev
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: call-graph,code-graph,graphify,php,static-analysis
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Provides-Extra: lint
|
|
16
|
+
Requires-Dist: ruff>=0.15; extra == 'lint'
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: graphifyy>=0.9.66; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# graphify-php
|
|
23
|
+
|
|
24
|
+
PHP member-call resolution for [graphify](https://github.com/OxCom/graphify). It binds
|
|
25
|
+
`$this->emails->reminderSubmitWeek($user)` to the method node it actually calls, so a PHP graph
|
|
26
|
+
has a call graph instead of a list of names.
|
|
27
|
+
|
|
28
|
+
## The measurement
|
|
29
|
+
|
|
30
|
+
graphify ships cross-file member-call resolvers for Swift, Python, Ruby, TypeScript, C#, Java
|
|
31
|
+
and Rust, and none for PHP. Its PHP extractor marks `$obj->method()` as a member call and the
|
|
32
|
+
shared cross-file pass then drops it, because a bare `method` collides across the corpus and a
|
|
33
|
+
name-only match is not evidence.
|
|
34
|
+
|
|
35
|
+
On a reference Symfony application (584 PHP files, not shipped with this package), measured
|
|
36
|
+
from the published graph:
|
|
37
|
+
|
|
38
|
+
| | |
|
|
39
|
+
|---|---|
|
|
40
|
+
| PHP callable nodes | 3426 |
|
|
41
|
+
| `calls` edges in the whole graph | 2172 |
|
|
42
|
+
| PHP callables with an outgoing `calls` edge | 24.4 % |
|
|
43
|
+
| PHP callables with an incoming `calls` edge | 20.1 % |
|
|
44
|
+
|
|
45
|
+
Three quarters of the PHP callables in that graph are connected to nothing they call. Every
|
|
46
|
+
"what calls this", "what does this touch" and blast-radius question over PHP is answered from
|
|
47
|
+
that quarter.
|
|
48
|
+
|
|
49
|
+
## What it does
|
|
50
|
+
|
|
51
|
+
The pass runs inside graphify's build, after extraction:
|
|
52
|
+
|
|
53
|
+
1. collects every member call graphify left unresolved, from `per_file[*]["raw_calls"]`;
|
|
54
|
+
2. hands the sources the PHP files graphify parsed as the `corpus` they may read declarations
|
|
55
|
+
from. A leaf service class has nothing unresolved in it, so it never appears among the sites,
|
|
56
|
+
and a source indexing only those files cannot tell whether it declares the method being
|
|
57
|
+
called on it. The set is bounded by the build, never by a filesystem walk;
|
|
58
|
+
3. asks each configured **call target source** in precedence order for the receiver's class —
|
|
59
|
+
the precise source (a PHP type engine that already knows the project's types) first, the
|
|
60
|
+
tree-sitter parser second. A site the first source proves is never offered to the second, so
|
|
61
|
+
a weaker source can add reach but can never overwrite a stronger source's answer;
|
|
62
|
+
4. maps each `(class FQN, method)` to a graphify node id;
|
|
63
|
+
5. appends `calls` edges carrying the target's confidence (`EXTRACTED` when the type was read
|
|
64
|
+
from source, `INFERRED` when it was derived) and `_resolver: "graphify-php"`, which is how a
|
|
65
|
+
reader tells our edges from graphify's own and can drop them without a rebuild. `_via` names
|
|
66
|
+
the technique that proved each one (`tree_sitter:promoted-parameter`, `tree_sitter:self-scope`,
|
|
67
|
+
...).
|
|
68
|
+
|
|
69
|
+
The marker is not `_origin`: graphify overwrites that on every edge with `"ast"` after the
|
|
70
|
+
resolvers run (`extract.py:8285-8289`), where it means the eviction tier rather than the author.
|
|
71
|
+
|
|
72
|
+
### Scoped calls
|
|
73
|
+
|
|
74
|
+
`self::`, `parent::`, `static::` and `Class::method()` reach graphify with `is_member_call`
|
|
75
|
+
false and the **scope** as the callee — the method name is dropped
|
|
76
|
+
(`extractors/engine.py:6147-6152`). The resolver recovers it without parsing PHP: it asks the
|
|
77
|
+
graph which methods that class declares and offers each as a candidate, and the type source
|
|
78
|
+
refuses every name not actually written on that line. A class with ten methods costs ten
|
|
79
|
+
lookups and yields at most one edge.
|
|
80
|
+
|
|
81
|
+
`Class::method()` additionally already has a coarse `caller -> Class` edge, because graphify
|
|
82
|
+
resolves the scope name to the class node. Our method-level edge is a **second** edge between
|
|
83
|
+
different endpoints, so both are published. The narrow one carries `_refines` naming the coarse
|
|
84
|
+
one, so a consumer can collapse the pair instead of counting the call twice.
|
|
85
|
+
|
|
86
|
+
A source returns a target only when it can prove it. Chained calls, dynamic names, `static::`
|
|
87
|
+
and anything through `__call` are counted by reason, not guessed at: a wrong call edge is worse
|
|
88
|
+
than a missing one, and every counted reason is a line on the next version's work list.
|
|
89
|
+
|
|
90
|
+
## Install and register
|
|
91
|
+
|
|
92
|
+
graphify-php is not on PyPI. Install a clone in editable mode into graphify's own environment;
|
|
93
|
+
the PHPStan source needs the `php/` directory of the checkout, which the wheel does not ship:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/OxCom/graphify-php.git ~/src/graphify-php
|
|
97
|
+
pipx inject --editable --include-apps graphifyy ~/src/graphify-php
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The one runtime dependency is pyyaml. graphify and tree-sitter come from the environment that
|
|
101
|
+
runs the build; pinning them here would fight graphify's own pins. Virtual environments,
|
|
102
|
+
PHPStan, verification and troubleshooting: [docs/install.md](docs/install.md).
|
|
103
|
+
|
|
104
|
+
Registration is one call, made before graphify builds:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import graphify_php
|
|
108
|
+
|
|
109
|
+
graphify_php.register()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
It is idempotent and imports graphify lazily, so importing the package never requires graphify
|
|
113
|
+
to be installed.
|
|
114
|
+
|
|
115
|
+
## Run a build
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
graphify-php-build -- update .
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Everything after `--` is passed to graphify unchanged; options before it belong to the wrapper:
|
|
122
|
+
|
|
123
|
+
| Option | Meaning |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `--graph PATH` | the graph artifact to protect (default `$GRAPHIFY_OUT/graph.json`) |
|
|
126
|
+
| `--expect RESOLVER` | a resolver that must report; repeatable, defaults to `php_member_calls` |
|
|
127
|
+
|
|
128
|
+
Exit codes:
|
|
129
|
+
|
|
130
|
+
| Code | Meaning |
|
|
131
|
+
|---|---|
|
|
132
|
+
| 0 | every expected resolver reported `completed` or `skipped` |
|
|
133
|
+
| 2 | an expected resolver reported `failed`, or never finished |
|
|
134
|
+
| 3 | an expected resolver never reported at all |
|
|
135
|
+
| other | graphify's own exit code, when graphify itself failed |
|
|
136
|
+
|
|
137
|
+
The wrapper sets `PYTHONHASHSEED=0` before calling graphify. graphify re-execs itself to pin
|
|
138
|
+
that seed for `update`, `extract`, `cluster-only` and `label` (`__main__.py:486-525`), and the
|
|
139
|
+
re-exec would replace the process — losing both the registration and the gate. Setting the seed
|
|
140
|
+
graphify would have set skips the re-exec and keeps its determinism guarantee.
|
|
141
|
+
|
|
142
|
+
## Why the wrapper exists
|
|
143
|
+
|
|
144
|
+
graphify runs registered resolvers like this:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
try:
|
|
148
|
+
resolver.resolve(per_file, all_nodes, all_edges)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A resolver that raises is skipped and the build still publishes. A resolver that was never
|
|
154
|
+
registered — because the seam moved in a graphify upgrade, or because an install left this
|
|
155
|
+
package out — is not even logged. Both produce a valid graph with fewer `calls` edges, which is
|
|
156
|
+
exactly the shape of the defect this package was written to fix. The failure would be invisible
|
|
157
|
+
at the one moment it matters, and the warning goes to graphify's logger, which nothing running
|
|
158
|
+
the build reads.
|
|
159
|
+
|
|
160
|
+
So each resolver writes its own record — state, and counters that include the denominator,
|
|
161
|
+
because "resolved 400 calls" says nothing without "out of how many" — and `graphify-php-build`
|
|
162
|
+
reads that record and refuses to publish when an expected resolver did not complete. On a
|
|
163
|
+
refusal the previous graph is restored and the exit code is non-zero.
|
|
164
|
+
|
|
165
|
+
`skipped` with a reason is a success: a repository with no PHP type engine in `vendor/` is a
|
|
166
|
+
normal skip, and conflating it with a failure is how a missing layer becomes noise. `failed`,
|
|
167
|
+
and silence, are not.
|
|
168
|
+
|
|
169
|
+
The record path defaults to `graphify-out/.graphify-php-status.json` and is overridable with
|
|
170
|
+
`GRAPHIFY_PHP_STATUS`, so one machine can index many repositories without their builds sharing
|
|
171
|
+
a file.
|
|
172
|
+
|
|
173
|
+
## Routes
|
|
174
|
+
|
|
175
|
+
A second registered pass publishes what `#[Route]` attributes declare. Each declaration becomes
|
|
176
|
+
one node — labelled with the path template, carrying the route name, the HTTP methods and
|
|
177
|
+
whether the path was composed whole — and one `handled_by` edge to the controller method that
|
|
178
|
+
serves it. On the reference Symfony application: **156 route nodes, 156 edges, no refusals**.
|
|
179
|
+
|
|
180
|
+
The node key is `Class::method#path`, so two controller methods declaring the same path stay two
|
|
181
|
+
nodes. That is not hypothetical: the reference application declares 156 routes over 135 distinct
|
|
182
|
+
path templates, the repeats being different HTTP verbs on one path.
|
|
183
|
+
|
|
184
|
+
The relation is `handled_by`, not `routed_to` — the latter already means messenger transport
|
|
185
|
+
routing, and merging the two would put HTTP paths and message transports in one query.
|
|
186
|
+
|
|
187
|
+
A declaration whose controller method is missing from the graph still gets its node; only the
|
|
188
|
+
edge is withheld, and the omission is counted under `controller_not_in_graph`. The path is a
|
|
189
|
+
fact about the codebase either way.
|
|
190
|
+
|
|
191
|
+
## Verifying it works
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
PYTHONPATH=src python -m pytest tests/test_cli_end_to_end.py -q
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
That test generates a small PHP project in a temporary directory, runs graphify over it twice —
|
|
198
|
+
once plain, once with `register()` called first — and asserts the difference. On the generated
|
|
199
|
+
fixture the plain build produces **1** `calls` edge and the registered build **5**. It needs no
|
|
200
|
+
reference application and writes nothing outside `tmp_path`.
|
|
201
|
+
|
|
202
|
+
## Extending
|
|
203
|
+
|
|
204
|
+
Adding a third source — a language server, an LSIF index — means implementing one protocol,
|
|
205
|
+
`CallTargetSource` in `ports.py`, and adding it to the factory in `__init__.py`. `resolver.py`
|
|
206
|
+
depends on the protocols and nothing else, and does not change.
|
|
207
|
+
|
|
208
|
+
## CI
|
|
209
|
+
|
|
210
|
+
GitHub Actions runs three stages in order: **security** (gitleaks, trufflehog, pip-audit,
|
|
211
|
+
`php -l` over `php/`), **tests** (ruff lint and format check, pytest on Python 3.10–3.14) and
|
|
212
|
+
**build** (`python -m build`, then import and `graphify-php-build --help` from the installed
|
|
213
|
+
wheel). The same checks locally: `pip install -e ".[test,lint]"`, then `ruff check .`,
|
|
214
|
+
`ruff format --check .`, `python -m pytest`, and `for f in php/autoload.php php/src/CallGraph/*.php; do php -l "$f"; done`.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
Apache-2.0.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
graphify_php/__init__.py,sha256=4EDW1vFFduuzwPjNff8rcS8dMOG0pq0HUaApmQDRv2o,5451
|
|
2
|
+
graphify_php/cli.py,sha256=hPfvX46J3XK0YZbZv12sjNC3Ol0a96K_MEc7ilK9PvY,14459
|
|
3
|
+
graphify_php/config_resolver.py,sha256=d3azDRtheX9xAbz1viWyz_18DEjsqpAZJQGGQGWns68,7239
|
|
4
|
+
graphify_php/graph_adapter.py,sha256=jXge2Tz9vrNpUi5ptLmKDaXLhrzZO8S56iZJGKyj-pw,24022
|
|
5
|
+
graphify_php/ports.py,sha256=pC9yL3-ZtUzm3PXF7rU3Db_MElkA_Ea95_hvW7vTO2A,7390
|
|
6
|
+
graphify_php/resolver.py,sha256=NXDq_Qw4jN4YkYFcZPt6RyW06_vh4ycAHISuZuC_Un0,22249
|
|
7
|
+
graphify_php/shrink.py,sha256=QTT3ECHTFb4Arzsx5m0WdRrIiFfGzgTU0WDvGaA0n1Q,16114
|
|
8
|
+
graphify_php/shrink_cli.py,sha256=uDdkRikil8NHAiaqkVSigpg2bqpU75aYIJCmJykCrvk,5085
|
|
9
|
+
graphify_php/status.py,sha256=5-WMfwNzBtpdQe0olTKI006N-OOAvPfGr7KaujeVVLA,5804
|
|
10
|
+
graphify_php/config/__init__.py,sha256=H1qLCXTkLCkBmmDslviNgdDI-e1Iwa45eRmaKxtJ27E,1709
|
|
11
|
+
graphify_php/config/class_lookup.py,sha256=FJOIfXPQFfFmgyqRRojVug2-7sL5nKz8F-TYzhbNjnQ,3808
|
|
12
|
+
graphify_php/config/discovery.py,sha256=rnrQDWEK-d_gqfwOlg8eLI5ogaHfMWilDt988OPZxbo,2198
|
|
13
|
+
graphify_php/config/document.py,sha256=a2zFdO1FK0CDXQvzhNHXI1tOETC1azz0eArLc5ivVJc,1180
|
|
14
|
+
graphify_php/config/emit.py,sha256=BbMFHAhE7ZgD3gFJOQU-_fH40gv_xb9N9P8--qHJ0Y8,4975
|
|
15
|
+
graphify_php/config/environment.py,sha256=LA2VfQIKYxheYvdzwYU3Ejj2cnrnsaSGC9anEblrVWA,4737
|
|
16
|
+
graphify_php/config/facts.py,sha256=jGYWz5QlWJs5_WUIMlSw3FkD9Wa1ZLgZ_s-m9y_0iCY,3121
|
|
17
|
+
graphify_php/config/messaging.py,sha256=Mpv6Ip2GNRxGkx5Ey_c1HiXrR8fK34jj2u0Yv2HOA3E,5901
|
|
18
|
+
graphify_php/config/ports.py,sha256=T5HzCkpaS7mJ6Zq4YW6D6e9T4LLsNiHmPWgqqUaZYR0,915
|
|
19
|
+
graphify_php/config/safe_yaml.py,sha256=tIx1HtPrMkJ6s9m75giCZopRGVBFCqJLsFEng5fadak,5598
|
|
20
|
+
graphify_php/container/__init__.py,sha256=dv1EgzTd1vC7IotzA80rBesm-nu9NlG73p1nhnF04xc,1639
|
|
21
|
+
graphify_php/container/facts.py,sha256=GMEFdR91pycOccKdFgggj8ipyXO_3xSr2hsIWPIVI10,7441
|
|
22
|
+
graphify_php/container/layer.py,sha256=dZ8vCWbNdIrnKwsgRZfdq2OCfxwJiGNGZLHyfYKbX2I,3013
|
|
23
|
+
graphify_php/container/locator.py,sha256=whBDvP_oJ1deYTrxMjVw1W65ogdJjGQcT-kwahvtcjo,3075
|
|
24
|
+
graphify_php/container/overlay.py,sha256=nw8ezCW8eVmfYXGtOFM2hoM4_qj-sFCiPl5n3qpfs5I,6452
|
|
25
|
+
graphify_php/container/ports.py,sha256=fqzT-08PbsYJwfDN0jiUE3FWWvdAKSHXja8Mr94NPZ0,1447
|
|
26
|
+
graphify_php/container/reader.py,sha256=Y4C3VDFshmkqw2SksBG8WNxoaPU1bYF7Nm7B1Eo9JAI,14223
|
|
27
|
+
graphify_php/container/reasons.py,sha256=Hdz7PUJH3QIqUWJWIihHKWWqOZxefuy8OXWWEaYErF8,1516
|
|
28
|
+
graphify_php/sources/phpstan_source.py,sha256=peueo3NLJBxmLREcfmoFc2yk8PSw-ue5kxoMoxkEvBU,25686
|
|
29
|
+
graphify_php/sources/route_source.py,sha256=yRF7p1-9tuzaUjgO6fPuQUyhihFtXsFo6bci46oYbqQ,4402
|
|
30
|
+
graphify_php/sources/tree_sitter_source.py,sha256=mBCovpIB6kq8ED6qxggjtzd_CaBFm6CZET42uTwOWas,20154
|
|
31
|
+
graphify_php/syntax/call_sites.py,sha256=T4PAeDVDLad-oKb66IFNuSDRc4D6gQueCmy8v3nktAs,4863
|
|
32
|
+
graphify_php/syntax/class_index.py,sha256=JCISHN86mrYPnhHwcXc7zeMDSKWYzmhetsLW7xHzjSY,4991
|
|
33
|
+
graphify_php/syntax/collectors.py,sha256=E7BineMX1TD7caI02SVqY4PznOvQ1v-Clng5zrOt2R0,14668
|
|
34
|
+
graphify_php/syntax/facts.py,sha256=nvH3g9fwHx_I3DCBbcppL458V1gUuPYfjMvre-uPoLQ,7058
|
|
35
|
+
graphify_php/syntax/file_scan.py,sha256=KVgYGDOIkT1lg2exS7pRKLOY-jMxreniQT9250S6ofM,6807
|
|
36
|
+
graphify_php/syntax/names.py,sha256=eEhTP1mzIzlZZsLDUIGA0B1OnaZ-ykaTeuybZqw13NE,1932
|
|
37
|
+
graphify_php/syntax/nodes.py,sha256=N5FTloxczzuHi3Z7OyxlsCGA2hMS9JlhT7mBU93sF4o,7071
|
|
38
|
+
graphify_php/syntax/routes.py,sha256=oNCThLjRlmtD-dPLZfVyCTTIZzo23ZYCpWVqqEWmg_Q,11307
|
|
39
|
+
graphify_php/php/README.md,sha256=kYNfc5A5i2xT-zY97RTJiy9HOJOcTlFNWRtYxV4jdLo,5267
|
|
40
|
+
graphify_php/php/autoload.php,sha256=Boy0ncV9-YgSlqw1RFPKmgI43UfH0WNF-7SeJkz0hfU,673
|
|
41
|
+
graphify_php/php/src/CallGraph/CallEdge.php,sha256=CuX4y0xNbBuqpSZMEsrvCXosDKPF4RX314S1qrhDKJo,3130
|
|
42
|
+
graphify_php/php/src/CallGraph/CallEdgeRule.php,sha256=CrGqil6sQwYhdpIC5zEGALxZG63I7MLsLIisWgpBehM,1739
|
|
43
|
+
graphify_php/php/src/CallGraph/CallEdgeWriter.php,sha256=J0BqKmc8d9y1ffFSItSNw-KFrb46fspKBHBAOLneRfs,432
|
|
44
|
+
graphify_php/php/src/CallGraph/JsonLinesWriter.php,sha256=JgbVFse96vac__70ZSHKWZpv35gYIYyxVuSsgFB2A6U,1675
|
|
45
|
+
graphify_php/php/src/CallGraph/MethodCallCollector.php,sha256=cdlyz_49pD395lqPzAytKj2UQfz2gbURuewMA_Jw2IM,5593
|
|
46
|
+
graphify_php-0.0.5.dist-info/METADATA,sha256=fIpYmHSky1WqOotDhL20ogbORq_mBcDr03uNT6SJuPs,10241
|
|
47
|
+
graphify_php-0.0.5.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
48
|
+
graphify_php-0.0.5.dist-info/entry_points.txt,sha256=ex-KP3uMC0omVy6TCsQxScwhHGiawRtfAvucE3DaeN0,132
|
|
49
|
+
graphify_php-0.0.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
50
|
+
graphify_php-0.0.5.dist-info/RECORD,,
|