graphify-php 0.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. graphify_php/__init__.py +144 -0
  2. graphify_php/cli.py +355 -0
  3. graphify_php/config/__init__.py +47 -0
  4. graphify_php/config/class_lookup.py +85 -0
  5. graphify_php/config/discovery.py +55 -0
  6. graphify_php/config/document.py +29 -0
  7. graphify_php/config/emit.py +117 -0
  8. graphify_php/config/environment.py +108 -0
  9. graphify_php/config/facts.py +97 -0
  10. graphify_php/config/messaging.py +142 -0
  11. graphify_php/config/ports.py +23 -0
  12. graphify_php/config/safe_yaml.py +127 -0
  13. graphify_php/config_resolver.py +164 -0
  14. graphify_php/container/__init__.py +50 -0
  15. graphify_php/container/facts.py +178 -0
  16. graphify_php/container/layer.py +71 -0
  17. graphify_php/container/locator.py +74 -0
  18. graphify_php/container/overlay.py +149 -0
  19. graphify_php/container/ports.py +40 -0
  20. graphify_php/container/reader.py +330 -0
  21. graphify_php/container/reasons.py +31 -0
  22. graphify_php/graph_adapter.py +520 -0
  23. graphify_php/php/README.md +106 -0
  24. graphify_php/php/autoload.php +27 -0
  25. graphify_php/php/src/CallGraph/CallEdge.php +91 -0
  26. graphify_php/php/src/CallGraph/CallEdgeRule.php +61 -0
  27. graphify_php/php/src/CallGraph/CallEdgeWriter.php +19 -0
  28. graphify_php/php/src/CallGraph/JsonLinesWriter.php +64 -0
  29. graphify_php/php/src/CallGraph/MethodCallCollector.php +163 -0
  30. graphify_php/ports.py +174 -0
  31. graphify_php/resolver.py +519 -0
  32. graphify_php/shrink.py +413 -0
  33. graphify_php/shrink_cli.py +150 -0
  34. graphify_php/sources/phpstan_source.py +641 -0
  35. graphify_php/sources/route_source.py +105 -0
  36. graphify_php/sources/tree_sitter_source.py +428 -0
  37. graphify_php/status.py +136 -0
  38. graphify_php/syntax/call_sites.py +117 -0
  39. graphify_php/syntax/class_index.py +121 -0
  40. graphify_php/syntax/collectors.py +351 -0
  41. graphify_php/syntax/facts.py +171 -0
  42. graphify_php/syntax/file_scan.py +158 -0
  43. graphify_php/syntax/names.py +49 -0
  44. graphify_php/syntax/nodes.py +209 -0
  45. graphify_php/syntax/routes.py +288 -0
  46. graphify_php-0.0.5.dist-info/METADATA +218 -0
  47. graphify_php-0.0.5.dist-info/RECORD +50 -0
  48. graphify_php-0.0.5.dist-info/WHEEL +4 -0
  49. graphify_php-0.0.5.dist-info/entry_points.txt +3 -0
  50. graphify_php-0.0.5.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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