aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/plugin-class-classifier/src/PurityAnalyzer.php
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-07-21 02:54:18 +0900
committernsfisis <nsfisis@gmail.com>2026-07-23 22:07:16 +0900
commit765b7749d31675d3cbf541b146ead1aafd6d6ae9 (patch)
tree11f8a07cd9e3d4ef92bb2f75a0811e41b1d928ba /scripts/plugin-class-classifier/src/PurityAnalyzer.php
parent770013c7097b6b5c0c49c7fa46d9235c308c4ad5 (diff)
downloadphp-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.tar.gz
php-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.tar.zst
php-shirabe-765b7749d31675d3cbf541b146ead1aafd6d6ae9.zip
feat(plugin-class-classifier): add deterministic plugin-boundary classifier
Decides, for every composer/composer class, how it is treated at the plugin boundary (rust-proxy / rust-snapshot / contract / two-world / php-native / unsupported) so that upstream updates re-classify new or rewritten classes without re-deriving the design by hand. Rules and category definitions live in docs/dev/plugin-class-classification.md; the tool (PHP + nikic/PHP-Parser) implements them as a reachability closure with direction marks, per-method pure/mutator analysis, and a leaf-first fixed point for unreachable classes, with three small versioned exception lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'scripts/plugin-class-classifier/src/PurityAnalyzer.php')
-rw-r--r--scripts/plugin-class-classifier/src/PurityAnalyzer.php133
1 files changed, 133 insertions, 0 deletions
diff --git a/scripts/plugin-class-classifier/src/PurityAnalyzer.php b/scripts/plugin-class-classifier/src/PurityAnalyzer.php
new file mode 100644
index 00000000..84e355c5
--- /dev/null
+++ b/scripts/plugin-class-classifier/src/PurityAnalyzer.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Per-method pure/mutator decision (see "pure/mutator" in
+ * docs/dev/plugin-class-classification.md): a method is a mutator iff it
+ * writes $this directly, passes a $this-rooted expression into a by-ref
+ * parameter, contains a statically unresolvable call that a $this-rooted
+ * expression escapes into, or (transitively) calls a mutator method on the
+ * same object.
+ */
+final class PurityAnalyzer
+{
+ /** @var array<string, string> "Fqcn::lowername" => 'pure' | 'mutator' | 'n/a' */
+ public array $verdicts = [];
+
+ public function __construct(private readonly SourceParser $sources)
+ {
+ }
+
+ public function run(): void
+ {
+ // Seed with local evidence.
+ foreach ($this->sources->classes as $fqcn => $info) {
+ foreach ($info->methods as $lname => $method) {
+ $key = "$fqcn::$lname";
+ if ($method->static || $method->abstract) {
+ $this->verdicts[$key] = 'n/a';
+ continue;
+ }
+ $this->verdicts[$key] = $this->locallyMutates($fqcn, $method) ? 'mutator' : 'pure';
+ }
+ }
+
+ // Propagate through same-object calls until stable.
+ do {
+ $changed = false;
+ foreach ($this->sources->classes as $fqcn => $info) {
+ foreach ($info->methods as $lname => $method) {
+ $key = "$fqcn::$lname";
+ if ($this->verdicts[$key] !== 'pure') {
+ continue;
+ }
+ foreach ($method->selfCalls as $call) {
+ $callee = $this->resolveMethod($fqcn, $call['name']);
+ if ($callee === null) {
+ // Magic __call or a method we cannot see.
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ [$calleeClass, $calleeMethod] = $callee;
+ $calleeVerdict = $this->verdicts["$calleeClass::" . strtolower($calleeMethod->name)] ?? 'mutator';
+ if ($calleeVerdict === 'mutator') {
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ if (array_intersect($call['thisArgs'], $calleeMethod->byRefParamPositions()) !== []) {
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ }
+ }
+ }
+ } while ($changed);
+ }
+
+ private function locallyMutates(string $fqcn, MethodInfo $method): bool
+ {
+ if ($method->mutatesThisDirectly || $method->thisEscapesUnresolved) {
+ return true;
+ }
+
+ foreach ($method->externalCalls as $call) {
+ $positions = $this->byRefPositionsOf($call['class'], $call['method']);
+ if ($positions === null) {
+ // Callee signature unknown: conservative.
+ return true;
+ }
+ if (array_intersect($call['thisArgs'], $positions) !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @return list<int>|null null when the signature cannot be resolved */
+ private function byRefPositionsOf(string $class, string $lowerMethod): ?array
+ {
+ $resolved = $this->resolveMethod($class, $lowerMethod);
+ if ($resolved !== null) {
+ return $resolved[1]->byRefParamPositions();
+ }
+
+ $vendor = VendorPackages::BY_REF[$class] ?? null;
+ if ($vendor !== null) {
+ return $vendor[$lowerMethod] ?? [];
+ }
+ if (VendorPackages::lookup($class) !== null) {
+ // Vendor packages other than the ones tabled expose no by-ref
+ // parameters in APIs composer calls.
+ return [];
+ }
+
+ return null;
+ }
+
+ /** @return array{string, MethodInfo}|null resolved (declaring class, method) */
+ private function resolveMethod(string $class, string $lowerMethod): ?array
+ {
+ $seen = [];
+ $current = $class;
+ while ($current !== null && !isset($seen[$current])) {
+ $seen[$current] = true;
+ $info = $this->sources->classes[$current] ?? null;
+ if ($info === null) {
+ return null;
+ }
+ if (isset($info->methods[$lowerMethod])) {
+ return [$current, $info->methods[$lowerMethod]];
+ }
+ $current = $info->parent;
+ }
+
+ return null;
+ }
+}