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,105 @@
|
|
|
1
|
+
"""Route declarations as a source, beside the call-target one.
|
|
2
|
+
|
|
3
|
+
## Why this is a second port rather than a `CallTargetSource`
|
|
4
|
+
|
|
5
|
+
`CallTargetSource.resolve` returns `CallTarget`s: a call site paired with the class and method
|
|
6
|
+
it reaches. A route declaration is not that shape and cannot be forced into it without lying
|
|
7
|
+
about one end — there is no `CallSite`, nothing calls the controller from inside this codebase,
|
|
8
|
+
and `caller_nid` would have to be invented. `ports.CallSite` says "this package never invents
|
|
9
|
+
node identity"; squeezing a route through it would break exactly that promise.
|
|
10
|
+
|
|
11
|
+
So the protocol below is deliberately separate and deliberately tiny: one method, returning
|
|
12
|
+
declarations. It is defined here rather than in `ports.py` because `ports.py` belongs to
|
|
13
|
+
another agent. **Proposed change, for whoever owns it:** move `RouteDeclarationSource` and
|
|
14
|
+
`RouteResolution` into `ports.py` unchanged and have the resolver discover sources by it, the
|
|
15
|
+
same way it discovers `CallTargetSource`. Nothing here needs to change when that happens —
|
|
16
|
+
this module would import them instead of defining them.
|
|
17
|
+
|
|
18
|
+
## What the resolver has to do with a declaration
|
|
19
|
+
|
|
20
|
+
Each declaration is one node and one edge:
|
|
21
|
+
|
|
22
|
+
- a node keyed by `RouteDeclaration.node_key`, labelled with the path template, carrying the
|
|
23
|
+
name and the HTTP methods;
|
|
24
|
+
- an edge from that node to `NodeIndex.method_node(controller_class, controller_method)` — the
|
|
25
|
+
existing narrow port, which already answers exactly this question and adds no edge when the
|
|
26
|
+
method is not in the graph.
|
|
27
|
+
|
|
28
|
+
A declaration whose `path` is None is still worth a node when `method_path` survived, and the
|
|
29
|
+
resolver should treat `reason` the way it treats `Resolution.unresolved`: a counted gap.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
from ..ports import RouteDeclarationSource # noqa: F401 the port this module implements
|
|
39
|
+
from ..syntax import nodes
|
|
40
|
+
from ..syntax.facts import FileFacts
|
|
41
|
+
from ..syntax.file_scan import scan_source
|
|
42
|
+
from ..syntax.routes import RouteDeclaration
|
|
43
|
+
|
|
44
|
+
UNREADABLE_FILE = "unreadable-file"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RouteResolution:
|
|
49
|
+
"""What a source made of the files it was given."""
|
|
50
|
+
|
|
51
|
+
declarations: list[RouteDeclaration] = field(default_factory=list)
|
|
52
|
+
unresolved: dict[str, int] = field(default_factory=dict)
|
|
53
|
+
files_seen: int = 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def resolved(self) -> list[RouteDeclaration]:
|
|
57
|
+
return [d for d in self.declarations if d.resolved]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TreeSitterRouteSource:
|
|
61
|
+
"""Route declarations read from PHP attributes.
|
|
62
|
+
|
|
63
|
+
A declaration proves that this codebase declares the path and names this controller method
|
|
64
|
+
to serve it. It does not prove that any deployed ingress sends that path here, nor that a
|
|
65
|
+
given environment loads the route — Symfony filters on `env`, and a reverse proxy or a
|
|
66
|
+
different service may answer first. The graph gains "who in this code claims this path",
|
|
67
|
+
which is the question that has no symbol in it and is therefore unanswerable today.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
name = "tree_sitter_routes"
|
|
71
|
+
|
|
72
|
+
def available(self, repo_root: str) -> tuple[bool, str]:
|
|
73
|
+
return nodes.tree_sitter_available()
|
|
74
|
+
|
|
75
|
+
def declarations(self, repo_root: str, files: Iterable[str]) -> RouteResolution:
|
|
76
|
+
resolution = RouteResolution()
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
for path in files:
|
|
79
|
+
full = path if os.path.isabs(path) else os.path.join(repo_root, path)
|
|
80
|
+
full = os.path.realpath(full)
|
|
81
|
+
if full in seen:
|
|
82
|
+
continue
|
|
83
|
+
seen.add(full)
|
|
84
|
+
facts = self._facts(path, full)
|
|
85
|
+
if facts is None:
|
|
86
|
+
_count(resolution, UNREADABLE_FILE)
|
|
87
|
+
continue
|
|
88
|
+
resolution.files_seen += 1
|
|
89
|
+
for declaration in facts.routes:
|
|
90
|
+
resolution.declarations.append(declaration)
|
|
91
|
+
if declaration.reason:
|
|
92
|
+
_count(resolution, declaration.reason)
|
|
93
|
+
return resolution
|
|
94
|
+
|
|
95
|
+
def _facts(self, path: str, full: str) -> FileFacts | None:
|
|
96
|
+
try:
|
|
97
|
+
with open(full, "rb") as handle:
|
|
98
|
+
source = handle.read()
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
return scan_source(path, source)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _count(resolution: RouteResolution, reason: str) -> None:
|
|
105
|
+
resolution.unresolved[reason] = resolution.unresolved.get(reason, 0) + 1
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The parser-backed `CallTargetSource`.
|
|
2
|
+
|
|
3
|
+
Needs nothing but the source text: no `vendor/`, no PHP binary, no composer autoloader. That
|
|
4
|
+
is what makes it the source that always runs, and what bounds what it can prove. Measured on
|
|
5
|
+
one Symfony application before this package existed: 2172 `calls` edges against 3426 callable
|
|
6
|
+
nodes, 24 % of callables with an outgoing call edge and 20 % with an incoming one. Most of the
|
|
7
|
+
missing edges are `$this->someInjectedService->method()`, and the type is written in the same
|
|
8
|
+
file, in the constructor.
|
|
9
|
+
|
|
10
|
+
Every site it cannot prove is counted under a fixed reason instead of guessed at. Six upstream
|
|
11
|
+
issues across four code-graph projects trace their false edges to a name-only fallback — one
|
|
12
|
+
of them after `empty()` was wired to a method named `empty` and `->get()` to an unrelated
|
|
13
|
+
class. A false edge is worse than a missing one, because a missing edge looks missing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
from ..ports import CallSite, CallTarget, Confidence, Resolution
|
|
23
|
+
from ..syntax import nodes
|
|
24
|
+
from ..syntax.class_index import DECLARED, MAGIC, NOT_DECLARED, ClassIndex, index_from_facts
|
|
25
|
+
from ..syntax.collectors import REASSIGNED_LOCAL, UNION_TYPE
|
|
26
|
+
from ..syntax.facts import FileFacts
|
|
27
|
+
from ..syntax.file_scan import scan_source
|
|
28
|
+
from ..syntax.names import NameResolver
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Reason:
|
|
32
|
+
"""The fixed vocabulary of refusals.
|
|
33
|
+
|
|
34
|
+
Constants rather than literals at the call sites: these counts are the work list for the
|
|
35
|
+
next version of this package, and a histogram of near-duplicate free-text strings is not a
|
|
36
|
+
work list.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
DYNAMIC_NAME = "dynamic-name"
|
|
40
|
+
CHAINED_CALL = "chained-call"
|
|
41
|
+
NESTED_PROPERTY = "nested-property-access"
|
|
42
|
+
STATIC_BINDING = "static-binding"
|
|
43
|
+
INDIRECT_DISPATCH = "indirect-dispatch"
|
|
44
|
+
REASSIGNED_LOCAL = REASSIGNED_LOCAL
|
|
45
|
+
UNION_TYPE = UNION_TYPE
|
|
46
|
+
UNSUPPORTED_RECEIVER = "unsupported-receiver"
|
|
47
|
+
UNKNOWN_RECEIVER_TYPE = "unknown-receiver-type"
|
|
48
|
+
UNLOCATABLE_SITE = "unlocatable-call-site"
|
|
49
|
+
UNREADABLE_FILE = "unreadable-file"
|
|
50
|
+
AMBIGUOUS_SITE = "ambiguous-call-site"
|
|
51
|
+
CALL_SITE_NOT_FOUND = "call-site-not-found"
|
|
52
|
+
METHOD_NOT_DECLARED = "method-not-declared"
|
|
53
|
+
AMBIGUOUS_INTERSECTION = "ambiguous-intersection"
|
|
54
|
+
DYNAMIC_SCOPE = "dynamic-scope"
|
|
55
|
+
UNRESOLVABLE_SCOPE = "unresolvable-scope"
|
|
56
|
+
ANONYMOUS_CLASS_SCOPE = "anonymous-class-scope"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Types whose methods dispatch somewhere else at runtime. Resolving `$container->get('x')` to
|
|
60
|
+
# `ContainerInterface::get` is true and useless: the edge that matters goes to whatever the
|
|
61
|
+
# container returns, which is not written at the call site. Reflection is the same shape.
|
|
62
|
+
DISPATCH_BARRIER_TYPES = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"Psr\\Container\\ContainerInterface",
|
|
65
|
+
"Symfony\\Component\\DependencyInjection\\ContainerInterface",
|
|
66
|
+
"Symfony\\Component\\DependencyInjection\\Container",
|
|
67
|
+
"ReflectionClass",
|
|
68
|
+
"ReflectionMethod",
|
|
69
|
+
"ReflectionProperty",
|
|
70
|
+
"ReflectionFunction",
|
|
71
|
+
"ReflectionObject",
|
|
72
|
+
"Closure",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Methods that are a dispatch mechanism rather than a destination.
|
|
77
|
+
DISPATCH_BARRIER_METHODS = frozenset({"__call", "__callStatic", "__invoke", "__get", "__set"})
|
|
78
|
+
|
|
79
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$")
|
|
80
|
+
_THIS_PROPERTY = re.compile(r"^\$this\s*->\s*([A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)$")
|
|
81
|
+
_LOCAL = re.compile(r"^\$([A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)$")
|
|
82
|
+
# `L6` is graphify's own shape (engine.py:6402); the bare number covers the rest.
|
|
83
|
+
_LEADING_LINE = re.compile(r"\s*L?(\d+)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TreeSitterSource:
|
|
87
|
+
"""Resolves `$this->prop->method()` and `$local->method()` from the file's own text."""
|
|
88
|
+
|
|
89
|
+
name = "tree_sitter"
|
|
90
|
+
|
|
91
|
+
def available(self, repo_root: str) -> tuple[bool, str]:
|
|
92
|
+
"""True wherever the parser imports. No `vendor/`, so no repository can be too bare."""
|
|
93
|
+
return nodes.tree_sitter_available()
|
|
94
|
+
|
|
95
|
+
def resolve(
|
|
96
|
+
self,
|
|
97
|
+
repo_root: str,
|
|
98
|
+
sites: Iterable[CallSite],
|
|
99
|
+
corpus: Iterable[str] | None = None,
|
|
100
|
+
) -> Resolution:
|
|
101
|
+
resolution = Resolution(targets=[], unresolved={})
|
|
102
|
+
# One cache for the whole call, keyed by real path, so a file that is both a corpus
|
|
103
|
+
# entry and a site file is parsed once.
|
|
104
|
+
cache: dict[str, FileFacts | None] = {}
|
|
105
|
+
# Built before any site is answered, because "this class declares no such method" is
|
|
106
|
+
# only honest once the files that could have declared it have been read. The same
|
|
107
|
+
# cache serves the sites below, so each file is parsed once.
|
|
108
|
+
by_file: dict[str, list[CallSite]] = {}
|
|
109
|
+
for site in sites:
|
|
110
|
+
by_file.setdefault(site.source_file, []).append(site)
|
|
111
|
+
index = self._corpus_index(repo_root, list(by_file), corpus, cache)
|
|
112
|
+
|
|
113
|
+
for path, file_sites in by_file.items():
|
|
114
|
+
facts = self._facts(repo_root, path, cache)
|
|
115
|
+
if facts is None:
|
|
116
|
+
_count(resolution, Reason.UNREADABLE_FILE, len(file_sites))
|
|
117
|
+
continue
|
|
118
|
+
resolution.files_seen += 1
|
|
119
|
+
for site in file_sites:
|
|
120
|
+
target, reason = self._resolve_site(site, facts, index)
|
|
121
|
+
if target is not None:
|
|
122
|
+
resolution.targets.append(target)
|
|
123
|
+
else:
|
|
124
|
+
_count(resolution, reason, 1)
|
|
125
|
+
return resolution
|
|
126
|
+
|
|
127
|
+
def _corpus_index(
|
|
128
|
+
self,
|
|
129
|
+
repo_root: str,
|
|
130
|
+
site_paths,
|
|
131
|
+
corpus: Iterable[str] | None,
|
|
132
|
+
cache: dict[str, FileFacts | None],
|
|
133
|
+
) -> ClassIndex:
|
|
134
|
+
"""Declarations from every file this run may read, which is what it can disprove with.
|
|
135
|
+
|
|
136
|
+
`corpus` is what the caller parsed for the build; without it only the files carrying
|
|
137
|
+
call sites are read. The difference is not cosmetic: a leaf service class holds no
|
|
138
|
+
unresolved call of its own, so it never appears among the sites, and without a corpus
|
|
139
|
+
whether its methods are known depends on whether some unrelated file happens to have a
|
|
140
|
+
site in it. The site files are always included, because a site file is readable by
|
|
141
|
+
definition and a caller may pass a corpus that does not list it.
|
|
142
|
+
|
|
143
|
+
Never a filesystem walk. The set is bounded by what the build already parsed, so this
|
|
144
|
+
reads nothing the build did not.
|
|
145
|
+
"""
|
|
146
|
+
index = ClassIndex()
|
|
147
|
+
paths = list(site_paths)
|
|
148
|
+
if corpus is not None:
|
|
149
|
+
paths.extend(corpus)
|
|
150
|
+
for path in paths:
|
|
151
|
+
facts = self._facts(repo_root, path, cache)
|
|
152
|
+
if facts is not None:
|
|
153
|
+
index_from_facts(index, facts)
|
|
154
|
+
return index
|
|
155
|
+
|
|
156
|
+
def _facts(
|
|
157
|
+
self, repo_root: str, path: str, cache: dict[str, FileFacts | None]
|
|
158
|
+
) -> FileFacts | None:
|
|
159
|
+
full = path if os.path.isabs(path) else os.path.join(repo_root, path)
|
|
160
|
+
full = os.path.realpath(full)
|
|
161
|
+
if full in cache:
|
|
162
|
+
return cache[full]
|
|
163
|
+
try:
|
|
164
|
+
with open(full, "rb") as handle:
|
|
165
|
+
source = handle.read()
|
|
166
|
+
except OSError:
|
|
167
|
+
cache[full] = None
|
|
168
|
+
return None
|
|
169
|
+
facts = scan_source(path, source)
|
|
170
|
+
cache[full] = facts
|
|
171
|
+
return facts
|
|
172
|
+
|
|
173
|
+
def _resolve_site(
|
|
174
|
+
self, site: CallSite, facts: FileFacts, index: ClassIndex
|
|
175
|
+
) -> tuple[CallTarget | None, str]:
|
|
176
|
+
method = (site.method or "").strip()
|
|
177
|
+
if not _IDENTIFIER.match(method):
|
|
178
|
+
return None, Reason.DYNAMIC_NAME
|
|
179
|
+
if method in DISPATCH_BARRIER_METHODS:
|
|
180
|
+
return None, Reason.INDIRECT_DISPATCH
|
|
181
|
+
|
|
182
|
+
line = line_of(site.source_location)
|
|
183
|
+
if line is None:
|
|
184
|
+
return None, Reason.UNLOCATABLE_SITE
|
|
185
|
+
klass = facts.class_at(line)
|
|
186
|
+
if klass is None:
|
|
187
|
+
return None, Reason.UNLOCATABLE_SITE
|
|
188
|
+
if facts.in_anonymous_class(line):
|
|
189
|
+
# `class_at` finds the enclosing class, but `$this->emails` here is a property of
|
|
190
|
+
# the `new class { ... }` the walker read nothing of, so the enclosing class's
|
|
191
|
+
# `$emails` would be the wrong one.
|
|
192
|
+
return None, Reason.ANONYMOUS_CLASS_SCOPE
|
|
193
|
+
|
|
194
|
+
resolved, reason = self._receiver_target(site, facts, klass, line, method, index)
|
|
195
|
+
if resolved is None and facts.calls.receiver_for(line, method)[1] == 0:
|
|
196
|
+
# Only a line with no member call of this name can be a scoped call. The resolver
|
|
197
|
+
# hands a scoped site its scope text (`self`, `Helper`) as the receiver, which the
|
|
198
|
+
# member path refuses and the scoped path answers. A member call that is there is
|
|
199
|
+
# answered by the member path alone: `Other::foo()` sitting in the argument list
|
|
200
|
+
# of `$x->foo(...)` must not stand in for a receiver that could not be typed.
|
|
201
|
+
resolved, reason = self._scoped_target(facts, klass, line, method)
|
|
202
|
+
if resolved is None:
|
|
203
|
+
return None, reason
|
|
204
|
+
class_fqn, confidence, via = resolved
|
|
205
|
+
|
|
206
|
+
if class_fqn in DISPATCH_BARRIER_TYPES:
|
|
207
|
+
return None, Reason.INDIRECT_DISPATCH
|
|
208
|
+
|
|
209
|
+
# The receiver's type is only half a target. Emitting the call site's method name
|
|
210
|
+
# without checking the class declares it is how a resolver invents a member.
|
|
211
|
+
#
|
|
212
|
+
# Only a class this package actually parsed can be disproved. When the class was never
|
|
213
|
+
# read, the target is emitted: `GraphNodeIndex.method_node` looks the method up across
|
|
214
|
+
# the whole corpus and adds no edge when it finds none, so an unprovable target dies
|
|
215
|
+
# there anyway. Refusing here would drop real edges to buy nothing.
|
|
216
|
+
verdict = index.declares(class_fqn, method)
|
|
217
|
+
if verdict == MAGIC:
|
|
218
|
+
return None, Reason.INDIRECT_DISPATCH
|
|
219
|
+
if verdict == NOT_DECLARED:
|
|
220
|
+
return None, Reason.METHOD_NOT_DECLARED
|
|
221
|
+
|
|
222
|
+
# First-class callable syntax, `$this->emails->reminderSubmitWeek(...)`, produces a
|
|
223
|
+
# Closure rather than invoking anything, so this edge is arguably a reference and not a
|
|
224
|
+
# call. It is emitted as a call deliberately: the question the graph is built to answer
|
|
225
|
+
# is "what breaks if I change this method", and for that the edge is worth more than
|
|
226
|
+
# its absence. The grammar makes no distinction here, so nothing downstream can tell
|
|
227
|
+
# the two apart — which is the cost of the choice, recorded rather than hidden.
|
|
228
|
+
return CallTarget(
|
|
229
|
+
site=site,
|
|
230
|
+
class_fqn=class_fqn,
|
|
231
|
+
method=method,
|
|
232
|
+
confidence=confidence,
|
|
233
|
+
via=f"{TreeSitterSource.name}:{via}",
|
|
234
|
+
), ""
|
|
235
|
+
|
|
236
|
+
def _receiver_target(self, site, facts, klass, line, method, index):
|
|
237
|
+
"""`$this->prop->m()` and `$local->m()`, as (class, confidence, via) or (None, reason)."""
|
|
238
|
+
receiver, reason = self._receiver(site, facts, line, method)
|
|
239
|
+
if receiver is None:
|
|
240
|
+
return None, reason
|
|
241
|
+
kind, key, reason = classify_receiver(receiver)
|
|
242
|
+
if kind is None:
|
|
243
|
+
return None, reason
|
|
244
|
+
|
|
245
|
+
if kind == "self":
|
|
246
|
+
# `$this->m()` is provable when `m` is declared anywhere in an ancestry this run
|
|
247
|
+
# actually read — the class itself, a trait it uses, a parent in a file with call
|
|
248
|
+
# sites. When the owner is in an unread file the answer is not known, and naming
|
|
249
|
+
# this class anyway would put the wrong class on a real edge.
|
|
250
|
+
verdict = index.declares(klass.fqn, method)
|
|
251
|
+
if verdict == DECLARED:
|
|
252
|
+
return (klass.fqn, Confidence.EXTRACTED, "self-receiver"), ""
|
|
253
|
+
if verdict == MAGIC:
|
|
254
|
+
return None, Reason.INDIRECT_DISPATCH
|
|
255
|
+
return None, Reason.UNSUPPORTED_RECEIVER
|
|
256
|
+
|
|
257
|
+
if kind == "property":
|
|
258
|
+
fact, refusal = index.property_fact(klass.fqn, key)
|
|
259
|
+
if refusal:
|
|
260
|
+
return None, refusal
|
|
261
|
+
else:
|
|
262
|
+
scope = klass.method_at(line)
|
|
263
|
+
if scope is None:
|
|
264
|
+
return None, Reason.UNLOCATABLE_SITE
|
|
265
|
+
if key in scope.refusals:
|
|
266
|
+
return None, scope.refusals[key]
|
|
267
|
+
fact = scope.locals.get(key)
|
|
268
|
+
|
|
269
|
+
if fact is None:
|
|
270
|
+
# The type is not in this file. Inherited from a parent class in another file, set
|
|
271
|
+
# by a container, or simply untyped — and any of those could be a class with this
|
|
272
|
+
# short name anywhere in the corpus, which is precisely the match not to make.
|
|
273
|
+
return None, Reason.UNKNOWN_RECEIVER_TYPE
|
|
274
|
+
if fact.is_intersection:
|
|
275
|
+
return self._intersection_target(fact, method, index)
|
|
276
|
+
return (fact.fqn, fact.confidence, fact.via), ""
|
|
277
|
+
|
|
278
|
+
def _intersection_target(self, fact, method: str, index: ClassIndex):
|
|
279
|
+
"""`A&B`: one object that is both, so the constituent declaring the method is the target.
|
|
280
|
+
|
|
281
|
+
This is why an intersection is not a union. With `A|B` the object is one of two things
|
|
282
|
+
and there are two candidate targets, so there is nothing to choose between. With `A&B`
|
|
283
|
+
there is one object, and a method declared in only one constituent names it without
|
|
284
|
+
ambiguity.
|
|
285
|
+
"""
|
|
286
|
+
verdicts = {member: index.declares(member, method) for member in fact.members}
|
|
287
|
+
declaring = [m for m, v in verdicts.items() if v == DECLARED]
|
|
288
|
+
if len(declaring) == 1:
|
|
289
|
+
return (declaring[0], Confidence.EXTRACTED, f"{fact.via}+intersection"), ""
|
|
290
|
+
if len(declaring) > 1:
|
|
291
|
+
# One call, one object, two declarations. Which body runs depends on the concrete
|
|
292
|
+
# class, which the type does not name, so the graph cannot say.
|
|
293
|
+
return None, Reason.AMBIGUOUS_INTERSECTION
|
|
294
|
+
if any(v == MAGIC for v in verdicts.values()):
|
|
295
|
+
return None, Reason.INDIRECT_DISPATCH
|
|
296
|
+
if all(v == NOT_DECLARED for v in verdicts.values()):
|
|
297
|
+
return None, Reason.METHOD_NOT_DECLARED
|
|
298
|
+
# A constituent sits in a file this run never read, so the method may well be there.
|
|
299
|
+
return None, Reason.UNKNOWN_RECEIVER_TYPE
|
|
300
|
+
|
|
301
|
+
def _scoped_target(self, facts, klass, line, method):
|
|
302
|
+
"""`self::`, `parent::`, `static::` and `Class::`, each on its own terms.
|
|
303
|
+
|
|
304
|
+
graphify records the scope text as the callee for these (`engine.py:6147-6152`), so the
|
|
305
|
+
method name is only in the source and the line is the only way back to it.
|
|
306
|
+
"""
|
|
307
|
+
scoped, matches = facts.calls.scope_for(line, method)
|
|
308
|
+
if scoped is None:
|
|
309
|
+
return None, Reason.AMBIGUOUS_SITE if matches > 1 else Reason.CALL_SITE_NOT_FOUND
|
|
310
|
+
if scoped.is_dynamic:
|
|
311
|
+
# `$class::m()`: the class is a runtime value, and no name is written to resolve.
|
|
312
|
+
return None, Reason.DYNAMIC_SCOPE
|
|
313
|
+
|
|
314
|
+
written = scoped.scope
|
|
315
|
+
if written == "self":
|
|
316
|
+
return (klass.fqn, Confidence.EXTRACTED, "self-scope"), ""
|
|
317
|
+
if written == "parent":
|
|
318
|
+
if not klass.parent:
|
|
319
|
+
return None, Reason.UNRESOLVABLE_SCOPE
|
|
320
|
+
# The parent's body is in another file, so whether it declares the method is not a
|
|
321
|
+
# question this file can answer; the class identity is what is proved here.
|
|
322
|
+
return (klass.parent, Confidence.EXTRACTED, "parent-scope"), ""
|
|
323
|
+
if written == "static":
|
|
324
|
+
# Late static binding names the lexical class, but the runtime class may be a
|
|
325
|
+
# subclass that overrides the method, so this edge points at a declaration that
|
|
326
|
+
# may not be the one that executes. INFERRED says so. Subclasses are deliberately
|
|
327
|
+
# not enumerated: that is a closed-world assumption nothing here supports, and it
|
|
328
|
+
# turns one honest edge into one per subclass, all but one of them wrong.
|
|
329
|
+
return (klass.fqn, Confidence.INFERRED, "static-binding"), ""
|
|
330
|
+
|
|
331
|
+
resolver = NameResolver(namespace=facts.namespace, imports=facts.imports)
|
|
332
|
+
target = resolver.resolve(written)
|
|
333
|
+
if not target:
|
|
334
|
+
return None, Reason.UNRESOLVABLE_SCOPE
|
|
335
|
+
return (target, Confidence.EXTRACTED, "class-scope"), ""
|
|
336
|
+
|
|
337
|
+
def _receiver(
|
|
338
|
+
self, site: CallSite, facts: FileFacts, line: int, method: str
|
|
339
|
+
) -> tuple[str | None, str]:
|
|
340
|
+
"""The receiver expression for this site, read from the tree when the site has none.
|
|
341
|
+
|
|
342
|
+
graphify's PHP branch never fills the field — `extractors/engine.py:6152-6157` sets
|
|
343
|
+
`is_member_call` and the callee name and stops, so `engine.py:6403` writes
|
|
344
|
+
`"receiver": swift_receiver or member_receiver` as None for every PHP site. A source
|
|
345
|
+
that trusted the field would resolve nothing in production while passing every test
|
|
346
|
+
that supplies one.
|
|
347
|
+
|
|
348
|
+
A non-empty `site.receiver` still wins, so a later graphify that does fill it is used
|
|
349
|
+
rather than second-guessed.
|
|
350
|
+
"""
|
|
351
|
+
given = (site.receiver or "").strip()
|
|
352
|
+
if given:
|
|
353
|
+
return given, ""
|
|
354
|
+
found, matches = facts.calls.receiver_for(line, method)
|
|
355
|
+
if found is not None:
|
|
356
|
+
return found, ""
|
|
357
|
+
if matches > 1:
|
|
358
|
+
# `$a->run(); $b->run();` on one line: two receivers, two classes, and the
|
|
359
|
+
# location is not precise enough to say which. Picking either is a coin toss.
|
|
360
|
+
return None, Reason.AMBIGUOUS_SITE
|
|
361
|
+
return None, Reason.CALL_SITE_NOT_FOUND
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def classify_receiver(receiver: str) -> tuple[str | None, str, str]:
|
|
365
|
+
"""Read a receiver expression as ("property"|"local", name, "") or (None, "", reason).
|
|
366
|
+
|
|
367
|
+
Ordered so that the refusals are decided before any shape is accepted. A receiver that
|
|
368
|
+
almost matches `$this->prop` is not a `$this->prop`, and treating it as one is how an
|
|
369
|
+
unrelated class ends up on the other end of an edge.
|
|
370
|
+
"""
|
|
371
|
+
text = (receiver or "").strip()
|
|
372
|
+
if not text:
|
|
373
|
+
return None, "", Reason.UNSUPPORTED_RECEIVER
|
|
374
|
+
if "(" in text:
|
|
375
|
+
# `$this->a->b()` as a receiver means the target is the return type of `b()`, which
|
|
376
|
+
# this version does not track.
|
|
377
|
+
return None, "", Reason.CHAINED_CALL
|
|
378
|
+
if text == "static" or text.startswith("static::"):
|
|
379
|
+
# Late static binding names the declaration, not the runtime class.
|
|
380
|
+
return None, "", Reason.STATIC_BINDING
|
|
381
|
+
if "::" in text:
|
|
382
|
+
return None, "", Reason.UNSUPPORTED_RECEIVER
|
|
383
|
+
if "->" in text:
|
|
384
|
+
head, _, rest = text.partition("->")
|
|
385
|
+
if head.strip() != "$this":
|
|
386
|
+
return None, "", Reason.NESTED_PROPERTY
|
|
387
|
+
if "->" in rest:
|
|
388
|
+
return None, "", Reason.NESTED_PROPERTY
|
|
389
|
+
if rest.strip().startswith("$") or rest.strip().startswith("{"):
|
|
390
|
+
return None, "", Reason.DYNAMIC_NAME
|
|
391
|
+
match = _THIS_PROPERTY.match(text)
|
|
392
|
+
if match is None:
|
|
393
|
+
return None, "", Reason.UNSUPPORTED_RECEIVER
|
|
394
|
+
return "property", match.group(1), ""
|
|
395
|
+
if text.startswith("$$"):
|
|
396
|
+
return None, "", Reason.DYNAMIC_NAME
|
|
397
|
+
match = _LOCAL.match(text)
|
|
398
|
+
if match is None:
|
|
399
|
+
return None, "", Reason.UNSUPPORTED_RECEIVER
|
|
400
|
+
if match.group(1) == "this":
|
|
401
|
+
return "self", "", ""
|
|
402
|
+
return "local", match.group(1), ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def line_of(location: str) -> int | None:
|
|
406
|
+
"""The 1-based line from whatever graphify put in `source_location`.
|
|
407
|
+
|
|
408
|
+
graphify writes `L<line>` — `extractors/engine.py:6402` builds it as
|
|
409
|
+
`f"L{node.start_point[0] + 1}"` — so the `L` prefix is the shape that actually arrives and
|
|
410
|
+
is stripped here. The others (`12`, `12:4`, `path.php:12`, `12-30`) are tolerated because
|
|
411
|
+
the field is a plain string in `ports` and this package does not own its format.
|
|
412
|
+
|
|
413
|
+
The first number-leading segment wins, so `12:4` yields the line rather than the column,
|
|
414
|
+
and path segments are skipped rather than mined for digits.
|
|
415
|
+
"""
|
|
416
|
+
if not location:
|
|
417
|
+
return None
|
|
418
|
+
for part in location.split(":"):
|
|
419
|
+
if "/" in part or "." in part:
|
|
420
|
+
continue
|
|
421
|
+
match = _LEADING_LINE.match(part)
|
|
422
|
+
if match:
|
|
423
|
+
return int(match.group(1))
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _count(resolution: Resolution, reason: str, count: int) -> None:
|
|
428
|
+
resolution.unresolved[reason] = resolution.unresolved.get(reason, 0) + count
|
graphify_php/status.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Build-scoped status record for every resolver this package registers.
|
|
2
|
+
|
|
3
|
+
Why this exists before any resolution code. graphify runs registered resolvers like this:
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
resolver.resolve(per_file, all_nodes, all_edges)
|
|
7
|
+
except Exception as exc:
|
|
8
|
+
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
|
9
|
+
|
|
10
|
+
A resolver that raises is skipped, the build succeeds, and the graph is published without its
|
|
11
|
+
edges. A resolver that is never registered — because the seam moved in an upgrade — behaves
|
|
12
|
+
identically. Both failures look exactly like a codebase that happens to have few calls, which
|
|
13
|
+
is the shape of the defect this package exists to fix, so the failure would be invisible
|
|
14
|
+
precisely where it matters.
|
|
15
|
+
|
|
16
|
+
The log line is not a usable signal: it goes to graphify's logger, not to whatever runs the
|
|
17
|
+
build. So each resolver writes its own record here, and `graphify-php-build` refuses to publish
|
|
18
|
+
a graph whose expected resolvers did not complete.
|
|
19
|
+
|
|
20
|
+
The record separates three outcomes on purpose:
|
|
21
|
+
|
|
22
|
+
- `completed` — ran, and the counters say what it did.
|
|
23
|
+
- `skipped` — ran and decided there was nothing to do, with a reason. A repository with no
|
|
24
|
+
PHPStan in `vendor/` is a normal skip, not a fault.
|
|
25
|
+
- `failed` — raised. The build must not publish.
|
|
26
|
+
|
|
27
|
+
Counters are per resolver and deliberately include the denominator (`eligible`), because
|
|
28
|
+
"resolved 400 calls" means nothing without "out of how many", and a regression that halves
|
|
29
|
+
coverage keeps producing a large, healthy-looking number.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import asdict, dataclass, field
|
|
38
|
+
from importlib.metadata import PackageNotFoundError
|
|
39
|
+
from importlib.metadata import version as _package_version
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
# The build writes here; the wrapper reads it. An environment variable rather than a fixed
|
|
43
|
+
# path because one machine indexes ~20 repositories and their builds must not share a file.
|
|
44
|
+
STATUS_PATH_ENV = "GRAPHIFY_PHP_STATUS"
|
|
45
|
+
DEFAULT_STATUS_PATH = "graphify-out/.graphify-php-status.json"
|
|
46
|
+
|
|
47
|
+
# Distribution names, not import names: graphify is published on PyPI as `graphifyy`.
|
|
48
|
+
PRODUCER_NAME = "graphify-php"
|
|
49
|
+
GRAPHIFY_DISTRIBUTION = "graphifyy"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def installed_version(distribution: str) -> str:
|
|
53
|
+
"""Installed version of `distribution`, or "unknown" when it carries no metadata."""
|
|
54
|
+
try:
|
|
55
|
+
return _package_version(distribution)
|
|
56
|
+
except (PackageNotFoundError, ValueError):
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ResolverStatus:
|
|
62
|
+
name: str
|
|
63
|
+
state: str = "started" # started | completed | skipped | failed
|
|
64
|
+
reason: str = "" # why it skipped, or the exception text
|
|
65
|
+
files_seen: int = 0
|
|
66
|
+
eligible_sites: int = 0 # member calls this resolver could have resolved
|
|
67
|
+
resolved_sites: int = 0 # ... and did
|
|
68
|
+
edges_added: int = 0
|
|
69
|
+
# Target records bound to an external stub node instead of being dropped as
|
|
70
|
+
# `target_not_in_graph`, and the stub nodes and edges that produced. Zero with the flag off.
|
|
71
|
+
external_stubbed: int = 0
|
|
72
|
+
stub_nodes_added: int = 0
|
|
73
|
+
stub_edges_added: int = 0
|
|
74
|
+
# reason -> count. The unit differs by reason: source reasons count sites, `probe:` reasons
|
|
75
|
+
# count candidate names, `scoped_method_*` count scoped groups, and the graph-lookup reasons
|
|
76
|
+
# (`target_not_in_graph`, `self_call`) count target records, so a two-arm site can appear twice.
|
|
77
|
+
unresolved: dict = field(default_factory=dict)
|
|
78
|
+
started_at: float = field(default_factory=time.time)
|
|
79
|
+
finished_at: float = 0.0
|
|
80
|
+
|
|
81
|
+
def unresolvable(self, reason: str, count: int = 1) -> None:
|
|
82
|
+
"""Record a call site left alone, and why.
|
|
83
|
+
|
|
84
|
+
Reasons are a fixed vocabulary in the resolvers, never free text: they are the work
|
|
85
|
+
list for the next version, and a histogram of unique strings is not a work list.
|
|
86
|
+
"""
|
|
87
|
+
self.unresolved[reason] = self.unresolved.get(reason, 0) + count
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def status_path() -> Path:
|
|
91
|
+
return Path(os.environ.get(STATUS_PATH_ENV) or DEFAULT_STATUS_PATH)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write(records: list[ResolverStatus]) -> Path:
|
|
95
|
+
"""Write the build record, merging with anything already there.
|
|
96
|
+
|
|
97
|
+
Merging rather than overwriting because resolvers run one after another and each writes as
|
|
98
|
+
it finishes; a crash after the second of three still leaves the first two on disk, which is
|
|
99
|
+
what makes the failure diagnosable.
|
|
100
|
+
"""
|
|
101
|
+
path = status_path()
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
existing: dict[str, dict] = {}
|
|
104
|
+
if path.exists():
|
|
105
|
+
# This runs inside the resolver's `finally`. A file that is not a record — a list,
|
|
106
|
+
# a row without a name — must not raise here and replace the resolver's own exception.
|
|
107
|
+
try:
|
|
108
|
+
previous = json.loads(path.read_text())
|
|
109
|
+
rows = previous.get("resolvers", []) if isinstance(previous, dict) else []
|
|
110
|
+
existing = {
|
|
111
|
+
r["name"]: r for r in rows if isinstance(r, dict) and isinstance(r.get("name"), str)
|
|
112
|
+
}
|
|
113
|
+
except (ValueError, OSError, TypeError, AttributeError, KeyError):
|
|
114
|
+
existing = {}
|
|
115
|
+
for record in records:
|
|
116
|
+
existing[record.name] = asdict(record)
|
|
117
|
+
payload = {
|
|
118
|
+
"schema": 1,
|
|
119
|
+
"written_at": time.time(),
|
|
120
|
+
# Which build of this package wrote the record, so a reader can tell an upgrade apart.
|
|
121
|
+
"producer": {"name": PRODUCER_NAME, "version": installed_version(PRODUCER_NAME)},
|
|
122
|
+
"resolvers": list(existing.values()),
|
|
123
|
+
}
|
|
124
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read() -> dict:
|
|
129
|
+
path = status_path()
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return {}
|
|
132
|
+
try:
|
|
133
|
+
record = json.loads(path.read_text())
|
|
134
|
+
except (ValueError, OSError):
|
|
135
|
+
return {}
|
|
136
|
+
return record if isinstance(record, dict) else {}
|