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 --- .../src/NativeFixedPoint.php | 153 +++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 scripts/plugin-class-classifier/src/NativeFixedPoint.php (limited to 'scripts/plugin-class-classifier/src/NativeFixedPoint.php') diff --git a/scripts/plugin-class-classifier/src/NativeFixedPoint.php b/scripts/plugin-class-classifier/src/NativeFixedPoint.php new file mode 100644 index 00000000..7b0ecc65 --- /dev/null +++ b/scripts/plugin-class-classifier/src/NativeFixedPoint.php @@ -0,0 +1,153 @@ + unreachable FQCN => 'php-native' | 'unsupported' */ + public array $categories = []; + + /** @var array> FQCN => reasons for demotion */ + public array $reasons = []; + + /** @var list 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 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; + } +} -- cgit v1.3.1