aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/plugin-class-classifier/src/Classifier.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/Classifier.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/Classifier.php')
-rw-r--r--scripts/plugin-class-classifier/src/Classifier.php508
1 files changed, 508 insertions, 0 deletions
diff --git a/scripts/plugin-class-classifier/src/Classifier.php b/scripts/plugin-class-classifier/src/Classifier.php
new file mode 100644
index 00000000..d24a5f96
--- /dev/null
+++ b/scripts/plugin-class-classifier/src/Classifier.php
@@ -0,0 +1,508 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class Classifier
+{
+ public const CATEGORIES = [
+ 'rust-proxy', 'rust-snapshot', 'contract', 'two-world', 'php-native', 'unsupported',
+ ];
+
+ private const THROWABLE_ROOTS = ['Throwable', 'Exception', 'Error'];
+
+ public SourceParser $sources;
+
+ public ReachabilityClosure $closure;
+
+ public PurityAnalyzer $purity;
+
+ public NativeFixedPoint $native;
+
+ /** @var array<string, array<string, mixed>> FQCN => row */
+ public array $rows = [];
+
+ /** @var list<string> */
+ public array $violations = [];
+
+ /** @var array<string, bool> */
+ private array $throwableCache = [];
+
+ /** @var array<string, bool> */
+ private array $valueObjectCandidates = [];
+
+ /** @var array<string, bool> */
+ private array $statelessPureCandidates = [];
+
+ public function __construct(private readonly string $composerSrc, private readonly Lists $lists)
+ {
+ }
+
+ public function run(): void
+ {
+ $this->sources = new SourceParser();
+ $this->sources->parseTree($this->composerSrc);
+ $this->sources->finalize();
+
+ $this->closure = new ReachabilityClosure($this->sources, $this->lists);
+ $this->closure->run();
+
+ $this->purity = new PurityAnalyzer($this->sources);
+ $this->purity->run();
+
+ $this->computeLocalCandidates();
+
+ // The unreachable fixed point needs to know which reachable classes
+ // are proxied (constructing one demotes), and candidate demotion
+ // needs to know which unreachable classes ended up unsupported.
+ // Iterate the two until stable; candidate sets only shrink, so this
+ // terminates.
+ do {
+ $reachableCategories = [];
+ foreach ($this->closure->reachable as $fqcn => $bits) {
+ $info = $this->sources->classes[$fqcn] ?? null;
+ if ($info !== null) {
+ $reachableCategories[$fqcn] = $this->categoryForReachable($fqcn, $info);
+ }
+ }
+
+ $this->native = new NativeFixedPoint($this->sources, $this->closure, $this->lists, $reachableCategories);
+ $this->native->run();
+ } while ($this->demoteCandidates());
+
+ foreach ($this->sources->classes as $fqcn => $info) {
+ $this->rows[$fqcn] = $this->classify($fqcn, $info);
+ }
+
+ $this->applyOverrides();
+ $this->collectViolations();
+ ksort($this->rows);
+ }
+
+ /** @return array<string, mixed> */
+ private function classify(string $fqcn, ClassInfo $info): array
+ {
+ $reachableBits = $this->closure->reachable[$fqcn] ?? 0;
+ $row = [
+ 'fqcn' => $fqcn,
+ 'kind' => $info->kind,
+ 'reachable' => $reachableBits !== 0,
+ 'direction' => $this->directionLabel($reachableBits),
+ 'category' => null,
+ 'reasons' => [],
+ 'attributes' => [],
+ ];
+
+ if ($info->writesOwnStaticProps) {
+ $row['attributes']['mutable-static'] =
+ $this->lists->staticDispositions[$fqcn] ?? 'needs-review';
+ }
+ if ($this->isThrowable($fqcn)) {
+ $row['attributes']['throwable'] = true;
+ }
+
+ if ($this->lists->isTwoWorld($fqcn)) {
+ $row['category'] = 'two-world';
+ $row['reasons'][] = 'two-world.list';
+
+ return $row;
+ }
+
+ if ($reachableBits === 0) {
+ $row['category'] = $this->native->categories[$fqcn] ?? 'unsupported';
+ $row['reasons'] = array_merge(
+ $row['reasons'],
+ $this->native->reasons[$fqcn] ?? ['not plugin-reachable; every reference resolves to executable code in the child'],
+ );
+
+ return $row;
+ }
+
+ $category = $this->categoryForReachable($fqcn, $info);
+ $row['category'] = $category;
+ $row['reasons'][] = match ($category) {
+ 'php-native' => $this->isThrowable($fqcn)
+ ? 'exception class: real definition in the child process, crossed by value'
+ : (($this->statelessPureCandidates[$fqcn] ?? false)
+ ? 'stateless pure class: no instance state, all methods pure, real code answers identically in both worlds'
+ : 'constants-only class: pure definitions, no state to share'),
+ 'contract' => 'interface/abstract type: declaration stub, artifacts depend on direction',
+ 'rust-snapshot' => 'immutable value object: no state referencing living services, all methods pure',
+ 'rust-proxy' => 'reachable concrete class holding or reaching shared state',
+ };
+ if ($category === 'rust-proxy') {
+ $row['attributes']['plugin-constructible'] = $this->hasPublicConstructor($info);
+ }
+ if (in_array($category, ['rust-proxy', 'rust-snapshot'], true)) {
+ // Stub static properties cannot be intercepted in PHP (there is
+ // no __getStatic); public ones need an explicit decision.
+ $statics = [];
+ foreach ($info->properties as $prop) {
+ if ($prop->static && $prop->visibility === 'public') {
+ $statics[] = $prop->name;
+ }
+ }
+ if ($statics !== []) {
+ sort($statics);
+ $row['attributes']['public-static-properties'] = $statics;
+ }
+ }
+ if (in_array($category, ['rust-proxy', 'rust-snapshot', 'contract'], true)) {
+ $row['methods'] = $this->methodRows($fqcn, $info);
+ }
+
+ return $row;
+ }
+
+ private function categoryForReachable(string $fqcn, ClassInfo $info): string
+ {
+ if ($this->isThrowable($fqcn)) {
+ return 'php-native';
+ }
+ // Contract wins over constants-only: a marker interface such as
+ // Capability is still implemented by plugins and needs direction
+ // attributes, not just its constant-free declaration.
+ if ($info->isContractLike() || $info->kind === 'trait') {
+ return 'contract';
+ }
+ if ($this->isConstantsOnly($info)) {
+ return 'php-native';
+ }
+ if ($this->statelessPureCandidates[$fqcn] ?? false) {
+ return 'php-native';
+ }
+ if ($this->valueObjectCandidates[$fqcn] ?? false) {
+ return 'rust-snapshot';
+ }
+
+ return 'rust-proxy';
+ }
+
+ /** @return list<array<string, mixed>> */
+ private function methodRows(string $fqcn, ClassInfo $info): array
+ {
+ $rows = [];
+ foreach ($info->methods as $lname => $method) {
+ if ($method->visibility === 'private') {
+ continue;
+ }
+ $callableParams = [];
+ foreach ($method->params as $i => $param) {
+ if ($param->callable) {
+ $callableParams[] = $i;
+ }
+ }
+ $rows[] = [
+ 'name' => $method->name,
+ 'visibility' => $method->visibility,
+ 'static' => $method->static,
+ 'purity' => $this->purity->verdicts["$fqcn::$lname"] ?? 'n/a',
+ 'byRefParams' => $method->byRefParamPositions(),
+ 'callableParams' => $callableParams,
+ ];
+ }
+ usort($rows, static fn (array $a, array $b) => strcmp($a['name'], $b['name']));
+
+ return $rows;
+ }
+
+ private function directionLabel(int $bits): ?string
+ {
+ return match ($bits) {
+ 0 => null,
+ ReachabilityClosure::PROVIDED => 'provided',
+ ReachabilityClosure::CONSUMED => 'consumed',
+ default => 'both',
+ };
+ }
+
+ private function isThrowable(string $fqcn): bool
+ {
+ if (isset($this->throwableCache[$fqcn])) {
+ return $this->throwableCache[$fqcn];
+ }
+ // Pre-set to break inheritance cycles (malformed input).
+ $this->throwableCache[$fqcn] = false;
+
+ $info = $this->sources->classes[$fqcn] ?? null;
+ if ($info === null) {
+ $isGlobal = !str_contains($fqcn, '\\');
+ $result = $isGlobal && (
+ in_array($fqcn, self::THROWABLE_ROOTS, true)
+ || str_ends_with($fqcn, 'Exception')
+ || str_ends_with($fqcn, 'Error')
+ );
+
+ return $this->throwableCache[$fqcn] = $result;
+ }
+
+ foreach (array_merge($info->parent !== null ? [$info->parent] : [], $info->interfaces) as $ancestor) {
+ if ($this->isThrowable($ancestor)) {
+ return $this->throwableCache[$fqcn] = true;
+ }
+ }
+
+ return false;
+ }
+
+ private function hasPublicConstructor(ClassInfo $info): bool
+ {
+ if ($info->abstract) {
+ return false;
+ }
+ $current = $info;
+ while (true) {
+ $ctor = $current->methods['__construct'] ?? null;
+ if ($ctor !== null) {
+ return $ctor->visibility === 'public';
+ }
+ if ($current->parent === null || !isset($this->sources->classes[$current->parent])) {
+ // No declared constructor anywhere visible: implicit public.
+ return true;
+ }
+ $current = $this->sources->classes[$current->parent];
+ }
+ }
+
+ private function computeLocalCandidates(): void
+ {
+ // Grow-only fixed point over two candidate kinds. Both require
+ // every instance method in the hierarchy to be pure and the body
+ // references to be locally satisfiable; they differ on state:
+ //
+ // - value objects carry copyable state (>= 1 instance property)
+ // none of which references a living service -> rust-snapshot
+ // - stateless pure classes carry no instance state at all;
+ // a snapshot has nothing to copy, and the real code answers
+ // identically in both worlds -> php-native
+ do {
+ $changed = false;
+ foreach ($this->closure->reachable as $fqcn => $bits) {
+ if (($this->valueObjectCandidates[$fqcn] ?? false) || ($this->statelessPureCandidates[$fqcn] ?? false)) {
+ continue;
+ }
+ $info = $this->sources->classes[$fqcn] ?? null;
+ if ($info === null || $info->isContractLike() || $info->kind !== 'class') {
+ continue;
+ }
+ if ($this->lists->isTwoWorld($fqcn) || $this->isThrowable($fqcn) || $this->isConstantsOnly($info)) {
+ continue;
+ }
+ if (!$this->allInstanceMethodsPure($fqcn) || !$this->bodyRefsAreLocal($info)) {
+ continue;
+ }
+ if ($this->hasInstanceProperties($info)) {
+ if ($this->stateIsValueOnly($info)) {
+ $this->valueObjectCandidates[$fqcn] = true;
+ $changed = true;
+ }
+ } else {
+ $this->statelessPureCandidates[$fqcn] = true;
+ $changed = true;
+ }
+ }
+ } while ($changed);
+ }
+
+ private function hasInstanceProperties(ClassInfo $info): bool
+ {
+ foreach ($this->hierarchyOf($info) as $level) {
+ foreach ($level->properties as $prop) {
+ if (!$prop->static) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Snapshot classes ship their real method bodies; stateless pure
+ * classes run as real code. Either way, what the bodies construct must
+ * be locally constructible: another candidate, an exception, real
+ * vendor/two-world/builtin code — not a proxied service.
+ */
+ private function bodyRefsAreLocal(ClassInfo $info): bool
+ {
+ foreach (array_unique($info->newRefs) as $ref) {
+ if ($ref === $info->fqcn || $this->isCandidate($ref) || $this->isThrowable($ref)) {
+ continue;
+ }
+ if (isset($this->closure->reachable[$ref])) {
+ $target = $this->sources->classes[$ref] ?? null;
+ if ($target !== null && ($this->isConstantsOnly($target) || $this->lists->isTwoWorld($ref))) {
+ continue;
+ }
+
+ // Constructs what will be a proxied service.
+ return false;
+ }
+ // Unreachable, vendor, or builtin targets are checked again
+ // once the unreachable fixed point has run (demoteCandidates).
+ }
+
+ return true;
+ }
+
+ private function isCandidate(string $fqcn): bool
+ {
+ return ($this->valueObjectCandidates[$fqcn] ?? false) || ($this->statelessPureCandidates[$fqcn] ?? false);
+ }
+
+ /**
+ * Re-check candidates against the unreachable fixed point's outcome:
+ * a candidate whose bodies construct or statically reference an
+ * unsupported class cannot run locally after all. Returns true when
+ * anything was demoted (the caller then reruns the fixed point).
+ */
+ private function demoteCandidates(): bool
+ {
+ $demoted = false;
+ do {
+ $changed = false;
+ foreach (array_merge(array_keys($this->valueObjectCandidates), array_keys($this->statelessPureCandidates)) as $fqcn) {
+ if (!$this->isCandidate($fqcn)) {
+ continue;
+ }
+ $info = $this->sources->classes[$fqcn];
+ if ($this->bodyRefsAreLocal($info) && !$this->refsUnsupported($info)) {
+ continue;
+ }
+ unset($this->valueObjectCandidates[$fqcn], $this->statelessPureCandidates[$fqcn]);
+ $changed = true;
+ $demoted = true;
+ }
+ } while ($changed);
+
+ return $demoted;
+ }
+
+ private function refsUnsupported(ClassInfo $info): bool
+ {
+ foreach (array_unique(array_merge($info->newRefs, $info->staticRefs)) as $ref) {
+ if (($this->native->categories[$ref] ?? null) === 'unsupported') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function stateIsValueOnly(ClassInfo $info): bool
+ {
+ $types = [];
+ // State includes inherited properties: a VcsDownloader subclass
+ // carries its parent's ProcessExecutor even with no own fields.
+ foreach ($this->hierarchyOf($info) as $level) {
+ foreach ($level->properties as $prop) {
+ $types = array_merge($types, $prop->classTypes);
+ if ($prop->expandable || $prop->classTypes === []) {
+ $types = array_merge($types, $prop->docblockTypes);
+ }
+ }
+ }
+ $ctor = $info->methods['__construct'] ?? null;
+ if ($ctor !== null) {
+ foreach ($ctor->params as $param) {
+ $types = array_merge($types, $param->classTypes);
+ if ($param->expandable || $param->classTypes === []) {
+ $types = array_merge($types, $param->docblockTypes);
+ }
+ }
+ }
+
+ foreach (array_unique($types) as $type) {
+ if ($type === $info->fqcn) {
+ continue;
+ }
+ if (!isset($this->closure->reachable[$type]) && !isset($this->sources->classes[$type])) {
+ // Vendor or builtin: value-safe only if php-native.
+ $vendor = VendorPackages::lookup($type);
+ if ($vendor !== null && $vendor['category'] !== 'php-native') {
+ return false;
+ }
+ continue;
+ }
+ if ($this->isThrowable($type)) {
+ continue;
+ }
+ if (!$this->isCandidate($type)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** @return list<ClassInfo> the class and its parents visible in the sources */
+ private function hierarchyOf(ClassInfo $info): array
+ {
+ $levels = [];
+ $seen = [];
+ $current = $info;
+ while (true) {
+ if (isset($seen[$current->fqcn])) {
+ break;
+ }
+ $seen[$current->fqcn] = true;
+ $levels[] = $current;
+ if ($current->parent === null || !isset($this->sources->classes[$current->parent])) {
+ break;
+ }
+ $current = $this->sources->classes[$current->parent];
+ }
+
+ return $levels;
+ }
+
+ private function allInstanceMethodsPure(string $fqcn): bool
+ {
+ $info = $this->sources->classes[$fqcn];
+ foreach ($this->hierarchyOf($info) as $level) {
+ foreach ($level->methods as $lname => $method) {
+ if ($method->static || $method->visibility === 'private' || $lname === '__construct') {
+ continue;
+ }
+ $verdict = $this->purity->verdicts["{$level->fqcn}::$lname"] ?? 'mutator';
+ if ($verdict === 'mutator') {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private function isConstantsOnly(ClassInfo $info): bool
+ {
+ return $info->methods === [] && $info->properties === []
+ && $info->parent === null && $info->interfaces === [];
+ }
+
+ private function applyOverrides(): void
+ {
+ foreach ($this->lists->overrides as $fqcn => $override) {
+ if (!isset($this->rows[$fqcn])) {
+ $this->violations[] = "overrides.list: unknown class $fqcn";
+ continue;
+ }
+ $this->rows[$fqcn]['computedCategory'] = $this->rows[$fqcn]['category'];
+ $this->rows[$fqcn]['category'] = $override['category'];
+ $this->rows[$fqcn]['reasons'][] = 'override: ' . $override['reason'];
+ }
+ }
+
+ private function collectViolations(): void
+ {
+ foreach ($this->rows as $fqcn => $row) {
+ if (($row['attributes']['mutable-static'] ?? null) === 'needs-review') {
+ $this->violations[] = "$fqcn writes static properties but has no disposition in static-state.list";
+ }
+ }
+ foreach ($this->closure->unknownReachable as $fqcn => $bits) {
+ $this->violations[] = "reachable type $fqcn is neither a composer class, a known vendor package, nor a PHP builtin";
+ }
+ }
+}