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
|
@@ -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."""
|