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,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 {}