aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/plugin-class-classifier/src/NativeFixedPoint.php
blob: 7b0ecc659596fb26a0d3f4c92701925b59350d49 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<?php

declare(strict_types=1);

namespace Shirabe\PluginClassifier;

/**
 * Decides php-native vs unsupported for classes the closure never reached,
 * per "Mechanical rules" step 6: leaf-first fixed point over body
 * references.
 *
 * What a reference may legally target from real PHP running in the child
 * process:
 *
 *   - `new X` works when X exists there as executable-or-local code: a
 *     php-native class, a two-world class (real console code), a vendor
 *     class (real package code), a rust-snapshot value object (values are
 *     built locally), or a PHP builtin. Constructing a rust-proxy service
 *     is the unresolved dual-instantiation case and demotes.
 *   - `X::method()` / `X::$prop` additionally works when X is any stubbed
 *     reachable class (proxy stubs carry static methods as RPC), so only
 *     unsupported peers and unknown types demote.
 */
final class NativeFixedPoint
{
    /** @var array<string, string> unreachable FQCN => 'php-native' | 'unsupported' */
    public array $categories = [];

    /** @var array<string, list<string>> FQCN => reasons for demotion */
    public array $reasons = [];

    /** @var list<string> classes writing static props with no filed disposition */
    public array $unfiledStaticState = [];

    public function __construct(
        private readonly SourceParser $sources,
        private readonly ReachabilityClosure $closure,
        private readonly Lists $lists,
        /** @var array<string, string> reachable FQCN => preliminary category */
        private readonly array $reachableCategories,
    ) {
    }

    public function run(): void
    {
        $universe = [];
        foreach ($this->sources->classes as $fqcn => $info) {
            if (isset($this->closure->reachable[$fqcn]) || $this->lists->isTwoWorld($fqcn)) {
                continue;
            }
            if (isset($this->lists->overrides[$fqcn])) {
                // The override fixes this class's category; it does not
                // participate in the fixed point.
                continue;
            }
            $universe[$fqcn] = $info;
        }

        foreach ($universe as $fqcn => $info) {
            $this->categories[$fqcn] = 'php-native';
            if ($info->writesOwnStaticProps) {
                $disposition = $this->lists->staticDispositions[$fqcn] ?? null;
                if ($disposition === null) {
                    $this->unfiledStaticState[] = $fqcn;
                    $this->demote($fqcn, 'mutable static state with no filed disposition');
                } elseif ($disposition === 'needs-sync') {
                    $this->demote($fqcn, 'mutable static state shared with the Rust side (needs-sync)');
                }
            }
        }

        do {
            $changed = false;
            foreach ($universe as $fqcn => $info) {
                if ($this->categories[$fqcn] === 'unsupported') {
                    continue;
                }
                $bad = $this->firstBadRef($info);
                if ($bad !== null) {
                    $this->demote($fqcn, $bad);
                    $changed = true;
                }
            }
        } while ($changed);
    }

    private function firstBadRef(ClassInfo $info): ?string
    {
        foreach (array_unique($info->newRefs) as $ref) {
            $problem = $this->checkTarget($info, $ref, true);
            if ($problem !== null) {
                return $problem;
            }
        }
        foreach (array_unique($info->staticRefs) as $ref) {
            $problem = $this->checkTarget($info, $ref, false);
            if ($problem !== null) {
                return $problem;
            }
        }

        return null;
    }

    private function checkTarget(ClassInfo $info, string $ref, bool $isNew): ?string
    {
        if ($ref === $info->fqcn) {
            return null;
        }

        $category = $this->lists->overrides[$ref]['category']
            ?? $this->reachableCategories[$ref]
            ?? null;
        if ($category !== null) {
            if ($isNew && $category === 'rust-proxy') {
                return "constructs proxied service $ref (dual instantiation unresolved)";
            }

            return null;
        }

        if ($this->lists->isTwoWorld($ref)) {
            return null;
        }

        if (isset($this->sources->classes[$ref])) {
            if (($this->categories[$ref] ?? 'unsupported') === 'unsupported') {
                return ($isNew ? 'constructs' : 'statically references') . " unsupported type $ref";
            }

            return null;
        }

        if (VendorPackages::lookup($ref) !== null) {
            // Vendor packages ship as real PHP in the child process
            // regardless of their bridging category.
            return null;
        }

        if (!str_contains($ref, '\\')) {
            // Global namespace: PHP builtin.
            return null;
        }

        return ($isNew ? 'constructs' : 'statically references') . " unknown type $ref";
    }

    private function demote(string $fqcn, string $reason): void
    {
        $this->categories[$fqcn] = 'unsupported';
        $this->reasons[$fqcn][] = $reason;
    }
}