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,91 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace CallGraph;
6
+
7
+ /**
8
+ * One resolved `$receiver->method()`: who called, what was called, where.
9
+ *
10
+ * This class is the only place in the PHP extension that knows the wire shape. The collector
11
+ * runs in analyser workers and the rule runs in the main process, so records cross a
12
+ * serialisation boundary as plain arrays; keeping the key names here means a shape change is
13
+ * one edit rather than a hunt through two processes.
14
+ */
15
+ final class CallEdge
16
+ {
17
+ public const RECEIVER_CLASS = 'class';
18
+ public const RECEIVER_INTERFACE = 'interface';
19
+
20
+ private function __construct(
21
+ private readonly string $callerClass,
22
+ private readonly string $callerMethod,
23
+ private readonly string $calleeClass,
24
+ private readonly string $calleeMethod,
25
+ private readonly string $calleeKind,
26
+ private readonly int $line,
27
+ private readonly string $file,
28
+ private readonly int $callPos,
29
+ private readonly string $calleeFile,
30
+ ) {
31
+ }
32
+
33
+ public static function create(
34
+ string $callerClass,
35
+ string $callerMethod,
36
+ string $calleeClass,
37
+ string $calleeMethod,
38
+ bool $calleeIsInterface,
39
+ int $line,
40
+ string $file = '',
41
+ int $callPos = -1,
42
+ string $calleeFile = '',
43
+ ): self {
44
+ $kind = match ($calleeIsInterface) {
45
+ true => self::RECEIVER_INTERFACE,
46
+ false => self::RECEIVER_CLASS,
47
+ };
48
+
49
+ return new self($callerClass, $callerMethod, $calleeClass, $calleeMethod, $kind, $line, $file, $callPos, $calleeFile);
50
+ }
51
+
52
+ /**
53
+ * @param array<string, mixed> $raw
54
+ */
55
+ public static function fromArray(array $raw, string $file): self
56
+ {
57
+ return new self(
58
+ (string) ($raw['callerClass'] ?? ''),
59
+ (string) ($raw['callerMethod'] ?? ''),
60
+ (string) ($raw['calleeClass'] ?? ''),
61
+ (string) ($raw['calleeMethod'] ?? ''),
62
+ (string) ($raw['calleeKind'] ?? self::RECEIVER_CLASS),
63
+ (int) ($raw['line'] ?? 0),
64
+ $file,
65
+ (int) ($raw['callPos'] ?? -1),
66
+ (string) ($raw['calleeFile'] ?? ''),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * @return array<string, mixed>
72
+ */
73
+ public function toArray(): array
74
+ {
75
+ return [
76
+ 'callerClass' => $this->callerClass,
77
+ 'callerMethod' => $this->callerMethod,
78
+ 'calleeClass' => $this->calleeClass,
79
+ 'calleeMethod' => $this->calleeMethod,
80
+ 'calleeKind' => $this->calleeKind,
81
+ 'line' => $this->line,
82
+ 'file' => $this->file,
83
+ // Token offset of the called method's name. Two arms of one union receiver share it;
84
+ // two calls of the same method on one line do not, and the Python half tells them apart.
85
+ 'callPos' => $this->callPos,
86
+ // Where the declaring class lives, '' for a built-in. The consumer tells a vendor
87
+ // class from a project class by this path, never by guessing from the namespace.
88
+ 'calleeFile' => $this->calleeFile,
89
+ ];
90
+ }
91
+ }
@@ -0,0 +1,61 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace CallGraph;
6
+
7
+ use PhpParser\Node;
8
+ use PHPStan\Analyser\Scope;
9
+ use PHPStan\Node\CollectedDataNode;
10
+ use PHPStan\Rules\Rule;
11
+
12
+ /**
13
+ * Turns everything the collector gathered into the output file.
14
+ *
15
+ * A rule rather than a second collector because `CollectedDataNode` is delivered once, in the
16
+ * main process, after every worker has finished — the only point at which the whole set of
17
+ * edges exists in one place.
18
+ *
19
+ * It reports no analysis errors. The output is the file; PHPStan's exit code stays whatever the
20
+ * project's own rules make it, and the consumer is expected to read the file regardless.
21
+ *
22
+ * @implements Rule<CollectedDataNode>
23
+ */
24
+ final class CallEdgeRule implements Rule
25
+ {
26
+ public function __construct(private readonly CallEdgeWriter $writer)
27
+ {
28
+ }
29
+
30
+ public function getNodeType(): string
31
+ {
32
+ return CollectedDataNode::class;
33
+ }
34
+
35
+ /**
36
+ * @param CollectedDataNode $node
37
+ * @return list<never>
38
+ */
39
+ public function processNode(Node $node, Scope $scope): array
40
+ {
41
+ $this->writer->write($this->edges($node));
42
+
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * @return iterable<CallEdge>
48
+ */
49
+ private function edges(CollectedDataNode $node): iterable
50
+ {
51
+ foreach ($node->get(MethodCallCollector::class) as $file => $perNode) {
52
+ foreach ($perNode as $records) {
53
+ foreach ($records as $record) {
54
+ // The file comes from the key rather than from the record: the analyser owns
55
+ // path normalisation, and a second copy would drift from it.
56
+ yield CallEdge::fromArray($record, (string) $file);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,19 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace CallGraph;
6
+
7
+ /**
8
+ * Where resolved edges go once the analysis is over.
9
+ *
10
+ * Separate from the rule so that changing the destination — a different file format, a socket,
11
+ * a test double — never touches the code that decides which calls are real.
12
+ */
13
+ interface CallEdgeWriter
14
+ {
15
+ /**
16
+ * @param iterable<CallEdge> $edges
17
+ */
18
+ public function write(iterable $edges): void;
19
+ }
@@ -0,0 +1,64 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace CallGraph;
6
+
7
+ use RuntimeException;
8
+
9
+ /**
10
+ * Writes one JSON object per line.
11
+ *
12
+ * Line-delimited rather than one JSON document: a truncated run — the analyser killed on a
13
+ * memory limit, a timeout — still leaves every completed record readable, and the consumer
14
+ * drops the one broken tail line instead of losing the whole file.
15
+ */
16
+ final class JsonLinesWriter implements CallEdgeWriter
17
+ {
18
+ /**
19
+ * Set by whoever launches the analyser, so one checkout can be analysed into different
20
+ * destinations without editing the committed phpstan.neon.
21
+ */
22
+ public const PATH_ENV = 'PHP_CALL_GRAPH_OUT';
23
+
24
+ private const DEFAULT_PATH = 'php-call-graph.jsonl';
25
+
26
+ private readonly string $path;
27
+
28
+ public function __construct(?string $path = null)
29
+ {
30
+ $this->path = $path ?? self::pathFromEnvironment();
31
+ }
32
+
33
+ public function write(iterable $edges): void
34
+ {
35
+ $handle = @fopen($this->path, 'wb');
36
+
37
+ if ($handle === false) {
38
+ throw new RuntimeException(sprintf('Cannot open %s for writing', $this->path));
39
+ }
40
+
41
+ foreach ($edges as $edge) {
42
+ $line = json_encode($edge->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
43
+
44
+ if ($line === false) {
45
+ continue;
46
+ }
47
+
48
+ fwrite($handle, $line . "\n");
49
+ }
50
+
51
+ fclose($handle);
52
+ }
53
+
54
+ private static function pathFromEnvironment(): string
55
+ {
56
+ $configured = getenv(self::PATH_ENV);
57
+
58
+ if (!is_string($configured) || $configured === '') {
59
+ return self::DEFAULT_PATH;
60
+ }
61
+
62
+ return $configured;
63
+ }
64
+ }
@@ -0,0 +1,163 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace CallGraph;
6
+
7
+ use PhpParser\Node;
8
+ use PhpParser\Node\Expr\MethodCall;
9
+ use PhpParser\Node\Identifier;
10
+ use PHPStan\Analyser\Scope;
11
+ use PHPStan\Collectors\Collector;
12
+ use PHPStan\Reflection\ClassReflection;
13
+ use PHPStan\Reflection\Php\PhpMethodReflection;
14
+ use PHPStan\Type\IntersectionType;
15
+ use PHPStan\Type\Type;
16
+ use PHPStan\Type\UnionType;
17
+
18
+ /**
19
+ * Records every `$receiver->method()` whose receiver the type engine could name.
20
+ *
21
+ * A parser can only see types that are written down. The engine also knows the type of a
22
+ * private property assigned in the constructor, of a local, of a return value and of a
23
+ * docblock, which is why this exists at all: on the reference application a parser-only
24
+ * pass left three quarters of callables with no outgoing call edge.
25
+ *
26
+ * Nothing here is reported unless the engine produced a class reflection for the receiver.
27
+ * A name-only fallback — matching `->get()` to any class owning a `get` — manufactures edges
28
+ * that look identical to real ones, and a wrong edge is worse than a missing one.
29
+ *
30
+ * @implements Collector<MethodCall, list<array<string, mixed>>>
31
+ */
32
+ final class MethodCallCollector implements Collector
33
+ {
34
+ public function getNodeType(): string
35
+ {
36
+ return MethodCall::class;
37
+ }
38
+
39
+ /**
40
+ * @param MethodCall $node
41
+ * @return list<array<string, mixed>>|null
42
+ */
43
+ public function processNode(Node $node, Scope $scope): ?array
44
+ {
45
+ if (!$node->name instanceof Identifier) {
46
+ return null;
47
+ }
48
+
49
+ $callerClass = $scope->getClassReflection();
50
+
51
+ if ($callerClass === null) {
52
+ return null;
53
+ }
54
+
55
+ $callerMethod = $scope->getFunctionName();
56
+
57
+ if ($callerMethod === null) {
58
+ return null;
59
+ }
60
+
61
+ $methodName = $node->name->toString();
62
+ $receiverType = $scope->getType($node->var);
63
+ $edges = [];
64
+
65
+ // A union `A|B` is one of two objects, so every arm is a body that can run and each arm
66
+ // is decided on its own: `(A&B)|C` keeps C and drops the intersection, `(A&B)|null` has
67
+ // nothing left. Decided on the receiver as a whole, the intersection inside a union was
68
+ // never seen and A, B and C all went out under one call position.
69
+ $arms = $receiverType instanceof UnionType ? $receiverType->getTypes() : [$receiverType];
70
+
71
+ foreach ($arms as $arm) {
72
+ foreach ($this->armEdges($arm, $methodName, $callerClass->getName(), $callerMethod, $node, $scope) as $record) {
73
+ $edges[] = $record;
74
+ }
75
+ }
76
+
77
+ if ($edges === []) {
78
+ return null;
79
+ }
80
+
81
+ return $edges;
82
+ }
83
+
84
+ /**
85
+ * The records for one arm of the receiver type, or none when the arm is ambiguous.
86
+ *
87
+ * An intersection `A&B` is one object; when two constituents declare the method the type
88
+ * does not say which body runs, and emitting both would attribute a call to a body that
89
+ * never executes. Refused, as the parser layer refuses the same shape. Keyed on the
90
+ * declaring class so `A&B` with B extending A still resolves to the one body in A.
91
+ *
92
+ * @return list<array<string, mixed>>
93
+ */
94
+ private function armEdges(
95
+ Type $arm,
96
+ string $methodName,
97
+ string $callerClass,
98
+ string $callerMethod,
99
+ MethodCall $node,
100
+ Scope $scope,
101
+ ): array {
102
+ $records = [];
103
+ $declaringClasses = [];
104
+
105
+ foreach ($arm->getObjectClassReflections() as $receiver) {
106
+ $edge = $this->edgeTo($receiver, $methodName, $callerClass, $callerMethod, $node, $scope);
107
+
108
+ if ($edge === null) {
109
+ continue;
110
+ }
111
+
112
+ $record = $edge->toArray();
113
+ $records[] = $record;
114
+ $declaringClasses[$record['calleeClass']] = true;
115
+ }
116
+
117
+ if ($arm instanceof IntersectionType && count($declaringClasses) > 1) {
118
+ return [];
119
+ }
120
+
121
+ return $records;
122
+ }
123
+
124
+ /**
125
+ * @return CallEdge|null
126
+ */
127
+ private function edgeTo(
128
+ ClassReflection $receiver,
129
+ string $methodName,
130
+ string $callerClass,
131
+ string $callerMethod,
132
+ MethodCall $node,
133
+ Scope $scope,
134
+ ): ?CallEdge {
135
+ if (!$receiver->hasMethod($methodName)) {
136
+ return null;
137
+ }
138
+
139
+ // The declaring class, not the receiver: an inherited method lives in the parent, and an
140
+ // edge to the child would point at a body that does not exist there. For a trait method
141
+ // the declaring class is the class using the trait, whose file holds no such body either,
142
+ // so the trait is named instead. Only the native reflection knows its trait; a wrapped
143
+ // reflection falls back to the class, as before.
144
+ $method = $receiver->getMethod($methodName, $scope);
145
+ $declaring = $method instanceof PhpMethodReflection
146
+ ? ($method->getDeclaringTrait() ?? $method->getDeclaringClass())
147
+ : $method->getDeclaringClass();
148
+
149
+ return CallEdge::create(
150
+ $callerClass,
151
+ $callerMethod,
152
+ $declaring->getName(),
153
+ $methodName,
154
+ $declaring->isInterface(),
155
+ $node->getStartLine(),
156
+ '',
157
+ // The method name's token, not the expression's: in a chain `$a->get()->get()` every
158
+ // link starts at `$a`, and the name is the one token each call owns alone.
159
+ $node->name->getStartTokenPos(),
160
+ $declaring->getFileName() ?: '',
161
+ );
162
+ }
163
+ }
graphify_php/ports.py ADDED
@@ -0,0 +1,174 @@
1
+ """The interfaces this package is built around.
2
+
3
+ There are two ways to learn that `$this->emails` is an `Emails`: parse the file, or ask a PHP
4
+ type engine that already knows. They have different costs — the parser needs nothing, the
5
+ engine needs the project's `vendor/` and runs PHP — and different reach: the engine resolves
6
+ untyped properties, chains and docblocks that the parser must refuse.
7
+
8
+ Both are therefore the same thing to the code that builds edges: a source of resolved call
9
+ targets. That is `CallTargetSource` below, and it is the only thing `resolver` depends on.
10
+ Adding a third source later — a language server, an LSIF index — means implementing one
11
+ protocol, not editing the resolver.
12
+
13
+ `Confidence` is not decoration. graphify already distinguishes EXTRACTED from INFERRED on its
14
+ own edges, and the mesh surfaces that distinction to callers; a target proved by a type engine
15
+ and one guessed from a declaration must not arrive looking alike.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass
22
+ from enum import Enum
23
+ from typing import Protocol, runtime_checkable
24
+
25
+
26
+ class Confidence(str, Enum):
27
+ """How the target was established, in graphify's own vocabulary."""
28
+
29
+ EXTRACTED = "EXTRACTED" # the receiver's type is stated in source and was read there
30
+ INFERRED = "INFERRED" # derived: a constructor-body assignment, a `new` binding
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CallSite:
35
+ """One `$receiver->method(...)` that graphify could not resolve.
36
+
37
+ `caller_nid` is graphify's node id for the enclosing callable, carried through untouched:
38
+ this package never invents node identity, it only connects nodes graphify already made.
39
+ """
40
+
41
+ caller_nid: str
42
+ receiver: str # source text of the receiver expression, e.g. `$this->emails`
43
+ method: str
44
+ source_file: str
45
+ source_location: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class CallTarget:
50
+ """A call site resolved to a class and method, with the evidence that resolved it."""
51
+
52
+ site: CallSite
53
+ class_fqn: str
54
+ method: str
55
+ confidence: Confidence
56
+ via: str # which type source proved it, for diagnosis and for the status record
57
+ # Where the declaring class lives, '' when the source does not know. Lets the graph layer
58
+ # tell a vendor target from a project class the build left out, without guessing from the FQN.
59
+ declaring_file: str = ""
60
+
61
+
62
+ @runtime_checkable
63
+ class CallTargetSource(Protocol):
64
+ """Anything that can turn unresolved call sites into targets.
65
+
66
+ The contract that matters is not in the signature: a source returns a target ONLY when it
67
+ can prove it. Six upstream issues across four code-graph projects trace their false edges
68
+ to a name-only fallback — `->get()` wired to an unrelated class, `empty()` wired to a
69
+ method called `empty`. A source that cannot prove a site leaves it out and says why
70
+ through `unresolved`, so the gap is counted rather than filled with a guess.
71
+ """
72
+
73
+ name: str
74
+
75
+ def available(self, repo_root: str) -> tuple[bool, str]:
76
+ """Can this source run here? Returns (yes, reason-if-not).
77
+
78
+ A repository with no `vendor/bin/phpstan` is a normal, expected "no": the build
79
+ records a skip and carries on with whatever other sources are available. It is not a
80
+ failure, and conflating the two is how a missing layer becomes invisible.
81
+ """
82
+
83
+ def resolve(
84
+ self,
85
+ repo_root: str,
86
+ sites: Iterable[CallSite],
87
+ corpus: Iterable[str] | None = None,
88
+ ) -> Resolution:
89
+ """Resolve `sites`, optionally with `corpus` as the set of files that may be read.
90
+
91
+ `corpus` exists because a source needs declarations from files that hold no call site
92
+ of their own. A leaf service class has nothing unresolved in it, so it never appears
93
+ among `sites`, and a source that indexes only the files it was handed cannot tell
94
+ whether that class declares the method being called on it. Measured on an intersection
95
+ type: the same call resolves when the constituent's file happens to carry a site and
96
+ stays `unknown-receiver-type` when it does not, which makes the answer depend on
97
+ unrelated code.
98
+
99
+ The caller passes the files graphify itself parsed, so the set is bounded by the build
100
+ rather than by a filesystem walk — an earlier version walked to the nearest
101
+ `composer.json` and cost 4.7 s against 0.16 s for the site files alone, while also
102
+ reading paths outside the build.
103
+
104
+ `None` means "only the files carrying sites", which stays the honest default for a
105
+ caller that has no corpus to offer.
106
+ """
107
+
108
+
109
+ @dataclass
110
+ class Resolution:
111
+ """What a source made of the sites it was given."""
112
+
113
+ targets: list[CallTarget]
114
+ unresolved: dict[str, int] # reason -> count, a fixed vocabulary per source
115
+ files_seen: int = 0
116
+
117
+
118
+ @runtime_checkable
119
+ class NodeIndex(Protocol):
120
+ """graphify's nodes, queried the one way this package needs.
121
+
122
+ Narrow on purpose: the resolver needs "which node is method M of class C", nothing else.
123
+ A wider interface would tie this package to graphify's node schema, which is the thing
124
+ most likely to move under it between releases.
125
+ """
126
+
127
+ def method_node(self, class_fqn: str, method: str) -> str | None: ...
128
+
129
+
130
+ @runtime_checkable
131
+ class EdgeSink(Protocol):
132
+ """Where resolved targets become graph edges."""
133
+
134
+ def add_call(self, caller_nid: str, callee_nid: str, target: CallTarget) -> None: ...
135
+
136
+
137
+ @runtime_checkable
138
+ class ScopeIndex(Protocol):
139
+ """What a scoped call needs from the graph, beside `NodeIndex`.
140
+
141
+ `Helper::format()` reaches graphify as a call to `Helper` with the method name dropped
142
+ (`extractors/engine.py`, the `scoped_call_expression` arm), so the site arrives without the
143
+ one field it exists to carry. The graph knows which methods a class declares, and a type
144
+ source can check each candidate against the line it came from — so the name is recoverable
145
+ without the integration layer parsing PHP. A class with ten methods costs ten lookups and
146
+ yields at most one edge.
147
+
148
+ Kept apart from `NodeIndex` because the two answer different questions and an index that
149
+ can do one need not do the other.
150
+ """
151
+
152
+ def scope_methods(self, caller_nid: str, scope: str) -> list[str]:
153
+ """Methods the named scope declares, for `self`, `parent`, `static` or a class name."""
154
+
155
+
156
+ @runtime_checkable
157
+ class RouteDeclarationSource(Protocol):
158
+ """Anything that can find route declarations in a codebase.
159
+
160
+ Same contract as `CallTargetSource`: a declaration is returned only when it was read, and a
161
+ route whose path could not be composed is counted under a fixed reason rather than guessed
162
+ into existence. It is a separate port because a route has no call site — forcing it through
163
+ `CallTargetSource` would mean inventing a `caller_nid`, and this package never invents node
164
+ identity.
165
+ """
166
+
167
+ name: str
168
+
169
+ def available(self, repo_root: str) -> tuple[bool, str]: ...
170
+
171
+ def declarations(self, repo_root: str, files: Iterable[str]) -> object:
172
+ """Route declarations found in `files`. The concrete result type lives with the source,
173
+ because the shape of a declaration is the source's business and this port only needs to
174
+ name the call."""