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