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,171 @@
1
+ """What one PHP file says about the types of its receivers.
2
+
3
+ Scoped per class AND per method rather than flat. Two classes in one file may both declare
4
+ `$repo`, and two methods may both bind a local `$service`; a flat table silently merges them
5
+ and produces an edge to the wrong class — the same failure the short-name fallback produces,
6
+ reached by a different route.
7
+
8
+ A scope records two kinds of answer, and the second is the point of the package: a type it
9
+ knows, and a name it deliberately refuses with a reason. A property that is missing from both
10
+ is a gap; a property in `refusals` is a decision, and the reason travels all the way out to
11
+ `Resolution.unresolved` so the gap becomes a work list.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from ..ports import Confidence
19
+ from .call_sites import CallSiteIndex
20
+ from .routes import RouteDeclaration
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TypeFact:
25
+ """A receiver type, with the evidence that produced it.
26
+
27
+ `precedence` is how two sources claiming the same property are settled without either
28
+ collector knowing the other exists: lowest wins, and the numbers live on the collectors.
29
+ """
30
+
31
+ fqn: str
32
+ confidence: Confidence
33
+ via: str
34
+ precedence: int
35
+ # `A&B`: every class the object is at once. Empty for an ordinary single type. The one
36
+ # that owns the called method is chosen at the call site, not here, because which
37
+ # constituent is the target depends on the method being called.
38
+ members: tuple[str, ...] = ()
39
+
40
+ @property
41
+ def is_intersection(self) -> bool:
42
+ return bool(self.members)
43
+
44
+
45
+ @dataclass
46
+ class MethodScope:
47
+ name: str
48
+ start_line: int
49
+ end_line: int
50
+ locals: dict[str, TypeFact] = field(default_factory=dict)
51
+ refusals: dict[str, str] = field(default_factory=dict)
52
+ # Every local this method writes to, whether or not the write produced a usable type.
53
+ # A second write is what revokes a binding, so the ones that produced nothing still count.
54
+ assigned: set = field(default_factory=set)
55
+
56
+ def record_local(self, name: str, fact: TypeFact) -> None:
57
+ existing = self.locals.get(name)
58
+ if existing is not None and existing.precedence <= fact.precedence:
59
+ return
60
+ self.locals[name] = fact
61
+
62
+ def note_write(self, name: str, reason: str, precedence: int) -> bool:
63
+ """Record a binding of `name`, revoking an earlier one. True when it was the first.
64
+
65
+ Every binding form counts — an assignment, a parameter, a `foreach` target, a `catch`
66
+ variable, a destructuring target — because without flow analysis a second write
67
+ anywhere in the method is the evidence that the first no longer holds at the call.
68
+ """
69
+ rewritten = name in self.assigned
70
+ self.assigned.add(name)
71
+ if rewritten:
72
+ self.refuse_local(name, reason, precedence)
73
+ return not rewritten
74
+
75
+ def refuse_local(self, name: str, reason: str, precedence: int) -> None:
76
+ """Drop whatever was known about a local and say why.
77
+
78
+ Refusal wins over a recorded type of equal or weaker precedence on purpose. The case
79
+ that forces it is a local reassigned after its binding: the earlier `new Emails()` is
80
+ still true at the point it was written and false by the time of the call, and there is
81
+ no flow analysis here to tell the two positions apart. A fact from a strictly stronger
82
+ source survives, so one collector's doubt cannot erase another's declaration.
83
+ """
84
+ existing = self.locals.get(name)
85
+ if existing is not None and existing.precedence < precedence:
86
+ return
87
+ self.locals.pop(name, None)
88
+ self.refusals[name] = reason
89
+
90
+ def contains(self, line: int) -> bool:
91
+ return self.start_line <= line <= self.end_line
92
+
93
+
94
+ @dataclass
95
+ class ClassScope:
96
+ fqn: str
97
+ start_line: int
98
+ end_line: int
99
+ parent: str | None = None
100
+ # extends, implements and `use`d traits, all resolved. One list because a method can be
101
+ # declared in any of them and PHP flattens all three into one class.
102
+ ancestors: list[str] = field(default_factory=list)
103
+ properties: dict[str, TypeFact] = field(default_factory=dict)
104
+ refusals: dict[str, str] = field(default_factory=dict)
105
+ methods: list[MethodScope] = field(default_factory=list)
106
+
107
+ def record_property(self, name: str, fact: TypeFact) -> None:
108
+ if name in self.refusals:
109
+ return
110
+ existing = self.properties.get(name)
111
+ if existing is not None and existing.precedence <= fact.precedence:
112
+ return
113
+ self.properties[name] = fact
114
+
115
+ def refuse_property(self, name: str, reason: str, precedence: int) -> None:
116
+ """See `MethodScope.refuse_local`: a stronger source's fact outranks a weaker doubt."""
117
+ existing = self.properties.get(name)
118
+ if existing is not None and existing.precedence < precedence:
119
+ return
120
+ self.properties.pop(name, None)
121
+ self.refusals[name] = reason
122
+
123
+ def method_at(self, line: int) -> MethodScope | None:
124
+ for method in self.methods:
125
+ if method.contains(line):
126
+ return method
127
+ return None
128
+
129
+ def contains(self, line: int) -> bool:
130
+ return self.start_line <= line <= self.end_line
131
+
132
+
133
+ @dataclass
134
+ class FileFacts:
135
+ """Everything one file proves about itself."""
136
+
137
+ path: str
138
+ namespace: str = ""
139
+ imports: dict[str, str] = field(default_factory=dict)
140
+ classes: list[ClassScope] = field(default_factory=list)
141
+ # The receivers graphify does not send: see `call_sites` for why they are read here.
142
+ calls: CallSiteIndex = field(default_factory=CallSiteIndex)
143
+ # Route attributes declared in this file. Not a type fact, carried here because the file
144
+ # is what owns them and one parse should answer every question about it.
145
+ routes: list[RouteDeclaration] = field(default_factory=list)
146
+ # Line spans of every `new class { ... }`. The walker reads nothing inside one, so a site
147
+ # there has no facts of its own and must not be answered with the enclosing class's.
148
+ anonymous_classes: list[tuple[int, int]] = field(default_factory=list)
149
+
150
+ def in_anonymous_class(self, line: int) -> bool:
151
+ return any(start <= line <= end for start, end in self.anonymous_classes)
152
+
153
+ def class_at(self, line: int) -> ClassScope | None:
154
+ """The innermost class whose body covers `line`.
155
+
156
+ Innermost rather than first because a file may declare several classes and the spans
157
+ are checked, not assumed disjoint.
158
+ """
159
+ best: ClassScope | None = None
160
+ for scope in self.classes:
161
+ if not scope.contains(line):
162
+ continue
163
+ if best is None or scope.start_line >= best.start_line:
164
+ best = scope
165
+ return best
166
+
167
+ def class_named(self, fqn: str) -> ClassScope | None:
168
+ for scope in self.classes:
169
+ if scope.fqn == fqn:
170
+ return scope
171
+ return None
@@ -0,0 +1,158 @@
1
+ """The walker: one pass over one file, driving whatever collectors are registered.
2
+
3
+ This module owns scope and nothing else. It knows that `namespace` and `use` bind names, that
4
+ a class declaration opens a class scope and a method declaration a method scope, and that a
5
+ line number inside the file falls in one of them. It does not know what a type source is, and
6
+ adding a fifth one must not change a line here — that is why the collectors are a registry
7
+ rather than a chain of `elif`s, and why the four that exist are never named in this file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from . import nodes
13
+ from .call_sites import index_calls
14
+ from .collectors import COLLECTORS, ScanContext, by_node_kind
15
+ from .facts import ClassScope, FileFacts, MethodScope
16
+ from .names import NameResolver
17
+ from .routes import collect_routes
18
+
19
+
20
+ def scan_source(path: str, source: bytes, collectors=None) -> FileFacts:
21
+ """Parse one file and return what it proves about its own receivers."""
22
+ tree = nodes.parse(source)
23
+ names = NameResolver()
24
+ facts = FileFacts(path=path, imports=names.imports)
25
+ registry = by_node_kind(collectors if collectors is not None else COLLECTORS)
26
+ _walk(tree.root_node, facts, names, registry, klass=None, method=None)
27
+ facts.namespace = names.namespace
28
+ # A second pass rather than a hook in `_walk`: locating calls is not scope work, and
29
+ # keeping it out means the walker still has one reason to change.
30
+ facts.calls = index_calls(tree.root_node)
31
+ facts.routes = collect_routes(tree.root_node, names, path)
32
+ return facts
33
+
34
+
35
+ def scan_file(path: str, collectors=None) -> FileFacts:
36
+ with open(path, "rb") as handle:
37
+ return scan_source(path, handle.read(), collectors)
38
+
39
+
40
+ def _walk(node, facts: FileFacts, names: NameResolver, registry, klass, method) -> None:
41
+ kind = node.type
42
+
43
+ if _is_anonymous_class(node):
44
+ # Its members belong to a class with no name, so attributing them to the enclosing
45
+ # class would invent properties the enclosing class does not have. The span is kept
46
+ # so a call site inside it is refused rather than read against the enclosing class.
47
+ facts.anonymous_classes.append(nodes.line_span(node))
48
+ return
49
+
50
+ if kind == "namespace_definition":
51
+ _read_namespace(node, names)
52
+ elif kind == "namespace_use_declaration":
53
+ _read_imports(node, names)
54
+ elif kind in nodes.CLASS_KINDS:
55
+ klass = _open_class(node, facts, names)
56
+ method = None
57
+ elif kind == "method_declaration" and klass is not None:
58
+ method = _open_method(node, klass)
59
+ elif kind == "use_declaration" and klass is not None:
60
+ # A trait `use` inside a class body, not a namespace import: the methods it brings in
61
+ # are declared by this class as far as any caller can tell.
62
+ klass.ancestors.extend(_names_in(node, names))
63
+
64
+ for collector in registry.get(kind, ()):
65
+ collector.collect(node, ScanContext(facts=facts, names=names, klass=klass, method=method))
66
+
67
+ for child in node.children:
68
+ _walk(child, facts, names, registry, klass, method)
69
+
70
+
71
+ def _is_anonymous_class(node) -> bool:
72
+ """`new class { ... }`, which the grammar hangs under an `object_creation_expression`."""
73
+ if node.type == "anonymous_class":
74
+ return True
75
+ return (
76
+ node.type == "object_creation_expression"
77
+ and nodes.child(node, "declaration_list") is not None
78
+ )
79
+
80
+
81
+ def _read_namespace(node, names: NameResolver) -> None:
82
+ name_node = nodes.child(node, "namespace_name", "qualified_name", "name")
83
+ if name_node is None:
84
+ return
85
+ # A braced `namespace X { ... }` block is read the same way, which is wrong for the rare
86
+ # file holding two of them. Two namespaces in one file is disallowed by PSR-4 and absent
87
+ # from the reference application, so the simpler reading stands until one turns up.
88
+ names.namespace = nodes.node_text(name_node).strip().strip(";").strip().strip("\\")
89
+
90
+
91
+ def _read_imports(node, names: NameResolver) -> None:
92
+ """`use A\\B\\C;` and `use A\\B\\C as D;`.
93
+
94
+ Grouped and function/const imports are not read: a grouped `use A\\{B, C};` nests its
95
+ clauses differently, and a function import binds a name that is never a call receiver.
96
+ """
97
+ for clause in nodes.children(node, "namespace_use_clause"):
98
+ target = nodes.child(clause, "qualified_name", "name")
99
+ if target is None:
100
+ continue
101
+ fqn = nodes.node_text(target).strip().lstrip("\\")
102
+ if not fqn:
103
+ continue
104
+ names.add_import(_alias_of(clause) or fqn.rsplit("\\", 1)[-1], fqn)
105
+
106
+
107
+ def _alias_of(clause) -> str:
108
+ """The name `use ... as X` binds, or "" when the clause has no alias.
109
+
110
+ The alias is read as the last `name` under the clause rather than the first, because
111
+ `use Foo as Bar;` puts two bare `name` children side by side and the first one is the
112
+ imported class. Both clause shapes are handled: some grammar builds wrap the alias in a
113
+ `namespace_aliasing_clause`, others leave the `as` token flat among the children.
114
+ """
115
+ aliasing = nodes.child(clause, "namespace_aliasing_clause")
116
+ if aliasing is not None:
117
+ return nodes.node_text(nodes.child(aliasing, "name"))
118
+ if nodes.child(clause, "as") is None:
119
+ return ""
120
+ name_children = nodes.children(clause, "name")
121
+ return nodes.node_text(name_children[-1]) if name_children else ""
122
+
123
+
124
+ def _open_class(node, facts: FileFacts, names: NameResolver) -> ClassScope:
125
+ start, end = nodes.line_span(node)
126
+ scope = ClassScope(
127
+ fqn=names.qualify_declaration(nodes.name_of(node)), start_line=start, end_line=end
128
+ )
129
+ base = nodes.child(node, "base_clause")
130
+ if base is not None:
131
+ inherited = _names_in(base, names)
132
+ scope.ancestors.extend(inherited)
133
+ if inherited:
134
+ scope.parent = inherited[0]
135
+ implemented = nodes.child(node, "class_interface_clause")
136
+ if implemented is not None:
137
+ # An interface declares the method too, so it answers "does this member exist" even
138
+ # though it never answers "what runs".
139
+ scope.ancestors.extend(_names_in(implemented, names))
140
+ facts.classes.append(scope)
141
+ return scope
142
+
143
+
144
+ def _names_in(node, names: NameResolver) -> list[str]:
145
+ """Every class name listed in a clause, resolved. `extends A, B` and `use T, U` both list."""
146
+ out = []
147
+ for child in nodes.children(node, "name", "qualified_name"):
148
+ text = nodes.node_text(child).strip()
149
+ if text:
150
+ out.append(names.resolve(text))
151
+ return out
152
+
153
+
154
+ def _open_method(node, klass: ClassScope) -> MethodScope:
155
+ start, end = nodes.line_span(node)
156
+ scope = MethodScope(name=nodes.name_of(node), start_line=start, end_line=end)
157
+ klass.methods.append(scope)
158
+ return scope
@@ -0,0 +1,49 @@
1
+ """Turning a source-level type name into a fully qualified one.
2
+
3
+ Its own module because this is where a resolver fabricates edges. A name written `Emails` in
4
+ one file and `Emails` in another is two different classes unless the file's `namespace` and
5
+ `use` lines say otherwise, and a resolver that matches on the short name links them anyway.
6
+ Six upstream issues across four code-graph projects trace their false edges to exactly that
7
+ fallback, so there is no fallback here: a name this file cannot see stays unqualified against
8
+ the file's own namespace and simply fails to match anything real.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass
17
+ class NameResolver:
18
+ """The `namespace` and `use` lines of one file, and nothing else.
19
+
20
+ Deliberately holds no corpus-wide index. Without one it is not possible to answer "is
21
+ there a class called `Emails` somewhere?", which is the question whose answer is always a
22
+ guess.
23
+ """
24
+
25
+ namespace: str = ""
26
+ imports: dict[str, str] = field(default_factory=dict) # alias -> FQN
27
+
28
+ def resolve(self, name: str) -> str:
29
+ """PHP's own order: absolute, then alias, then relative to the file's namespace."""
30
+ if not name:
31
+ return name
32
+ if name.startswith("\\"):
33
+ return name.lstrip("\\")
34
+ head, _, tail = name.partition("\\")
35
+ alias = self.imports.get(head)
36
+ if alias is not None:
37
+ return f"{alias}\\{tail}" if tail else alias
38
+ if self.namespace:
39
+ return f"{self.namespace}\\{name}"
40
+ return name
41
+
42
+ def qualify_declaration(self, short: str) -> str:
43
+ """The FQN of a class declared in this file."""
44
+ if not short:
45
+ return short
46
+ return f"{self.namespace}\\{short}" if self.namespace else short
47
+
48
+ def add_import(self, alias: str, fqn: str) -> None:
49
+ self.imports[alias] = fqn.lstrip("\\")
@@ -0,0 +1,209 @@
1
+ """Tree-sitter primitives shared by the collectors.
2
+
3
+ Kept separate from the collectors so that a collector is about one type source and nothing
4
+ else: the moment `type_name` lived next to the walker, every new source meant editing the
5
+ walker too.
6
+
7
+ Nothing here knows what a type source is, and nothing here knows about `ports`. The whole
8
+ module is "ask the grammar a question", so it changes when tree-sitter-php changes and for no
9
+ other reason.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ try: # both ship with graphify, so this is reuse rather than a new dependency
15
+ import tree_sitter as ts
16
+ import tree_sitter_php as tsp
17
+ except ImportError: # pragma: no cover - exercised only on a broken install
18
+ ts = None
19
+ tsp = None
20
+
21
+ # Type positions in the grammar. A declaration carries exactly one of these as a child, so the
22
+ # collectors name the set once here instead of repeating it at every declaration site.
23
+ TYPE_KINDS = (
24
+ "named_type",
25
+ "union_type",
26
+ "optional_type",
27
+ "primitive_type",
28
+ "intersection_type",
29
+ "disjunctive_normal_form_type", # `(A&B)|C`, a disjunction whose branches are intersections
30
+ )
31
+
32
+ CLASS_KINDS = (
33
+ "class_declaration",
34
+ "trait_declaration",
35
+ "interface_declaration",
36
+ "enum_declaration",
37
+ )
38
+
39
+ # A closure's parameters and `use` clause bind names of their own, and its body is not the
40
+ # enclosing method's body. Both grammar names for the `function () {}` form are listed because
41
+ # tree-sitter-php renamed the node between releases and graphify does not pin the version.
42
+ CLOSURE_KINDS = (
43
+ "anonymous_function",
44
+ "anonymous_function_creation_expression",
45
+ "arrow_function",
46
+ )
47
+
48
+ _PARSER = None
49
+
50
+
51
+ def tree_sitter_available() -> tuple[bool, str]:
52
+ if ts is None or tsp is None:
53
+ return False, "tree_sitter and tree_sitter_php are not importable"
54
+ return True, ""
55
+
56
+
57
+ def parser():
58
+ """The one parser instance for this process.
59
+
60
+ Reused rather than rebuilt per file: constructing the Language and Parser dominates the
61
+ cost of parsing a single small file, and a build walks every PHP file in the repository.
62
+ """
63
+ global _PARSER
64
+ if _PARSER is None:
65
+ ok, why = tree_sitter_available()
66
+ if not ok:
67
+ raise RuntimeError(why)
68
+ _PARSER = ts.Parser(ts.Language(tsp.language_php()))
69
+ return _PARSER
70
+
71
+
72
+ def parse(source: bytes):
73
+ return parser().parse(source)
74
+
75
+
76
+ def node_text(node) -> str:
77
+ """Source text of a node.
78
+
79
+ py-tree-sitter keeps the bytes it parsed on the node, so nothing downstream has to carry
80
+ the buffer alongside every node it handles.
81
+ """
82
+ if node is None:
83
+ return ""
84
+ raw = node.text
85
+ return raw.decode("utf-8", "replace") if raw is not None else ""
86
+
87
+
88
+ def child(node, *kinds):
89
+ """First direct child of any of `kinds`, or None.
90
+
91
+ Direct children only. A recursive search would reach into a nested declaration and read a
92
+ type belonging to another scope, which is the mistake this whole package exists to avoid.
93
+ """
94
+ if node is None:
95
+ return None
96
+ for candidate in node.children:
97
+ if candidate.type in kinds:
98
+ return candidate
99
+ return None
100
+
101
+
102
+ def children(node, *kinds):
103
+ if node is None:
104
+ return []
105
+ return [c for c in node.children if c.type in kinds]
106
+
107
+
108
+ def name_of(node) -> str:
109
+ """The declared identifier of a class or method declaration."""
110
+ return node_text(child(node, "name"))
111
+
112
+
113
+ def variable_of(node) -> str:
114
+ """The bare name of a `variable_name` child, with the sigil removed."""
115
+ return node_text(child(node, "variable_name")).lstrip("$")
116
+
117
+
118
+ def line_span(node) -> tuple[int, int]:
119
+ """1-based inclusive line span, matching how every other tool reports a location."""
120
+ return node.start_point[0] + 1, node.end_point[0] + 1
121
+
122
+
123
+ def type_node(node):
124
+ """The type annotation of a declaration, or None when it is untyped."""
125
+ return child(node, *TYPE_KINDS)
126
+
127
+
128
+ def is_union(node) -> bool:
129
+ """Is this type position an alternation — the object is ONE OF several classes?
130
+
131
+ `A|B` and `(A&B)|C` both are: there are two candidate targets and nothing in the type says
132
+ which. An intersection is deliberately not here. `A&B` is one object that is both things
133
+ at once, so a method declared in only one constituent is an unambiguous target, and
134
+ `intersection_members` handles it instead.
135
+ """
136
+ if node is None:
137
+ return False
138
+ if node.type in ("union_type", "disjunctive_normal_form_type"):
139
+ return True
140
+ if node.type == "optional_type":
141
+ return any(is_union(c) for c in node.children)
142
+ return False
143
+
144
+
145
+ def intersection_members(node) -> list[str]:
146
+ """The class names of an `A&B` type, or [] when this is not an intersection.
147
+
148
+ Only class names are returned: `A&int` is not expressible in PHP, so a member that reads
149
+ as a keyword is a parse this code does not understand and is dropped rather than guessed.
150
+ """
151
+ if node is None:
152
+ return []
153
+ if node.type == "optional_type":
154
+ for child in node.children:
155
+ found = intersection_members(child)
156
+ if found:
157
+ return found
158
+ return []
159
+ if node.type != "intersection_type":
160
+ return []
161
+ names = []
162
+ for child in node.children:
163
+ name = type_name(child)
164
+ if name:
165
+ names.append(name)
166
+ return names
167
+
168
+
169
+ def type_name(node) -> str | None:
170
+ """Return a single class name from a type node, or None when it is not one class.
171
+
172
+ Nullable `?Foo` yields `Foo`: null is not an object target, so the call still reaches
173
+ `Foo::method` when it happens at all. A union `Foo|Bar` yields None — both alternatives
174
+ are real targets and this version cannot express two, so it records nothing rather than
175
+ picking one. `array`, `string`, `int` and friends yield None: they own no methods.
176
+
177
+ `self`, `static` and `parent` yield None as well. They name a declaration, not the runtime
178
+ class, and treating them as a class is how late static binding turns into a wrong edge.
179
+ """
180
+ if node is None:
181
+ return None
182
+ if node.type in (
183
+ "union_type",
184
+ "disjunctive_normal_form_type",
185
+ "intersection_type",
186
+ "primitive_type",
187
+ ):
188
+ return None
189
+ if node.type in ("optional_type", "nullable_type"):
190
+ for candidate in node.children:
191
+ found = type_name(candidate)
192
+ if found:
193
+ return found
194
+ return None
195
+ if node.type in ("named_type", "qualified_name", "name"):
196
+ raw = node_text(node).strip().lstrip("?").strip()
197
+ if not raw:
198
+ return None
199
+ # PHP keyword types and relative scopes are all lower case; user classes conventionally
200
+ # are not. The check is on the first character so that an unknown future keyword is
201
+ # refused rather than fabricated into a class.
202
+ if raw[0].islower():
203
+ return None
204
+ return raw
205
+ for candidate in node.children:
206
+ found = type_name(candidate)
207
+ if found:
208
+ return found
209
+ return None