From 765b7749d31675d3cbf541b146ead1aafd6d6ae9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 21 Jul 2026 02:54:18 +0900 Subject: 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 --- .../plugin-class-classifier/src/PurityAnalyzer.php | 133 +++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 scripts/plugin-class-classifier/src/PurityAnalyzer.php (limited to 'scripts/plugin-class-classifier/src/PurityAnalyzer.php') 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 @@ + "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|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; + } +} -- cgit v1.3.1