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,641 @@
|
|
|
1
|
+
"""The PHPStan-backed `CallTargetSource`.
|
|
2
|
+
|
|
3
|
+
Why a second source at all. The parser in `php_types` reads what the code states: a promoted
|
|
4
|
+
constructor parameter, a typed property, a `new` binding. PHPStan reads what the code *means* —
|
|
5
|
+
a `@var` docblock on an untyped property, the return type of an intermediate call in a chain,
|
|
6
|
+
a local whose type came from a parameter three statements up. Measured on the reference Symfony
|
|
7
|
+
application before either source existed: 2172 `calls` edges against 3426 callable nodes, 24 %
|
|
8
|
+
of callables with an outgoing edge. The chain case alone is a large part of that gap and the
|
|
9
|
+
parser is not allowed to guess at it.
|
|
10
|
+
|
|
11
|
+
Why it must be optional. Nine of the twenty repositories indexed on this machine have no
|
|
12
|
+
PHPStan at all. `available()` therefore answers with a reason instead of raising, and the build
|
|
13
|
+
records a skip and carries on; a source that cannot run is not a source that failed.
|
|
14
|
+
|
|
15
|
+
What PHPStan does not do, checked against 2.2.8 rather than assumed: it does **not** infer the
|
|
16
|
+
type of an untyped property from its constructor assignment. It reports `missingType.property`
|
|
17
|
+
and types the receiver as `mixed`, so such a call arrives here unresolved and is counted as
|
|
18
|
+
`phpstan-receiver-not-typed`. The parser source is what covers that case.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import signal
|
|
28
|
+
import subprocess
|
|
29
|
+
import tempfile
|
|
30
|
+
from collections.abc import Iterable
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from graphify_php.ports import CallSite, CallTarget, Confidence, Resolution
|
|
35
|
+
|
|
36
|
+
_BINARY = "vendor/bin/phpstan"
|
|
37
|
+
|
|
38
|
+
# PHPStan's own precedence, so a project with both a committed and a local config gets the one
|
|
39
|
+
# PHPStan itself would pick.
|
|
40
|
+
_CONFIG_NAMES = ("phpstan.neon", "phpstan.neon.dist", "phpstan.dist.neon")
|
|
41
|
+
|
|
42
|
+
_TIMEOUT_ENV = "GRAPHIFY_PHP_PHPSTAN_TIMEOUT"
|
|
43
|
+
_DEFAULT_TIMEOUT_S = 900.0
|
|
44
|
+
_MEMORY_LIMIT = "1G"
|
|
45
|
+
|
|
46
|
+
# Fixed vocabulary. These strings are a work list for the next version, so they never carry a
|
|
47
|
+
# variable in them — a histogram of unique strings cannot be worked through.
|
|
48
|
+
REASON_UNAVAILABLE = "phpstan-unavailable"
|
|
49
|
+
REASON_TIMEOUT = "phpstan-timeout"
|
|
50
|
+
REASON_RUN_FAILED = "phpstan-run-failed"
|
|
51
|
+
REASON_NO_OUTPUT = "phpstan-no-output"
|
|
52
|
+
REASON_MALFORMED = "phpstan-malformed-record"
|
|
53
|
+
REASON_NOT_TYPED = "phpstan-receiver-not-typed"
|
|
54
|
+
# Two calls of one method on one line share a site key. Without `callPos` their records look
|
|
55
|
+
# like the arms of a union receiver; with it, calls that prove different targets are refused.
|
|
56
|
+
REASON_SAME_LINE_COLLISION = "same-line-collision"
|
|
57
|
+
# The wrapper is written into the run's own temporary directory, so this fires only when that
|
|
58
|
+
# directory is unwritable or the project config vanished mid-build — never for an ordinary
|
|
59
|
+
# project that simply does not register the collector itself.
|
|
60
|
+
REASON_NO_CONFIG = "phpstan-config-unavailable"
|
|
61
|
+
|
|
62
|
+
# The two classes the project's own config must register. Without them PHPStan runs, analyses
|
|
63
|
+
# the whole tree and writes nothing, which is indistinguishable from a project whose receivers
|
|
64
|
+
# are all untyped — the one failure the reason vocabulary could not name.
|
|
65
|
+
_COLLECTOR_CLASS = "CallGraph\\MethodCallCollector"
|
|
66
|
+
_RULE_CLASS = "CallGraph\\CallEdgeRule"
|
|
67
|
+
|
|
68
|
+
# A config may `includes:` another, so the registration is looked for across the closure rather
|
|
69
|
+
# than in one file. Both bounds are guards, not tuning: a config that needs more than this is
|
|
70
|
+
# not a config this source can read.
|
|
71
|
+
_MAX_INCLUDE_DEPTH = 8
|
|
72
|
+
_MAX_CONFIG_BYTES = 1 << 20
|
|
73
|
+
|
|
74
|
+
# The registration this source needs, as a config of its own. It is written to a temporary file
|
|
75
|
+
# that `includes:` the project's config, so an adopting repository keeps its phpstan.neon exactly
|
|
76
|
+
# as its CI runs it: the block cannot be committed there anyway, because the CallGraph classes
|
|
77
|
+
# reach PHPStan only through our `--autoload-file` and a plain `vendor/bin/phpstan` would die on
|
|
78
|
+
# "Class 'CallGraph\\JsonLinesWriter' not found" before analysing anything.
|
|
79
|
+
_WRAPPER_NAME = "graphify-php-callgraph.neon"
|
|
80
|
+
_WRAPPER_TEMPLATE = """includes:
|
|
81
|
+
- %s
|
|
82
|
+
|
|
83
|
+
services:
|
|
84
|
+
-
|
|
85
|
+
class: CallGraph\\JsonLinesWriter
|
|
86
|
+
-
|
|
87
|
+
class: CallGraph\\MethodCallCollector
|
|
88
|
+
tags:
|
|
89
|
+
- phpstan.collector
|
|
90
|
+
-
|
|
91
|
+
class: CallGraph\\CallEdgeRule
|
|
92
|
+
tags:
|
|
93
|
+
- phpstan.rules.rule
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# The one exception, and it is a diagnostic rather than a work item: without the first stderr
|
|
97
|
+
# line an unattended failure is indistinguishable from a project that simply has no calls.
|
|
98
|
+
STDERR_PREFIX = "phpstan-stderr: "
|
|
99
|
+
|
|
100
|
+
# That line ends up in the status record, which is committed with the graph. PHPStan runs the
|
|
101
|
+
# project's bootstrap, so a fatal error there can print a DSN or a credential; the value after
|
|
102
|
+
# a secret-looking key and the userinfo of a URL are cut before the line is kept.
|
|
103
|
+
_SECRET_VALUE_RE = re.compile(r"(password|passwd|secret|token|key|dsn)\s*[=:]\s*\S+", re.IGNORECASE)
|
|
104
|
+
_URL_USERINFO_RE = re.compile(r"://[^/@\s]+@")
|
|
105
|
+
|
|
106
|
+
_LINE_RE = re.compile(r"\d+")
|
|
107
|
+
|
|
108
|
+
VIA_CLASS = "phpstan"
|
|
109
|
+
VIA_INTERFACE = "phpstan:interface"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class PhpStanCall:
|
|
114
|
+
"""One record from the collector's output file.
|
|
115
|
+
|
|
116
|
+
The only place in the Python half that knows the PHP half's wire shape. Everything below
|
|
117
|
+
reads attributes, so a key rename on the PHP side is one edit here.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
caller_class: str
|
|
121
|
+
caller_method: str
|
|
122
|
+
callee_class: str
|
|
123
|
+
callee_method: str
|
|
124
|
+
callee_is_interface: bool
|
|
125
|
+
file: str
|
|
126
|
+
line: int
|
|
127
|
+
call_pos: int | None = None
|
|
128
|
+
callee_file: str = ""
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def from_json(raw: object) -> PhpStanCall | None:
|
|
132
|
+
"""Return the call, or None when the record is not one.
|
|
133
|
+
|
|
134
|
+
None rather than an exception: the output is line-delimited precisely so that a run
|
|
135
|
+
killed mid-write still yields every complete record, and a truncated tail line is an
|
|
136
|
+
expected input, not an error.
|
|
137
|
+
"""
|
|
138
|
+
if not isinstance(raw, dict):
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
callee_class = str(raw["calleeClass"])
|
|
142
|
+
callee_method = str(raw["calleeMethod"])
|
|
143
|
+
file = str(raw["file"])
|
|
144
|
+
line = int(raw["line"])
|
|
145
|
+
except (KeyError, TypeError, ValueError):
|
|
146
|
+
return None
|
|
147
|
+
if not callee_class or not callee_method:
|
|
148
|
+
return None
|
|
149
|
+
call_pos = raw.get("callPos")
|
|
150
|
+
if isinstance(call_pos, bool) or not isinstance(call_pos, int):
|
|
151
|
+
call_pos = None
|
|
152
|
+
return PhpStanCall(
|
|
153
|
+
caller_class=str(raw.get("callerClass", "")),
|
|
154
|
+
caller_method=str(raw.get("callerMethod", "")),
|
|
155
|
+
callee_class=callee_class,
|
|
156
|
+
callee_method=callee_method,
|
|
157
|
+
callee_is_interface=raw.get("calleeKind") == "interface",
|
|
158
|
+
file=file,
|
|
159
|
+
line=line,
|
|
160
|
+
call_pos=call_pos,
|
|
161
|
+
callee_file=str(raw.get("calleeFile") or ""),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def via(self) -> str:
|
|
166
|
+
"""An interface target names a contract, not a body.
|
|
167
|
+
|
|
168
|
+
The consumer has to be able to tell the two apart: an edge to `MailerInterface::send`
|
|
169
|
+
points at a signature with no code behind it, and treating it as a call into an
|
|
170
|
+
implementation is how a call graph acquires a body that was never executed.
|
|
171
|
+
"""
|
|
172
|
+
if self.callee_is_interface:
|
|
173
|
+
return VIA_INTERFACE
|
|
174
|
+
return VIA_CLASS
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class RunOutcome:
|
|
179
|
+
"""What the subprocess did, separated from what it produced."""
|
|
180
|
+
|
|
181
|
+
ok: bool
|
|
182
|
+
reason: str = ""
|
|
183
|
+
stderr_line: str = ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class EnvironmentProbe:
|
|
187
|
+
"""Answers whether PHPStan can run in a given checkout, and nothing else."""
|
|
188
|
+
|
|
189
|
+
def check(self, repo_root: str) -> tuple[bool, str]:
|
|
190
|
+
ok, _code, message = self.inspect(repo_root)
|
|
191
|
+
return ok, message
|
|
192
|
+
|
|
193
|
+
def inspect(self, repo_root: str) -> tuple[bool, str, str]:
|
|
194
|
+
"""`check` plus the fixed reason code, so a caller can count what stopped it.
|
|
195
|
+
|
|
196
|
+
`check` answers a human; the histogram needs a constant. Splitting them here keeps the
|
|
197
|
+
`available()` port signature untouched and keeps the two answers from drifting apart.
|
|
198
|
+
"""
|
|
199
|
+
root = Path(repo_root)
|
|
200
|
+
if not (root / _BINARY).is_file():
|
|
201
|
+
return False, REASON_UNAVAILABLE, f"no {_BINARY} in {repo_root}"
|
|
202
|
+
config = self.config_path(repo_root)
|
|
203
|
+
if config is None:
|
|
204
|
+
return False, REASON_UNAVAILABLE, f"no {' or '.join(_CONFIG_NAMES)} in {repo_root}"
|
|
205
|
+
if shutil.which("php") is None:
|
|
206
|
+
return False, REASON_UNAVAILABLE, "no php executable on PATH"
|
|
207
|
+
return True, "", ""
|
|
208
|
+
|
|
209
|
+
def config_path(self, repo_root: str) -> Path | None:
|
|
210
|
+
for name in _CONFIG_NAMES:
|
|
211
|
+
candidate = Path(repo_root) / name
|
|
212
|
+
if candidate.is_file():
|
|
213
|
+
return candidate
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def missing_wiring(self, config: Path) -> tuple[str, ...]:
|
|
217
|
+
"""Which of the two required classes the config closure never names.
|
|
218
|
+
|
|
219
|
+
PHPStan builds its rules from the config alone: `--autoload-file` makes the classes
|
|
220
|
+
loadable, it does not register them. An unregistered collector produces a clean exit,
|
|
221
|
+
an empty output file and a full-length run, so this is answered before launching rather
|
|
222
|
+
than inferred from the silence afterwards.
|
|
223
|
+
|
|
224
|
+
A project that registers them itself is run on its own config untouched. Everything else
|
|
225
|
+
is run on a generated wrapper, so the answer here picks a path and never refuses one:
|
|
226
|
+
registering twice would attach two collectors and duplicate every record.
|
|
227
|
+
"""
|
|
228
|
+
text = _without_comments(self._config_closure(config))
|
|
229
|
+
return tuple(name for name in (_COLLECTOR_CLASS, _RULE_CLASS) if name not in text)
|
|
230
|
+
|
|
231
|
+
def _config_closure(self, config: Path) -> str:
|
|
232
|
+
"""Every config file reachable from this one through `includes:`, concatenated.
|
|
233
|
+
|
|
234
|
+
Registration is often kept in a separate neon that the project's own config pulls in,
|
|
235
|
+
so looking only at the entry file would report a wired project as unwired.
|
|
236
|
+
"""
|
|
237
|
+
seen: set[Path] = set()
|
|
238
|
+
pending: list[tuple[Path, int]] = [(config, 0)]
|
|
239
|
+
chunks: list[str] = []
|
|
240
|
+
while pending:
|
|
241
|
+
current, depth = pending.pop()
|
|
242
|
+
try:
|
|
243
|
+
resolved = current.resolve()
|
|
244
|
+
except OSError:
|
|
245
|
+
continue
|
|
246
|
+
if resolved in seen or depth > _MAX_INCLUDE_DEPTH:
|
|
247
|
+
continue
|
|
248
|
+
seen.add(resolved)
|
|
249
|
+
text = _read_config(resolved)
|
|
250
|
+
if not text:
|
|
251
|
+
continue
|
|
252
|
+
chunks.append(text)
|
|
253
|
+
for include in _includes(text):
|
|
254
|
+
pending.append(((resolved.parent / include), depth + 1))
|
|
255
|
+
return "\n".join(chunks)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def extension_autoload_file() -> Path | None:
|
|
259
|
+
"""The PSR-4 registrar for the PHP collector, shipped beside this package.
|
|
260
|
+
|
|
261
|
+
Passed to PHPStan as `-a`. `parameters.bootstrapFiles` does not work for this and the
|
|
262
|
+
failure is not obvious: PHPStan builds its DI container before bootstrap files run, so the
|
|
263
|
+
services block fails with "Class 'CallGraph\\JsonLinesWriter' not found". Verified against
|
|
264
|
+
PHPStan 2.2.8.
|
|
265
|
+
"""
|
|
266
|
+
# The wheel ships php/ inside the package; a checkout keeps it next to src/.
|
|
267
|
+
here = Path(__file__).resolve()
|
|
268
|
+
for candidate in (
|
|
269
|
+
here.parents[1] / "php" / "autoload.php",
|
|
270
|
+
here.parents[3] / "php" / "autoload.php",
|
|
271
|
+
):
|
|
272
|
+
if candidate.is_file():
|
|
273
|
+
return candidate
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class PhpStanRunner:
|
|
278
|
+
"""Launches PHPStan and reports how it went. Owns the subprocess, nothing above it."""
|
|
279
|
+
|
|
280
|
+
def __init__(self, probe: EnvironmentProbe | None = None) -> None:
|
|
281
|
+
self._probe = probe or EnvironmentProbe()
|
|
282
|
+
|
|
283
|
+
def timeout_s(self) -> float:
|
|
284
|
+
raw = os.environ.get(_TIMEOUT_ENV)
|
|
285
|
+
if not raw:
|
|
286
|
+
return _DEFAULT_TIMEOUT_S
|
|
287
|
+
try:
|
|
288
|
+
return float(raw)
|
|
289
|
+
except ValueError:
|
|
290
|
+
return _DEFAULT_TIMEOUT_S
|
|
291
|
+
|
|
292
|
+
def config_for(self, repo_root: str, workdir: Path) -> Path | None:
|
|
293
|
+
"""The config PHPStan is launched with: the project's own, or a wrapper including it.
|
|
294
|
+
|
|
295
|
+
The wrapper lives in the run's temporary directory rather than in the repository, so a
|
|
296
|
+
build leaves no file behind and two builds cannot race over one. PHPStan resolves the
|
|
297
|
+
relative paths inside an included config against THAT config's directory, so the
|
|
298
|
+
project's `paths:`, `excludePaths:` and its own `includes:` keep pointing where they did.
|
|
299
|
+
"""
|
|
300
|
+
project = self._probe.config_path(repo_root)
|
|
301
|
+
if project is None:
|
|
302
|
+
return None
|
|
303
|
+
if not self._probe.missing_wiring(project):
|
|
304
|
+
return project
|
|
305
|
+
wrapper = workdir / _WRAPPER_NAME
|
|
306
|
+
try:
|
|
307
|
+
wrapper.write_text(
|
|
308
|
+
_WRAPPER_TEMPLATE % _neon_string(str(project.resolve())), encoding="utf-8"
|
|
309
|
+
)
|
|
310
|
+
except OSError:
|
|
311
|
+
return None
|
|
312
|
+
return wrapper
|
|
313
|
+
|
|
314
|
+
def command(self, repo_root: str, config: Path) -> list[str]:
|
|
315
|
+
argv = [
|
|
316
|
+
str(Path(repo_root) / _BINARY),
|
|
317
|
+
"analyse",
|
|
318
|
+
"--configuration",
|
|
319
|
+
str(config),
|
|
320
|
+
"--no-progress",
|
|
321
|
+
"--no-interaction",
|
|
322
|
+
f"--memory-limit={_MEMORY_LIMIT}",
|
|
323
|
+
# Findings go to the collector's file; PHPStan's own report is noise here, and the
|
|
324
|
+
# table formatter would render megabytes of it into the captured pipe.
|
|
325
|
+
"--error-format=raw",
|
|
326
|
+
]
|
|
327
|
+
autoload = extension_autoload_file()
|
|
328
|
+
if autoload is not None:
|
|
329
|
+
argv += ["--autoload-file", str(autoload)]
|
|
330
|
+
return argv
|
|
331
|
+
|
|
332
|
+
def run(self, repo_root: str, output_path: Path) -> RunOutcome:
|
|
333
|
+
config = self.config_for(repo_root, output_path.parent)
|
|
334
|
+
if config is None:
|
|
335
|
+
return RunOutcome(False, REASON_NO_CONFIG)
|
|
336
|
+
|
|
337
|
+
env = dict(os.environ)
|
|
338
|
+
env["PHP_CALL_GRAPH_OUT"] = str(output_path)
|
|
339
|
+
|
|
340
|
+
# PHPStan resolves project classes through the composer autoloader, which means this
|
|
341
|
+
# subprocess executes project code at class-load time: attribute constructors, static
|
|
342
|
+
# initialisers, and anything a bootstrap or bootstrapFiles entry does. It is not a pure
|
|
343
|
+
# read of the source tree, so it runs with a timeout and captured output and is never
|
|
344
|
+
# given a terminal to prompt at.
|
|
345
|
+
#
|
|
346
|
+
# Its own session, because the phar re-executes itself and forks parallel workers: a
|
|
347
|
+
# timeout that killed only the direct child left those running to completion, orphaned,
|
|
348
|
+
# on the reference application. The group id is the child's pid, so the whole tree goes.
|
|
349
|
+
try:
|
|
350
|
+
process = subprocess.Popen( # noqa: S603 - list argv, no shell, path from config
|
|
351
|
+
self.command(repo_root, config),
|
|
352
|
+
cwd=repo_root,
|
|
353
|
+
env=env,
|
|
354
|
+
stdin=subprocess.DEVNULL,
|
|
355
|
+
stdout=subprocess.PIPE,
|
|
356
|
+
stderr=subprocess.PIPE,
|
|
357
|
+
text=True,
|
|
358
|
+
start_new_session=True,
|
|
359
|
+
)
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
return RunOutcome(False, REASON_UNAVAILABLE, str(exc).splitlines()[0])
|
|
362
|
+
try:
|
|
363
|
+
_stdout, stderr = process.communicate(timeout=self.timeout_s())
|
|
364
|
+
except subprocess.TimeoutExpired:
|
|
365
|
+
_kill_group(process)
|
|
366
|
+
return RunOutcome(False, REASON_TIMEOUT)
|
|
367
|
+
|
|
368
|
+
stderr_line = _first_line(stderr)
|
|
369
|
+
if process.returncode == 0:
|
|
370
|
+
return RunOutcome(True, "", stderr_line)
|
|
371
|
+
|
|
372
|
+
# A non-zero exit is the normal outcome on any project that has analysis errors of its
|
|
373
|
+
# own, and the collector's file is complete by then regardless. So the exit code alone
|
|
374
|
+
# decides nothing; whether records arrived does. The caller treats an empty file as the
|
|
375
|
+
# source being unavailable for this build rather than as a crash.
|
|
376
|
+
return RunOutcome(False, REASON_RUN_FAILED, stderr_line)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class PhpStanSource:
|
|
380
|
+
"""Resolves call sites by asking PHPStan what the receiver's type is.
|
|
381
|
+
|
|
382
|
+
Confidence is `EXTRACTED` for everything it returns: the type engine proved the receiver,
|
|
383
|
+
it did not derive it from a declaration the way the parser source does.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
name = "phpstan"
|
|
387
|
+
|
|
388
|
+
def __init__(
|
|
389
|
+
self, probe: EnvironmentProbe | None = None, runner: PhpStanRunner | None = None
|
|
390
|
+
) -> None:
|
|
391
|
+
self._probe = probe or EnvironmentProbe()
|
|
392
|
+
self._runner = runner or PhpStanRunner(self._probe)
|
|
393
|
+
|
|
394
|
+
def available(self, repo_root: str) -> tuple[bool, str]:
|
|
395
|
+
return self._probe.check(repo_root)
|
|
396
|
+
|
|
397
|
+
def resolve(self, repo_root: str, sites: Iterable[CallSite]) -> Resolution:
|
|
398
|
+
wanted = list(sites)
|
|
399
|
+
ok, code, _message = self._probe.inspect(repo_root)
|
|
400
|
+
if not ok:
|
|
401
|
+
return Resolution([], {code or REASON_UNAVAILABLE: len(wanted)}, 0)
|
|
402
|
+
if not wanted:
|
|
403
|
+
return Resolution([], {}, 0)
|
|
404
|
+
|
|
405
|
+
with tempfile.TemporaryDirectory(prefix="graphify-php-") as workdir:
|
|
406
|
+
output_path = Path(workdir) / "calls.jsonl"
|
|
407
|
+
outcome = self._runner.run(repo_root, output_path)
|
|
408
|
+
text = _read(output_path)
|
|
409
|
+
|
|
410
|
+
unresolved: dict[str, int] = {}
|
|
411
|
+
if outcome.stderr_line:
|
|
412
|
+
unresolved[STDERR_PREFIX + outcome.stderr_line] = 1
|
|
413
|
+
|
|
414
|
+
calls, malformed = _parse(text)
|
|
415
|
+
if malformed:
|
|
416
|
+
unresolved[REASON_MALFORMED] = malformed
|
|
417
|
+
|
|
418
|
+
if not calls:
|
|
419
|
+
unresolved[_failure_reason(outcome)] = len(wanted)
|
|
420
|
+
return Resolution([], unresolved, 0)
|
|
421
|
+
|
|
422
|
+
targets, collisions = _match(wanted, calls, repo_root)
|
|
423
|
+
if collisions:
|
|
424
|
+
unresolved[REASON_SAME_LINE_COLLISION] = collisions
|
|
425
|
+
# CallSite is frozen and hashable, so the set counts distinct sites that got at
|
|
426
|
+
# least one target; a site with two targets is one site, not two.
|
|
427
|
+
missed = len(wanted) - len({target.site for target in targets}) - collisions
|
|
428
|
+
if missed > 0:
|
|
429
|
+
unresolved[REASON_NOT_TYPED] = missed
|
|
430
|
+
|
|
431
|
+
return Resolution(targets, unresolved, len({call.file for call in calls}))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _failure_reason(outcome: RunOutcome) -> str:
|
|
435
|
+
if outcome.ok:
|
|
436
|
+
return REASON_NO_OUTPUT
|
|
437
|
+
return outcome.reason or REASON_UNAVAILABLE
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _kill_group(process: subprocess.Popen) -> None:
|
|
441
|
+
"""End the session `run` started, then reap.
|
|
442
|
+
|
|
443
|
+
Already gone is the outcome wanted, not an error.
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
447
|
+
except (ProcessLookupError, PermissionError):
|
|
448
|
+
pass
|
|
449
|
+
process.communicate()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _neon_string(value: str) -> str:
|
|
453
|
+
"""`value` as a double-quoted NEON string that means exactly `value`.
|
|
454
|
+
|
|
455
|
+
Unquoted, a `%` in the path is parameter syntax (`%rootDir%`) and a `"` ends nothing it
|
|
456
|
+
should; a doubled `%` is NEON's own escape for a literal one.
|
|
457
|
+
"""
|
|
458
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
459
|
+
return f'"{escaped}"'
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _without_comments(text: str) -> str:
|
|
463
|
+
"""The config with every `#` comment cut, so a commented-out registration registers nothing."""
|
|
464
|
+
return "\n".join(line.split("#", 1)[0] for line in text.splitlines())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _read_config(path: Path) -> str:
|
|
468
|
+
"""A config file's text, or nothing. An unreadable include is a file that registers nothing."""
|
|
469
|
+
try:
|
|
470
|
+
if path.stat().st_size > _MAX_CONFIG_BYTES:
|
|
471
|
+
return ""
|
|
472
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
473
|
+
except OSError:
|
|
474
|
+
return ""
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _includes(text: str) -> list[str]:
|
|
478
|
+
"""The paths under a top-level `includes:` key.
|
|
479
|
+
|
|
480
|
+
Deliberately not a NEON parser: this looks for one key whose values are file paths, and a
|
|
481
|
+
line it misreads costs at most one unvisited include. An entry carrying a NEON placeholder
|
|
482
|
+
(`%rootDir%`) is skipped rather than guessed at — expanding it is PHPStan's job.
|
|
483
|
+
"""
|
|
484
|
+
found: list[str] = []
|
|
485
|
+
inside = False
|
|
486
|
+
for line in text.splitlines():
|
|
487
|
+
stripped = line.strip()
|
|
488
|
+
if not inside:
|
|
489
|
+
if stripped.startswith("includes:"):
|
|
490
|
+
inside = True
|
|
491
|
+
inline = stripped[len("includes:") :].strip()
|
|
492
|
+
found.extend(_include_items(inline))
|
|
493
|
+
continue
|
|
494
|
+
if not stripped or stripped.startswith("#"):
|
|
495
|
+
continue
|
|
496
|
+
if not line[:1].isspace():
|
|
497
|
+
inside = False
|
|
498
|
+
if stripped.startswith("includes:"):
|
|
499
|
+
inside = True
|
|
500
|
+
continue
|
|
501
|
+
if stripped.startswith("-"):
|
|
502
|
+
found.extend(_include_items(stripped[1:]))
|
|
503
|
+
return [item for item in found if item and "%" not in item]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _include_items(raw: str) -> list[str]:
|
|
507
|
+
raw = raw.strip().strip("[]")
|
|
508
|
+
items = []
|
|
509
|
+
for part in raw.split(","):
|
|
510
|
+
part = part.strip().strip("'\"")
|
|
511
|
+
if part:
|
|
512
|
+
items.append(part)
|
|
513
|
+
return items
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _first_line(stderr: str) -> str:
|
|
517
|
+
for line in (stderr or "").splitlines():
|
|
518
|
+
stripped = line.strip()
|
|
519
|
+
if stripped:
|
|
520
|
+
return _redact(stripped)[:200]
|
|
521
|
+
return ""
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _redact(line: str) -> str:
|
|
525
|
+
line = _SECRET_VALUE_RE.sub(r"\1=<redacted>", line)
|
|
526
|
+
return _URL_USERINFO_RE.sub("://<redacted>@", line)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _read(path: Path) -> str:
|
|
530
|
+
try:
|
|
531
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
532
|
+
except OSError:
|
|
533
|
+
return ""
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _parse(text: str) -> tuple[list[PhpStanCall], int]:
|
|
537
|
+
calls: list[PhpStanCall] = []
|
|
538
|
+
malformed = 0
|
|
539
|
+
for line in text.splitlines():
|
|
540
|
+
stripped = line.strip()
|
|
541
|
+
if not stripped:
|
|
542
|
+
continue
|
|
543
|
+
try:
|
|
544
|
+
raw = json.loads(stripped)
|
|
545
|
+
except ValueError:
|
|
546
|
+
malformed += 1
|
|
547
|
+
continue
|
|
548
|
+
call = PhpStanCall.from_json(raw)
|
|
549
|
+
if call is None:
|
|
550
|
+
malformed += 1
|
|
551
|
+
continue
|
|
552
|
+
calls.append(call)
|
|
553
|
+
return calls, malformed
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _site_key(path: str, line: int, method: str, repo_root: str) -> tuple[str, int, str]:
|
|
557
|
+
"""Identity of a call site, agreed between the two halves.
|
|
558
|
+
|
|
559
|
+
File and line, because that is all PHPStan reports and all graphify's site carries. The
|
|
560
|
+
method name is in the key as well so that two different calls on one line — a chain — stay
|
|
561
|
+
apart instead of collapsing into whichever the dictionary saw last.
|
|
562
|
+
"""
|
|
563
|
+
resolved = Path(path)
|
|
564
|
+
if not resolved.is_absolute():
|
|
565
|
+
resolved = Path(repo_root) / resolved
|
|
566
|
+
return (os.path.normpath(str(resolved)), line, method)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _line_of(source_location: str) -> int:
|
|
570
|
+
"""`source_location` is a string in the port, and its format is graphify's to change.
|
|
571
|
+
|
|
572
|
+
The leading integer is the line in every form it has taken so far (`"120"`, `"120:8"`,
|
|
573
|
+
`"120-134"`), so that is what is read, and an unparseable value yields 0 — which matches
|
|
574
|
+
nothing and leaves the site counted as unresolved rather than matched to the wrong call.
|
|
575
|
+
"""
|
|
576
|
+
found = _LINE_RE.search(source_location or "")
|
|
577
|
+
if found is None:
|
|
578
|
+
return 0
|
|
579
|
+
return int(found.group())
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _match(
|
|
583
|
+
sites: list[CallSite], calls: list[PhpStanCall], repo_root: str
|
|
584
|
+
) -> tuple[list[CallTarget], int]:
|
|
585
|
+
"""Targets for each site, and how many sites were refused as same-line collisions.
|
|
586
|
+
|
|
587
|
+
A site key is file, line and method, so `$a->get(); $b->get();` on one line lands in one
|
|
588
|
+
bucket together with the arms of a `Emails|Cache` receiver. The arms share the call's token
|
|
589
|
+
position and the two calls do not. The site is one of the calls and nothing says which, so
|
|
590
|
+
it is emitted only when every call in the bucket proves the same target set: the common
|
|
591
|
+
`$a->getId() === $b->getId()` keeps its edge, and two calls proving different targets are
|
|
592
|
+
refused rather than merged into a union that is not in the code.
|
|
593
|
+
"""
|
|
594
|
+
by_key: dict[tuple[str, int, str], list[PhpStanCall]] = {}
|
|
595
|
+
for call in calls:
|
|
596
|
+
by_key.setdefault(
|
|
597
|
+
_site_key(call.file, call.line, call.callee_method, repo_root), []
|
|
598
|
+
).append(call)
|
|
599
|
+
|
|
600
|
+
# How many sites share a key. `$a->get() ?? $b->get()` with `$b` untyped yields one call
|
|
601
|
+
# position for two sites, and the one record cannot say which of them it belongs to.
|
|
602
|
+
site_keys = [
|
|
603
|
+
_site_key(site.source_file, _line_of(site.source_location), site.method, repo_root)
|
|
604
|
+
for site in sites
|
|
605
|
+
]
|
|
606
|
+
sites_per_key: dict[tuple[str, int, str], int] = {}
|
|
607
|
+
for key in site_keys:
|
|
608
|
+
sites_per_key[key] = sites_per_key.get(key, 0) + 1
|
|
609
|
+
|
|
610
|
+
targets: list[CallTarget] = []
|
|
611
|
+
collisions = 0
|
|
612
|
+
for site, key in zip(sites, site_keys, strict=True):
|
|
613
|
+
matched = by_key.get(key, ())
|
|
614
|
+
arms_by_call: dict[int | None, set[tuple[str, str]]] = {}
|
|
615
|
+
for call in matched:
|
|
616
|
+
arms_by_call.setdefault(call.call_pos, set()).add(
|
|
617
|
+
(call.callee_class, call.callee_method)
|
|
618
|
+
)
|
|
619
|
+
if len({frozenset(arms) for arms in arms_by_call.values()}) > 1:
|
|
620
|
+
collisions += 1
|
|
621
|
+
continue
|
|
622
|
+
if matched and len(arms_by_call) < sites_per_key[key]:
|
|
623
|
+
collisions += 1
|
|
624
|
+
continue
|
|
625
|
+
seen: set[tuple[str, str]] = set()
|
|
626
|
+
for call in matched:
|
|
627
|
+
arm = (call.callee_class, call.callee_method)
|
|
628
|
+
if arm in seen:
|
|
629
|
+
continue
|
|
630
|
+
seen.add(arm)
|
|
631
|
+
targets.append(
|
|
632
|
+
CallTarget(
|
|
633
|
+
site=site,
|
|
634
|
+
class_fqn=call.callee_class,
|
|
635
|
+
method=call.callee_method,
|
|
636
|
+
confidence=Confidence.EXTRACTED,
|
|
637
|
+
via=call.via,
|
|
638
|
+
declaring_file=call.callee_file,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
return targets, collisions
|