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
graphify_php/resolver.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""The resolution pass graphify runs, expressed against `ports` alone.
|
|
2
|
+
|
|
3
|
+
This module imports nothing from `syntax` or `sources`. It is handed a list of `CallTargetSource`
|
|
4
|
+
and asks them in order; adding a PHPStan source, a language server or an LSIF index later is a
|
|
5
|
+
change to the factory that assembles that list, not to anything here.
|
|
6
|
+
|
|
7
|
+
Precedence is the whole reason the list is ordered. A precise source that ran the project's own
|
|
8
|
+
type engine and a parser that read a declaration will both answer `$this->emails`, and they will
|
|
9
|
+
not always agree. The first source that proves a site owns it, and that site is never offered to
|
|
10
|
+
the next one — so a later, weaker source can add reach but can never overwrite a stronger
|
|
11
|
+
source's answer.
|
|
12
|
+
|
|
13
|
+
Every path through this function ends in a status record, including the exception path, because
|
|
14
|
+
graphify's driver catches whatever escapes:
|
|
15
|
+
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
|
|
18
|
+
|
|
19
|
+
The warning goes to graphify's logger. Nothing that runs the build reads it, so without the
|
|
20
|
+
record written below a total failure of this pass and a PHP codebase with few calls produce the
|
|
21
|
+
same artifact.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import inspect
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from . import status
|
|
34
|
+
from .ports import CallSite, CallTarget, CallTargetSource, EdgeSink, NodeIndex, ScopeIndex
|
|
35
|
+
|
|
36
|
+
RESOLVER_NAME = "php_member_calls"
|
|
37
|
+
PHP_SUFFIXES = frozenset({".php"})
|
|
38
|
+
|
|
39
|
+
# "1" mints a body-less `external` node per vendor class a proven call targets, instead of
|
|
40
|
+
# dropping the record. Off by default: graphify core refuses stubs for `calls` because they
|
|
41
|
+
# become hub nodes; this plugin opts in per build.
|
|
42
|
+
EXTERNAL_STUBS_ENV = "GRAPHIFY_PHP_EXTERNAL_STUBS"
|
|
43
|
+
|
|
44
|
+
# The unresolved-reason vocabulary this module owns. Reasons a source reports are prefixed with
|
|
45
|
+
# that source's name, so the two namespaces cannot collide and the histogram stays a work list.
|
|
46
|
+
REASON_NO_SOURCE = "no_source_resolved"
|
|
47
|
+
REASON_NOT_IN_GRAPH = "target_not_in_graph"
|
|
48
|
+
REASON_SELF_CALL = "self_call"
|
|
49
|
+
REASON_SCOPE_NOT_RECOVERED = "scoped_method_not_recovered"
|
|
50
|
+
REASON_SCOPE_AMBIGUOUS = "scoped_method_ambiguous"
|
|
51
|
+
|
|
52
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_\\][A-Za-z0-9_\\]*$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_vendor_file(path: str, repo_root: str) -> bool:
|
|
56
|
+
"""Whether `path` sits under the repository's `vendor/` directory.
|
|
57
|
+
|
|
58
|
+
Only the first segment under the root counts. A project class the build left out (a
|
|
59
|
+
`Tests\\` class under `.graphifyignore`) is still project code and gets no stub for it.
|
|
60
|
+
"""
|
|
61
|
+
if not path:
|
|
62
|
+
return False
|
|
63
|
+
root = os.path.abspath(repo_root)
|
|
64
|
+
relative = os.path.relpath(os.path.join(root, path), root)
|
|
65
|
+
return relative.replace("\\", "/").split("/", 1)[0] == "vendor"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ScopedGroup:
|
|
70
|
+
"""One scoped call site, and the candidate method names offered on its behalf.
|
|
71
|
+
|
|
72
|
+
A group is ONE eligible site however many candidates it takes to find its method, which is
|
|
73
|
+
why the candidates are accounted separately: counting nine refused probes as nine failures
|
|
74
|
+
would bury the real unresolved reasons under noise of our own making.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
scope: str
|
|
78
|
+
candidates: list[CallSite]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_sites(per_file: Sequence[dict]) -> list[CallSite]:
|
|
82
|
+
"""Every `$receiver->method()` graphify left unresolved, as `CallSite`.
|
|
83
|
+
|
|
84
|
+
graphify's PHP extractor sets `is_member_call` and the callee name but leaves `receiver`
|
|
85
|
+
unset (extractors/engine.py, the `tree_sitter_php` branch of the call walk: unlike the C#,
|
|
86
|
+
Java and C++ branches it never assigns `member_receiver`). The field is carried through
|
|
87
|
+
when it is there and left empty when it is not; a source that needs the receiver expression
|
|
88
|
+
re-reads it from `source_file` at `source_location`, which it must parse anyway.
|
|
89
|
+
"""
|
|
90
|
+
sites: list[CallSite] = []
|
|
91
|
+
for result in per_file:
|
|
92
|
+
for raw in result.get("raw_calls", []) or []:
|
|
93
|
+
if not raw.get("is_member_call"):
|
|
94
|
+
continue
|
|
95
|
+
caller, callee = raw.get("caller_nid"), raw.get("callee")
|
|
96
|
+
source_file = raw.get("source_file") or ""
|
|
97
|
+
if not caller or not callee or not source_file.endswith(".php"):
|
|
98
|
+
continue
|
|
99
|
+
sites.append(
|
|
100
|
+
CallSite(
|
|
101
|
+
caller_nid=caller,
|
|
102
|
+
receiver=raw.get("receiver") or "",
|
|
103
|
+
method=callee,
|
|
104
|
+
source_file=source_file,
|
|
105
|
+
source_location=raw.get("source_location") or "",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return sites
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def collect_scoped_groups(per_file: Sequence[dict], scopes: ScopeIndex) -> list[ScopedGroup]:
|
|
112
|
+
"""Scoped calls — `self::`, `parent::`, `static::`, `Class::` — as candidate sites.
|
|
113
|
+
|
|
114
|
+
graphify records all four with `is_member_call` false and the SCOPE as the callee, the same
|
|
115
|
+
shape it uses for an ordinary function call. They are told apart by asking the graph whether
|
|
116
|
+
the callee names a class with methods: `trim` and `error_log` name no class, so they produce
|
|
117
|
+
no candidates and drop out without a hardcoded list of PHP builtins.
|
|
118
|
+
"""
|
|
119
|
+
groups: list[ScopedGroup] = []
|
|
120
|
+
for result in per_file:
|
|
121
|
+
for raw in result.get("raw_calls", []) or []:
|
|
122
|
+
if raw.get("is_member_call"):
|
|
123
|
+
continue
|
|
124
|
+
caller, scope = raw.get("caller_nid"), raw.get("callee")
|
|
125
|
+
source_file = raw.get("source_file") or ""
|
|
126
|
+
if not caller or not scope or not source_file.endswith(".php"):
|
|
127
|
+
continue
|
|
128
|
+
if not _IDENTIFIER.match(str(scope)):
|
|
129
|
+
continue
|
|
130
|
+
candidates = [
|
|
131
|
+
CallSite(
|
|
132
|
+
caller_nid=caller,
|
|
133
|
+
receiver=str(scope),
|
|
134
|
+
method=name,
|
|
135
|
+
source_file=source_file,
|
|
136
|
+
source_location=raw.get("source_location") or "",
|
|
137
|
+
)
|
|
138
|
+
for name in scopes.scope_methods(caller, str(scope))
|
|
139
|
+
]
|
|
140
|
+
if candidates:
|
|
141
|
+
groups.append(ScopedGroup(scope=str(scope), candidates=candidates))
|
|
142
|
+
return groups
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def collect_corpus(per_file: Sequence[dict]) -> list[str]:
|
|
146
|
+
"""Every PHP file graphify parsed, as the corpus a source may read declarations from.
|
|
147
|
+
|
|
148
|
+
A leaf service class has nothing unresolved in it, so it never appears among the sites and
|
|
149
|
+
a source indexing only those files cannot tell whether it declares the method being called
|
|
150
|
+
on it. The fixture shows the shape exactly: `Emails.php` contributes nodes but no
|
|
151
|
+
`raw_calls`, and it is the class every resolved call points at.
|
|
152
|
+
|
|
153
|
+
Bounded by what the build already parsed, never by a filesystem walk: a walk to the nearest
|
|
154
|
+
`composer.json` measured 4.7 s against 0.16 s and read paths outside the build.
|
|
155
|
+
|
|
156
|
+
Paths are taken verbatim from the same dicts the sites come from. During a build they are
|
|
157
|
+
ABSOLUTE and only relativized at publish time, so rebuilding them from a root here would
|
|
158
|
+
hand the source the same file under two names and make it parse it twice.
|
|
159
|
+
"""
|
|
160
|
+
seen: set[str] = set()
|
|
161
|
+
for result in per_file:
|
|
162
|
+
for group in ("nodes", "raw_calls"):
|
|
163
|
+
for item in result.get(group, []) or []:
|
|
164
|
+
path = item.get("source_file") or ""
|
|
165
|
+
if path.endswith(".php"):
|
|
166
|
+
seen.add(path)
|
|
167
|
+
return sorted(seen)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Whether a source's `resolve` takes `corpus`, cached per source class. The shim exists because
|
|
171
|
+
# `corpus` was added to `CallTargetSource` after the first sources were written, and a source
|
|
172
|
+
# built against the old signature is still a valid implementation of the protocol. Reading the
|
|
173
|
+
# signature rather than catching `TypeError` on the call: a `TypeError` raised from inside a
|
|
174
|
+
# source's own body would otherwise be misread as "this source is old" and silently retried
|
|
175
|
+
# without the corpus.
|
|
176
|
+
_ACCEPTS_CORPUS: dict[type, bool] = {}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolve_with(
|
|
180
|
+
source: CallTargetSource,
|
|
181
|
+
repo_root: str,
|
|
182
|
+
sites: Sequence[CallSite],
|
|
183
|
+
corpus: Sequence[str] | None,
|
|
184
|
+
):
|
|
185
|
+
kind = type(source)
|
|
186
|
+
accepts = _ACCEPTS_CORPUS.get(kind)
|
|
187
|
+
if accepts is None:
|
|
188
|
+
try:
|
|
189
|
+
params = inspect.signature(source.resolve).parameters
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
accepts = False
|
|
192
|
+
else:
|
|
193
|
+
accepts = "corpus" in params or any(
|
|
194
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
195
|
+
)
|
|
196
|
+
_ACCEPTS_CORPUS[kind] = accepts
|
|
197
|
+
if corpus is not None and accepts:
|
|
198
|
+
return source.resolve(repo_root, sites, corpus)
|
|
199
|
+
return source.resolve(repo_root, sites)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ask_sources(
|
|
203
|
+
sources: Sequence[CallTargetSource],
|
|
204
|
+
repo_root: str,
|
|
205
|
+
sites: Sequence[CallSite],
|
|
206
|
+
record: status.ResolverStatus,
|
|
207
|
+
reason_prefix: str = "",
|
|
208
|
+
count_unclaimed: bool = True,
|
|
209
|
+
corpus: Sequence[str] | None = None,
|
|
210
|
+
) -> list[CallTarget]:
|
|
211
|
+
"""Offer each site to the sources in order, stopping at the first that proves it."""
|
|
212
|
+
pending: list[CallSite] = list(sites)
|
|
213
|
+
targets: list[CallTarget] = []
|
|
214
|
+
for source in sources:
|
|
215
|
+
if not pending:
|
|
216
|
+
break
|
|
217
|
+
resolution = _resolve_with(source, repo_root, pending, corpus)
|
|
218
|
+
record.files_seen = max(record.files_seen, resolution.files_seen)
|
|
219
|
+
for reason, count in (resolution.unresolved or {}).items():
|
|
220
|
+
record.unresolvable(f"{reason_prefix}{source.name}:{reason}", count)
|
|
221
|
+
|
|
222
|
+
# A site keeps every target a source proved for it: a `Emails|Cache` receiver has two
|
|
223
|
+
# real arms, and one arm alone is a wrong answer. Only an exact repeat is dropped.
|
|
224
|
+
claimed: set[CallSite] = set()
|
|
225
|
+
seen: set[tuple[CallSite, str, str]] = set()
|
|
226
|
+
for target in resolution.targets:
|
|
227
|
+
key = (target.site, target.class_fqn, target.method)
|
|
228
|
+
if key in seen:
|
|
229
|
+
continue
|
|
230
|
+
seen.add(key)
|
|
231
|
+
claimed.add(target.site)
|
|
232
|
+
targets.append(target)
|
|
233
|
+
pending = [site for site in pending if site not in claimed]
|
|
234
|
+
|
|
235
|
+
if pending and count_unclaimed:
|
|
236
|
+
record.unresolvable(REASON_NO_SOURCE, len(pending))
|
|
237
|
+
return targets
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolve_groups(
|
|
241
|
+
groups: Sequence[ScopedGroup],
|
|
242
|
+
sources: Sequence[CallTargetSource],
|
|
243
|
+
repo_root: str,
|
|
244
|
+
record: status.ResolverStatus,
|
|
245
|
+
corpus: Sequence[str] | None = None,
|
|
246
|
+
) -> list[CallTarget]:
|
|
247
|
+
"""Turn each group's candidates into at most one target for that call site.
|
|
248
|
+
|
|
249
|
+
A separate pass rather than one flat list with the member sites, so the probes' refusals
|
|
250
|
+
land under their own `probe:` prefix. Mixed in, a class with nine other methods would report
|
|
251
|
+
nine `call-site-not-found` against every scoped call and make the histogram useless.
|
|
252
|
+
"""
|
|
253
|
+
if not groups:
|
|
254
|
+
return []
|
|
255
|
+
probes = [site for group in groups for site in group.candidates]
|
|
256
|
+
found: dict[CallSite, list[CallTarget]] = {}
|
|
257
|
+
for target in _ask_sources(
|
|
258
|
+
sources,
|
|
259
|
+
repo_root,
|
|
260
|
+
probes,
|
|
261
|
+
record,
|
|
262
|
+
reason_prefix="probe:",
|
|
263
|
+
count_unclaimed=False,
|
|
264
|
+
corpus=corpus,
|
|
265
|
+
):
|
|
266
|
+
found.setdefault(target.site, []).append(target)
|
|
267
|
+
targets: list[CallTarget] = []
|
|
268
|
+
for group in groups:
|
|
269
|
+
hits = [target for site in group.candidates for target in found.get(site, ())]
|
|
270
|
+
if len(hits) == 1:
|
|
271
|
+
targets.append(hits[0])
|
|
272
|
+
elif not hits:
|
|
273
|
+
record.unresolvable(REASON_SCOPE_NOT_RECOVERED)
|
|
274
|
+
else:
|
|
275
|
+
# Two candidate names verified against one line, or one name with two targets.
|
|
276
|
+
# Either way nothing here says which body is called, so the site gets no edge.
|
|
277
|
+
record.unresolvable(REASON_SCOPE_AMBIGUOUS)
|
|
278
|
+
return targets
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _write_edges(
|
|
282
|
+
targets: Iterable[CallTarget],
|
|
283
|
+
index: NodeIndex,
|
|
284
|
+
sink: EdgeSink,
|
|
285
|
+
record: status.ResolverStatus,
|
|
286
|
+
stubs=None,
|
|
287
|
+
repo_root: str = ".",
|
|
288
|
+
) -> None:
|
|
289
|
+
# `ports.NodeIndex` takes no caller, so an index that can apply the caller's own visibility
|
|
290
|
+
# offers a wider call and is used when present. The report asks for the caller to be added
|
|
291
|
+
# to the port; until then this keeps `ports` untouched and fake indexes in tests working.
|
|
292
|
+
origin_aware = getattr(index, "method_node_for", None)
|
|
293
|
+
# Counted per target record, not per site: a union site with one arm missing from the graph
|
|
294
|
+
# still gets its other arm, and the histogram says how many records were lost.
|
|
295
|
+
for target in targets:
|
|
296
|
+
callee_nid = (
|
|
297
|
+
origin_aware(target.site.caller_nid, target.class_fqn, target.method)
|
|
298
|
+
if callable(origin_aware)
|
|
299
|
+
else index.method_node(target.class_fqn, target.method)
|
|
300
|
+
)
|
|
301
|
+
if callee_nid is None:
|
|
302
|
+
# Only a class proven to live under `vendor/` gets a stub. Project code the build
|
|
303
|
+
# excluded is still project code, and a stub for it would shadow the real class.
|
|
304
|
+
if stubs is not None and is_vendor_file(target.declaring_file, repo_root):
|
|
305
|
+
stubs.add_call(target.site.caller_nid, stubs.node_for(target.class_fqn), target)
|
|
306
|
+
record.external_stubbed += 1
|
|
307
|
+
continue
|
|
308
|
+
# `NodeIndex` answers one question, so a missing class and a missing method arrive
|
|
309
|
+
# here indistinguishable. Widening the port to tell them apart would tie this
|
|
310
|
+
# package to graphify's node schema, which is the part most likely to move.
|
|
311
|
+
record.unresolvable(REASON_NOT_IN_GRAPH)
|
|
312
|
+
continue
|
|
313
|
+
if callee_nid == target.site.caller_nid:
|
|
314
|
+
record.unresolvable(REASON_SELF_CALL)
|
|
315
|
+
continue
|
|
316
|
+
sink.add_call(target.site.caller_nid, callee_nid, target)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def run(
|
|
320
|
+
per_file: Sequence[dict],
|
|
321
|
+
all_nodes: list[dict],
|
|
322
|
+
all_edges: list[dict],
|
|
323
|
+
*,
|
|
324
|
+
sources: Sequence[CallTargetSource] | Callable[[str], Sequence[CallTargetSource]],
|
|
325
|
+
repo_root: str = ".",
|
|
326
|
+
index: NodeIndex | None = None,
|
|
327
|
+
sink: EdgeSink | None = None,
|
|
328
|
+
scopes: ScopeIndex | None = None,
|
|
329
|
+
stubs=None,
|
|
330
|
+
) -> status.ResolverStatus:
|
|
331
|
+
"""Resolve PHP member calls into `calls` edges, and record what happened either way.
|
|
332
|
+
|
|
333
|
+
`sources` may be a factory taking the repository root. It is called in here, inside the
|
|
334
|
+
record's own try, so a factory that raises leaves a `failed` record and not silence.
|
|
335
|
+
"""
|
|
336
|
+
record = status.ResolverStatus(name=RESOLVER_NAME)
|
|
337
|
+
try:
|
|
338
|
+
if callable(sources):
|
|
339
|
+
sources = sources(repo_root)
|
|
340
|
+
if index is None or sink is None:
|
|
341
|
+
from .graph_adapter import GraphEdgeSink, GraphNodeIndex
|
|
342
|
+
|
|
343
|
+
built = GraphNodeIndex(all_nodes, all_edges)
|
|
344
|
+
index = index or built
|
|
345
|
+
sink = sink or GraphEdgeSink(all_edges, built)
|
|
346
|
+
if stubs is None and os.environ.get(EXTERNAL_STUBS_ENV) == "1":
|
|
347
|
+
from .graph_adapter import GraphStubSink
|
|
348
|
+
|
|
349
|
+
stubs = GraphStubSink(all_nodes, all_edges)
|
|
350
|
+
if scopes is None and isinstance(index, ScopeIndex):
|
|
351
|
+
scopes = index
|
|
352
|
+
|
|
353
|
+
unavailable: list[str] = []
|
|
354
|
+
usable: list[CallTargetSource] = []
|
|
355
|
+
for source in sources:
|
|
356
|
+
ok, reason = source.available(repo_root)
|
|
357
|
+
if ok:
|
|
358
|
+
usable.append(source)
|
|
359
|
+
else:
|
|
360
|
+
unavailable.append(f"{source.name}: {reason}")
|
|
361
|
+
|
|
362
|
+
corpus = collect_corpus(per_file)
|
|
363
|
+
record.files_seen = len(corpus)
|
|
364
|
+
if not usable:
|
|
365
|
+
# A repository with no PHPStan in `vendor/` is the normal case, not a fault: the
|
|
366
|
+
# build publishes, and the record says which layer was missing.
|
|
367
|
+
record.state = "skipped"
|
|
368
|
+
record.reason = "; ".join(unavailable) or "no call target sources configured"
|
|
369
|
+
return record
|
|
370
|
+
|
|
371
|
+
sites = collect_sites(per_file)
|
|
372
|
+
groups = collect_scoped_groups(per_file, scopes) if scopes is not None else []
|
|
373
|
+
record.eligible_sites = len(sites) + len(groups)
|
|
374
|
+
targets = _ask_sources(usable, repo_root, sites, record, corpus=corpus)
|
|
375
|
+
targets += _resolve_groups(groups, usable, repo_root, record, corpus=corpus)
|
|
376
|
+
record.resolved_sites = len({t.site for t in targets})
|
|
377
|
+
_write_edges(targets, index, sink, record, stubs=stubs, repo_root=repo_root)
|
|
378
|
+
record.edges_added = getattr(sink, "added", record.resolved_sites)
|
|
379
|
+
if stubs is not None:
|
|
380
|
+
record.stub_nodes_added = stubs.nodes_added
|
|
381
|
+
record.stub_edges_added = stubs.edges_added
|
|
382
|
+
record.state = "completed"
|
|
383
|
+
if unavailable:
|
|
384
|
+
record.reason = "; ".join(unavailable)
|
|
385
|
+
return record
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
record.state = "failed"
|
|
388
|
+
record.reason = f"{type(exc).__name__}: {exc}"
|
|
389
|
+
raise
|
|
390
|
+
finally:
|
|
391
|
+
record.finished_at = time.time()
|
|
392
|
+
status.write([record])
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def make_resolver(
|
|
396
|
+
sources_factory: Callable[[str], Sequence[CallTargetSource]], repo_root: str = "."
|
|
397
|
+
):
|
|
398
|
+
"""Wrap `run` in the `(per_file, all_nodes, all_edges) -> None` shape graphify calls.
|
|
399
|
+
|
|
400
|
+
The factory is called at resolution time, not at registration time: registering happens at
|
|
401
|
+
import, when the working directory and the repository under build are not yet settled.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def resolve(per_file, all_nodes, all_edges) -> None:
|
|
405
|
+
run(per_file, all_nodes, all_edges, sources=sources_factory, repo_root=repo_root)
|
|
406
|
+
|
|
407
|
+
resolve.__name__ = RESOLVER_NAME
|
|
408
|
+
return resolve
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
RESOLVER_NAME_ROUTES = "php_routes"
|
|
412
|
+
|
|
413
|
+
# Reasons the route pass owns. A source's own reasons arrive prefixed with its name.
|
|
414
|
+
REASON_ROUTE_NO_CONTROLLER = "controller_not_in_graph"
|
|
415
|
+
REASON_ROUTE_NO_PATH = "route_had_no_path"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def run_routes(
|
|
419
|
+
per_file: Sequence[dict],
|
|
420
|
+
all_nodes: list[dict],
|
|
421
|
+
all_edges: list[dict],
|
|
422
|
+
*,
|
|
423
|
+
sources: Sequence[object] | Callable[[str], Sequence[object]],
|
|
424
|
+
repo_root: str = ".",
|
|
425
|
+
index: NodeIndex | None = None,
|
|
426
|
+
sink=None,
|
|
427
|
+
) -> status.ResolverStatus:
|
|
428
|
+
"""Publish route declarations as nodes, each edged to the controller method that serves it.
|
|
429
|
+
|
|
430
|
+
A pass of its own rather than a stage of the member-call one, and with its own record, for
|
|
431
|
+
the reason this package exists: a layer that silently adds nothing is indistinguishable from
|
|
432
|
+
a codebase that has none of what it looks for. Routes were extracted and measured for a
|
|
433
|
+
while before anything connected them to the graph, and every test stayed green throughout,
|
|
434
|
+
because the tests checked extraction and nothing checked publication.
|
|
435
|
+
"""
|
|
436
|
+
record = status.ResolverStatus(name=RESOLVER_NAME_ROUTES)
|
|
437
|
+
try:
|
|
438
|
+
if callable(sources):
|
|
439
|
+
# Same contract as `run`: a factory, called inside the try so that it reports.
|
|
440
|
+
sources = sources(repo_root)
|
|
441
|
+
if index is None or sink is None:
|
|
442
|
+
from .graph_adapter import GraphNodeIndex, GraphRouteSink
|
|
443
|
+
|
|
444
|
+
index = index or GraphNodeIndex(all_nodes, all_edges)
|
|
445
|
+
sink = sink or GraphRouteSink(all_nodes, all_edges)
|
|
446
|
+
|
|
447
|
+
unavailable: list[str] = []
|
|
448
|
+
usable: list[object] = []
|
|
449
|
+
for source in sources:
|
|
450
|
+
ok, reason = source.available(repo_root)
|
|
451
|
+
if ok:
|
|
452
|
+
usable.append(source)
|
|
453
|
+
else:
|
|
454
|
+
unavailable.append(f"{source.name}: {reason}")
|
|
455
|
+
|
|
456
|
+
# The same corpus the call sources get. A route lives in a controller file, which in
|
|
457
|
+
# practice always carries call sites too — but "in practice" is not a reason to hand a
|
|
458
|
+
# source a narrower set than the build parsed.
|
|
459
|
+
corpus = collect_corpus(per_file)
|
|
460
|
+
record.files_seen = len(corpus)
|
|
461
|
+
if not usable:
|
|
462
|
+
record.state = "skipped"
|
|
463
|
+
record.reason = "; ".join(unavailable) or "no route sources configured"
|
|
464
|
+
return record
|
|
465
|
+
|
|
466
|
+
declarations = []
|
|
467
|
+
for source in usable:
|
|
468
|
+
resolution = source.declarations(repo_root, corpus)
|
|
469
|
+
record.files_seen = max(record.files_seen, resolution.files_seen)
|
|
470
|
+
for reason, count in (resolution.unresolved or {}).items():
|
|
471
|
+
record.unresolvable(f"{source.name}:{reason}", count)
|
|
472
|
+
declarations.extend(resolution.declarations)
|
|
473
|
+
|
|
474
|
+
record.eligible_sites = len(declarations)
|
|
475
|
+
# The declaration names the file it was read from, and the controller class is declared
|
|
476
|
+
# in that same file. An index that can use it does; `ports.NodeIndex` cannot express it
|
|
477
|
+
# yet, so the narrow call stays the fallback.
|
|
478
|
+
in_file = getattr(index, "method_node_in_file", None)
|
|
479
|
+
for declaration in declarations:
|
|
480
|
+
controller_nid = (
|
|
481
|
+
in_file(
|
|
482
|
+
declaration.source_file,
|
|
483
|
+
declaration.controller_class,
|
|
484
|
+
declaration.controller_method,
|
|
485
|
+
)
|
|
486
|
+
if callable(in_file)
|
|
487
|
+
else index.method_node(declaration.controller_class, declaration.controller_method)
|
|
488
|
+
)
|
|
489
|
+
if controller_nid is None:
|
|
490
|
+
# The node still goes in: the declaration is a fact about this codebase whether
|
|
491
|
+
# or not the controller method survived extraction, and dropping it would hide
|
|
492
|
+
# the path. Only the edge is withheld.
|
|
493
|
+
record.unresolvable(REASON_ROUTE_NO_CONTROLLER)
|
|
494
|
+
if sink.add_route(declaration, controller_nid) is None:
|
|
495
|
+
record.unresolvable(REASON_ROUTE_NO_PATH)
|
|
496
|
+
|
|
497
|
+
record.resolved_sites = sink.nodes_added
|
|
498
|
+
record.edges_added = sink.edges_added
|
|
499
|
+
record.state = "completed"
|
|
500
|
+
if unavailable:
|
|
501
|
+
record.reason = "; ".join(unavailable)
|
|
502
|
+
return record
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
record.state = "failed"
|
|
505
|
+
record.reason = f"{type(exc).__name__}: {exc}"
|
|
506
|
+
raise
|
|
507
|
+
finally:
|
|
508
|
+
record.finished_at = time.time()
|
|
509
|
+
status.write([record])
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def make_route_resolver(sources_factory: Callable[[str], Sequence[object]], repo_root: str = "."):
|
|
513
|
+
"""Wrap `run_routes` in the shape graphify's registry calls."""
|
|
514
|
+
|
|
515
|
+
def resolve(per_file, all_nodes, all_edges) -> None:
|
|
516
|
+
run_routes(per_file, all_nodes, all_edges, sources=sources_factory, repo_root=repo_root)
|
|
517
|
+
|
|
518
|
+
resolve.__name__ = RESOLVER_NAME_ROUTES
|
|
519
|
+
return resolve
|