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 --- scripts/plugin-class-classifier/.gitignore | 2 + scripts/plugin-class-classifier/classify | 40 ++ scripts/plugin-class-classifier/composer.json | 15 + scripts/plugin-class-classifier/composer.lock | 122 +++++ .../plugin-class-classifier/lists/overrides.list | 7 + .../lists/static-state.list | 21 + .../plugin-class-classifier/lists/two-world.list | 6 + .../plugin-class-classifier/src/BodyAnalyzer.php | 335 ++++++++++++++ .../src/BuiltinSignatures.php | 126 +++++ scripts/plugin-class-classifier/src/ClassInfo.php | 63 +++ scripts/plugin-class-classifier/src/Classifier.php | 508 +++++++++++++++++++++ .../src/DocblockTypeExtractor.php | 197 ++++++++ scripts/plugin-class-classifier/src/Lists.php | 84 ++++ scripts/plugin-class-classifier/src/MethodInfo.php | 71 +++ .../src/NativeFixedPoint.php | 153 +++++++ scripts/plugin-class-classifier/src/ParamInfo.php | 24 + .../plugin-class-classifier/src/PropertyInfo.php | 22 + .../plugin-class-classifier/src/PurityAnalyzer.php | 133 ++++++ .../src/ReachabilityClosure.php | 221 +++++++++ scripts/plugin-class-classifier/src/Report.php | 103 +++++ .../plugin-class-classifier/src/SourceParser.php | 416 +++++++++++++++++ .../plugin-class-classifier/src/VendorPackages.php | 69 +++ 22 files changed, 2738 insertions(+) create mode 100644 scripts/plugin-class-classifier/.gitignore create mode 100755 scripts/plugin-class-classifier/classify create mode 100644 scripts/plugin-class-classifier/composer.json create mode 100644 scripts/plugin-class-classifier/composer.lock create mode 100644 scripts/plugin-class-classifier/lists/overrides.list create mode 100644 scripts/plugin-class-classifier/lists/static-state.list create mode 100644 scripts/plugin-class-classifier/lists/two-world.list create mode 100644 scripts/plugin-class-classifier/src/BodyAnalyzer.php create mode 100644 scripts/plugin-class-classifier/src/BuiltinSignatures.php create mode 100644 scripts/plugin-class-classifier/src/ClassInfo.php create mode 100644 scripts/plugin-class-classifier/src/Classifier.php create mode 100644 scripts/plugin-class-classifier/src/DocblockTypeExtractor.php create mode 100644 scripts/plugin-class-classifier/src/Lists.php create mode 100644 scripts/plugin-class-classifier/src/MethodInfo.php create mode 100644 scripts/plugin-class-classifier/src/NativeFixedPoint.php create mode 100644 scripts/plugin-class-classifier/src/ParamInfo.php create mode 100644 scripts/plugin-class-classifier/src/PropertyInfo.php create mode 100644 scripts/plugin-class-classifier/src/PurityAnalyzer.php create mode 100644 scripts/plugin-class-classifier/src/ReachabilityClosure.php create mode 100644 scripts/plugin-class-classifier/src/Report.php create mode 100644 scripts/plugin-class-classifier/src/SourceParser.php create mode 100644 scripts/plugin-class-classifier/src/VendorPackages.php (limited to 'scripts/plugin-class-classifier') diff --git a/scripts/plugin-class-classifier/.gitignore b/scripts/plugin-class-classifier/.gitignore new file mode 100644 index 00000000..f7db2fa7 --- /dev/null +++ b/scripts/plugin-class-classifier/.gitignore @@ -0,0 +1,2 @@ +vendor/ +report.json diff --git a/scripts/plugin-class-classifier/classify b/scripts/plugin-class-classifier/classify new file mode 100755 index 00000000..03406ac2 --- /dev/null +++ b/scripts/plugin-class-classifier/classify @@ -0,0 +1,40 @@ +#!/usr/bin/env php +run(); + +$report = new Report($classifier); +$report->writeJson($out); +$report->printSummary(); + +fwrite(STDERR, "report written to $out\n"); +exit($classifier->violations === [] ? 0 : 1); diff --git a/scripts/plugin-class-classifier/composer.json b/scripts/plugin-class-classifier/composer.json new file mode 100644 index 00000000..853e755e --- /dev/null +++ b/scripts/plugin-class-classifier/composer.json @@ -0,0 +1,15 @@ +{ + "name": "shirabe/plugin-class-classifier", + "description": "Deterministic classifier for Composer classes at the Shirabe plugin boundary", + "license": "MIT", + "require": { + "php": ">=8.1", + "nikic/php-parser": "^5.0", + "jetbrains/phpstorm-stubs": "^2026.1" + }, + "autoload": { + "psr-4": { + "Shirabe\\PluginClassifier\\": "src/" + } + } +} diff --git a/scripts/plugin-class-classifier/composer.lock b/scripts/plugin-class-classifier/composer.lock new file mode 100644 index 00000000..b836b820 --- /dev/null +++ b/scripts/plugin-class-classifier/composer.lock @@ -0,0 +1,122 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3dc1707f38fff8158ce4675b45d8915c", + "packages": [ + { + "name": "jetbrains/phpstorm-stubs", + "version": "v2026.1", + "source": { + "type": "git", + "url": "https://github.com/JetBrains/phpstorm-stubs", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243", + "reference": "2cdd054c4109dfb76667c9198bf9427606354243", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.86", + "nikic/php-parser": "^v5.6", + "phpdocumentor/reflection-docblock": "^5.6", + "phpunit/phpunit": "^12.3" + }, + "type": "library", + "autoload": { + "files": [ + "PhpStormStubsMap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "PHP runtime & extensions header files for PhpStorm", + "homepage": "https://www.jetbrains.com/phpstorm", + "keywords": [ + "autocomplete", + "code", + "inference", + "inspection", + "jetbrains", + "phpstorm", + "stubs", + "type" + ], + "time": "2026-02-19T20:12:01+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/plugin-class-classifier/lists/overrides.list b/scripts/plugin-class-classifier/lists/overrides.list new file mode 100644 index 00000000..099578e0 --- /dev/null +++ b/scripts/plugin-class-classifier/lists/overrides.list @@ -0,0 +1,7 @@ +# Per-class category corrections: "FQCN category reason...". Every entry +# must carry a reason. Keep this list short; prefer fixing the rules. +Composer\Util\Platform rust-proxy env table is shared process state; the child's Platform stub RPCs putEnv/getEnv/clearEnv so both worlds see the same environment +Composer\Autoload\ClassLoader php-native the child bootstraps via the real vendor/composer/ClassLoader.php before any stub could load; a same-FQCN stub cannot shadow it. Registered-loader state is per-world. +Composer\InstalledVersions php-native same bootstrap constraint as ClassLoader (real file in vendor/composer is always loaded); its static $installed is genuinely shared state — Rust must push a reload after each install dump (see static-state.list needs-sync) +Composer\Platform\HhvmDetector php-native stateless environment probe; only reachable as a consumed ctor param of PlatformRepository, no Rust-owned instance is ever provided to plugins. Plugin-custom detectors passed into a proxied PlatformRepository become an explicit error. +Composer\Package\Version\VersionGuesser php-native stateless computation over VCS output; only reachable as a consumed ctor param of RootPackageLoader, no Rust-owned instance is ever provided to plugins. Plugin-custom guessers passed into proxied ctors become an explicit error. diff --git a/scripts/plugin-class-classifier/lists/static-state.list b/scripts/plugin-class-classifier/lists/static-state.list new file mode 100644 index 00000000..a33faeec --- /dev/null +++ b/scripts/plugin-class-classifier/lists/static-state.list @@ -0,0 +1,21 @@ +# Disposition of classes that write static properties. +# memo-cache pure memoization; each world computes its own copy +# seed-once copied from the Rust side once at child startup +# needs-sync genuinely shared process state; must not be php-native +# A class writing statics without an entry here fails the run. +Composer\Autoload\ClassLoader memo-cache +Composer\Cache memo-cache +Composer\Downloader\FileDownloader needs-sync +Composer\Downloader\ZipDownloader memo-cache +Composer\InstalledVersions needs-sync +Composer\Package\Version\VersionParser memo-cache +Composer\Platform\HhvmDetector memo-cache +Composer\Repository\PlatformRepository memo-cache +Composer\Util\ErrorHandler memo-cache +Composer\Util\Git memo-cache +Composer\Util\Hg memo-cache +Composer\Util\Http\ProxyManager memo-cache +Composer\Util\Platform needs-sync +Composer\Util\ProcessExecutor seed-once +Composer\Util\Silencer memo-cache +Composer\Util\Svn memo-cache diff --git a/scripts/plugin-class-classifier/lists/two-world.list b/scripts/plugin-class-classifier/lists/two-world.list new file mode 100644 index 00000000..11425cf6 --- /dev/null +++ b/scripts/plugin-class-classifier/lists/two-world.list @@ -0,0 +1,6 @@ +# Types with independent sibling implementations in each world +# (docs/dev/plugin-class-classification.md, category two-world). +# One namespace prefix or exact FQCN per line. +Composer\Console +Composer\Command +Symfony\Component\Console diff --git a/scripts/plugin-class-classifier/src/BodyAnalyzer.php b/scripts/plugin-class-classifier/src/BodyAnalyzer.php new file mode 100644 index 00000000..ad9d3795 --- /dev/null +++ b/scripts/plugin-class-classifier/src/BodyAnalyzer.php @@ -0,0 +1,335 @@ + */ + public array $newRefs = []; + + /** @var list */ + public array $staticRefs = []; + + /** @var list */ + public array $benignRefs = []; + + public bool $writesOwnStaticProps = false; + + public function __construct( + private readonly MethodInfo $method, + private readonly string $currentClass, + /** @var array property name => single class type FQCN */ + private readonly array $propertyTypes, + /** @var array param name => single class type FQCN */ + private readonly array $paramTypes, + private readonly BuiltinSignatures $builtins, + ) { + } + + public static function analyze(ClassMethod $node, MethodInfo $method, string $currentClass, array $propertyTypes, array $paramTypes, BuiltinSignatures $builtins): self + { + $analyzer = new self($method, $currentClass, $propertyTypes, $paramTypes, $builtins); + if ($node->stmts !== null) { + $traverser = new NodeTraverser(); + $traverser->addVisitor($analyzer); + $traverser->traverse($node->stmts); + } + + return $analyzer; + } + + public function enterNode(Node $node): null + { + if ($node instanceof Expr\Assign || $node instanceof Expr\AssignOp || $node instanceof Expr\AssignRef) { + $this->handleAssignTarget($node->var); + } elseif ($node instanceof Node\Stmt\Unset_) { + foreach ($node->vars as $var) { + if ($this->isThisRooted($var)) { + $this->method->mutatesThisDirectly = true; + } + } + } elseif ($node instanceof Expr\PreInc || $node instanceof Expr\PostInc + || $node instanceof Expr\PreDec || $node instanceof Expr\PostDec) { + if ($this->isThisRooted($node->var)) { + $this->method->mutatesThisDirectly = true; + } + } elseif ($node instanceof Expr\FuncCall) { + $this->handleFuncCall($node); + } elseif ($node instanceof Expr\MethodCall || $node instanceof Expr\NullsafeMethodCall) { + $this->handleMethodCall($node); + } elseif ($node instanceof Expr\StaticCall) { + $this->handleStaticCall($node); + } elseif ($node instanceof Expr\New_) { + $this->handleNew($node); + } elseif ($node instanceof Expr\Instanceof_) { + if ($node->class instanceof Name) { + $this->benignRefs[] = $node->class->toString(); + } + } elseif ($node instanceof Node\Stmt\Catch_) { + foreach ($node->types as $type) { + $this->benignRefs[] = $type->toString(); + } + } elseif ($node instanceof Expr\ClassConstFetch) { + if ($node->class instanceof Name && !$this->isSelfLike($node->class)) { + $this->benignRefs[] = $node->class->toString(); + } + } elseif ($node instanceof Expr\StaticPropertyFetch) { + // Reads are benign; writes are caught via handleAssignTarget. + if ($node->class instanceof Name && !$this->isSelfLike($node->class)) { + $this->benignRefs[] = $node->class->toString(); + } + } + + return null; + } + + private function handleAssignTarget(Expr $target): void + { + // Destructuring assigns to several targets at once. + if ($target instanceof Expr\List_ || $target instanceof Expr\Array_) { + foreach ($target->items as $item) { + if ($item !== null) { + $this->handleAssignTarget($item->value); + } + } + + return; + } + + if ($this->isThisRooted($target)) { + $this->method->mutatesThisDirectly = true; + + return; + } + + $root = $this->rootOf($target); + if ($root instanceof Expr\StaticPropertyFetch && $root->class instanceof Name) { + if ($this->isSelfLike($root->class) || $root->class->toString() === $this->currentClass) { + $this->writesOwnStaticProps = true; + } else { + $this->staticRefs[] = $root->class->toString(); + } + } + } + + private function handleFuncCall(Expr\FuncCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + + if (!$call->name instanceof Name) { + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + + return; + } + + if ($thisArgs === []) { + return; + } + + $name = strtolower($call->name->toString()); + + // Callback-forwarding builtins re-dispatch their arguments to an + // unknown callee, so the stub signature (no by-ref) is not the + // whole truth: [$this, 'method'] can reach a mutator. + if (in_array($name, ['call_user_func', 'call_user_func_array', 'array_walk', 'array_walk_recursive', 'usort', 'uasort', 'uksort'], true)) { + $this->method->thisEscapesUnresolved = true; + + return; + } + + $info = $this->builtins->byRefInfo($name); + if ($info === null) { + // Not a known builtin (user-land global function, unknown + // extension): conservative. + $this->method->thisEscapesUnresolved = true; + + return; + } + + foreach ($thisArgs as $pos) { + if (in_array($pos, $info['fixed'], true) + || ($info['variadicFrom'] !== null && $pos >= $info['variadicFrom'])) { + $this->method->mutatesThisDirectly = true; + + return; + } + } + } + + private function handleMethodCall(Expr\MethodCall|Expr\NullsafeMethodCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + $literalName = $call->name instanceof Node\Identifier ? strtolower($call->name->toString()) : null; + + $receiverIsThis = $call->var instanceof Expr\Variable && $call->var->name === 'this'; + + if ($receiverIsThis) { + if ($literalName === null) { + // $this->$method(): both the callee and any argument escape + // are unresolvable. + $this->method->thisEscapesUnresolved = true; + + return; + } + $this->method->selfCalls[] = ['name' => $literalName, 'thisArgs' => $thisArgs]; + + return; + } + + if ($thisArgs === []) { + return; + } + + $receiverClass = $this->resolveReceiverClass($call->var); + if ($receiverClass === null || $literalName === null) { + $this->method->thisEscapesUnresolved = true; + + return; + } + + $this->method->externalCalls[] = ['class' => $receiverClass, 'method' => $literalName, 'thisArgs' => $thisArgs]; + } + + private function handleStaticCall(Expr\StaticCall $call): void + { + $thisArgs = $this->thisArgPositions($call->args); + $literalName = $call->name instanceof Node\Identifier ? strtolower($call->name->toString()) : null; + + if ($call->class instanceof Name && $this->isSelfLike($call->class)) { + if ($literalName === null) { + $this->method->thisEscapesUnresolved = true; + + return; + } + $this->method->selfCalls[] = ['name' => $literalName, 'thisArgs' => $thisArgs]; + + return; + } + + if ($call->class instanceof Name) { + $className = $call->class->toString(); + $this->staticRefs[] = $className; + if ($thisArgs !== []) { + if ($literalName === null) { + $this->method->thisEscapesUnresolved = true; + } else { + $this->method->externalCalls[] = ['class' => $className, 'method' => $literalName, 'thisArgs' => $thisArgs]; + } + } + + return; + } + + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + } + + private function handleNew(Expr\New_ $new): void + { + $thisArgs = $this->thisArgPositions($new->args); + + if (!$new->class instanceof Name) { + if ($thisArgs !== []) { + $this->method->thisEscapesUnresolved = true; + } + + return; + } + + if ($this->isSelfLike($new->class)) { + return; + } + + $className = $new->class->toString(); + $this->newRefs[] = $className; + if ($thisArgs !== []) { + $this->method->externalCalls[] = ['class' => $className, 'method' => '__construct', 'thisArgs' => $thisArgs]; + } + } + + /** @param array $args */ + private function thisArgPositions(array $args): array + { + $positions = []; + foreach ($args as $i => $arg) { + if (!$arg instanceof Node\Arg) { + continue; + } + if ($this->isThisRooted($arg->value)) { + $positions[] = $i; + continue; + } + // [$this, 'method'] callback literals: the receiver escapes + // into the argument even though the array itself is fresh. + if ($arg->value instanceof Expr\Array_) { + foreach ($arg->value->items as $item) { + if ($item !== null && $this->isThisRooted($item->value)) { + $positions[] = $i; + break; + } + } + } + } + + return $positions; + } + + private function isThisRooted(Expr $expr): bool + { + $root = $this->rootOf($expr); + + return $root instanceof Expr\Variable && $root->name === 'this'; + } + + private function rootOf(Expr $expr): Expr + { + while (true) { + if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + $expr = $expr->var; + } elseif ($expr instanceof Expr\ArrayDimFetch) { + $expr = $expr->var; + } else { + return $expr; + } + } + } + + private function isSelfLike(Name $name): bool + { + return in_array(strtolower($name->toString()), ['self', 'static', 'parent'], true); + } + + /** Resolve the receiver's class when it is a typed property or param. */ + private function resolveReceiverClass(Expr $receiver): ?string + { + if (($receiver instanceof Expr\PropertyFetch || $receiver instanceof Expr\NullsafePropertyFetch) + && $receiver->var instanceof Expr\Variable && $receiver->var->name === 'this' + && $receiver->name instanceof Node\Identifier) { + return $this->propertyTypes[$receiver->name->toString()] ?? null; + } + + if ($receiver instanceof Expr\Variable && is_string($receiver->name)) { + return $this->paramTypes[$receiver->name] ?? null; + } + + return null; + } +} diff --git a/scripts/plugin-class-classifier/src/BuiltinSignatures.php b/scripts/plugin-class-classifier/src/BuiltinSignatures.php new file mode 100644 index 00000000..af3d77f3 --- /dev/null +++ b/scripts/plugin-class-classifier/src/BuiltinSignatures.php @@ -0,0 +1,126 @@ + lowercase function name => stub file relative path */ + private array $functionFiles = []; + + /** @var array, variadicFrom: int|null}> lowercase function name => by-ref info */ + private array $byRef = []; + + /** @var array */ + private array $parsedFiles = []; + + public function __construct() + { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + foreach (PhpStormStubsMap::FUNCTIONS as $name => $file) { + $this->functionFiles[strtolower((string) $name)] = $file; + } + } + + /** + * @return array{fixed: list, variadicFrom: int|null}|null + * null when the function is not a known builtin + */ + public function byRefInfo(string $lowerName): ?array + { + if (isset($this->byRef[$lowerName])) { + return $this->byRef[$lowerName]; + } + + $file = $this->functionFiles[$lowerName] ?? null; + if ($file === null) { + return null; + } + + $this->parseStubFile($file); + + // Defined in the map but somehow absent from the parsed file: + // treat as unknown rather than silently non-by-ref. + return $this->byRef[$lowerName] ?? null; + } + + private function parseStubFile(string $relativePath): void + { + if (isset($this->parsedFiles[$relativePath])) { + return; + } + $this->parsedFiles[$relativePath] = true; + + $path = PhpStormStubsMap::DIR . '/' . $relativePath; + $code = file_get_contents($path); + if ($code === false) { + throw new \RuntimeException("cannot read stub file $path"); + } + $stmts = $this->parser->parse($code); + if ($stmts === null) { + throw new \RuntimeException("cannot parse stub file $path"); + } + + $byRef = &$this->byRef; + $collector = new class($byRef) extends NodeVisitorAbstract { + /** @param array, variadicFrom: int|null}> $byRef */ + public function __construct(private array &$byRef) + { + } + + public function enterNode(Node $node): null + { + if (!$node instanceof Node\Stmt\Function_) { + return null; + } + + $name = strtolower($node->name->toString()); + $fixed = []; + $variadicFrom = null; + foreach ($node->params as $i => $param) { + if (!$param->byRef) { + continue; + } + if ($param->variadic) { + $variadicFrom = $variadicFrom === null ? $i : min($variadicFrom, $i); + } else { + $fixed[] = $i; + } + } + + // Stub files occasionally declare a function more than once + // (per-version signatures); merge conservatively. + if (isset($this->byRef[$name])) { + $fixed = array_values(array_unique(array_merge($this->byRef[$name]['fixed'], $fixed))); + sort($fixed); + $prev = $this->byRef[$name]['variadicFrom']; + $variadicFrom = $prev === null ? $variadicFrom : ($variadicFrom === null ? $prev : min($prev, $variadicFrom)); + } + $this->byRef[$name] = ['fixed' => $fixed, 'variadicFrom' => $variadicFrom]; + + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($collector); + $traverser->traverse($stmts); + } +} diff --git a/scripts/plugin-class-classifier/src/ClassInfo.php b/scripts/plugin-class-classifier/src/ClassInfo.php new file mode 100644 index 00000000..84af72d4 --- /dev/null +++ b/scripts/plugin-class-classifier/src/ClassInfo.php @@ -0,0 +1,63 @@ + keyed by lowercase method name */ + public array $methods = []; + + /** @var list */ + public array $properties = []; + + /** @var list FQCNs of used traits */ + public array $traitUses = []; + + /** + * Types instantiated in method bodies (`new X`). Instantiating a + * proxied service from real PHP raises the dual-instantiation question, + * so these are tracked apart from static references. + * + * @var list + */ + public array $newRefs = []; + + /** + * Types referenced statically in method bodies (`X::method()`, writes + * to `X::$prop`). Satisfied by any executable presence in the child + * world, including a generated proxy stub. + * + * @var list + */ + public array $staticRefs = []; + + /** + * Types referenced from method bodies in ways satisfied by a mere + * declaration: `instanceof`, `catch`, `X::class`, constant reads. + * + * @var list + */ + public array $benignBodyRefs = []; + + public bool $writesOwnStaticProps = false; + + public function __construct( + public readonly string $fqcn, + /** 'class' | 'interface' | 'trait' | 'enum' */ + public readonly string $kind, + public readonly bool $abstract, + public readonly bool $final, + public readonly ?string $parent, + /** @var list */ + public readonly array $interfaces, + public readonly string $file, + ) { + } + + public function isContractLike(): bool + { + return $this->kind === 'interface' || ($this->kind === 'class' && $this->abstract); + } +} 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 @@ +> FQCN => row */ + public array $rows = []; + + /** @var list */ + public array $violations = []; + + /** @var array */ + private array $throwableCache = []; + + /** @var array */ + private array $valueObjectCandidates = []; + + /** @var array */ + 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 */ + 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> */ + 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 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"; + } + } +} diff --git a/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php b/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php new file mode 100644 index 00000000..60fac191 --- /dev/null +++ b/scripts/plugin-class-classifier/src/DocblockTypeExtractor.php @@ -0,0 +1,197 @@ + + */ + public array $aliasNames = []; + + public function collectAliases(string $code): void + { + if (preg_match_all( + '/@(?:phpstan-|psalm-)?(?:template(?:-covariant|-contravariant)?|type|import-type)\s+([A-Za-z_][A-Za-z0-9_]*)/', + $code, + $m, + ) > 0) { + foreach ($m[1] as $name) { + $this->aliasNames[$name] = true; + } + } + } + + /** + * @param array $useMap lowercase alias => FQCN + * @return array{params: array>, return: list, var: list, throws: list} + */ + public function extract(?string $docblock, string $namespace, array $useMap): array + { + $result = ['params' => [], 'return' => [], 'var' => [], 'throws' => []]; + if ($docblock === null) { + return $result; + } + + $pattern = '/@(param|return|var|throws|phpstan-param|phpstan-return|phpstan-var)[ \t]+(.+)$/m'; + if (preg_match_all($pattern, $docblock, $matches, PREG_SET_ORDER) === false) { + return $result; + } + + foreach ($matches as $m) { + $tag = str_replace('phpstan-', '', $m[1]); + $rest = rtrim($m[2]); + // The type expression ends at the first whitespace at bracket + // depth zero; spaces inside array{...} / array<...> shapes are + // part of the type. Whatever follows is the variable name + // (for @param) and/or a free-text description. + $typeExpr = $this->cutAtToplevelSpace($rest); + $after = substr($rest, strlen($typeExpr)); + $paramName = null; + if (preg_match('/^\s*\$(\w+)/', $after, $nm) === 1) { + $paramName = $nm[1]; + } + + $types = $this->classLikeTokens($typeExpr, $namespace, $useMap); + if ($types === []) { + continue; + } + + switch ($tag) { + case 'param': + if ($paramName !== null) { + $result['params'][$paramName] = array_values(array_unique(array_merge( + $result['params'][$paramName] ?? [], + $types, + ))); + } + break; + case 'return': + $result['return'] = array_values(array_unique(array_merge($result['return'], $types))); + break; + case 'var': + $result['var'] = array_values(array_unique(array_merge($result['var'], $types))); + break; + case 'throws': + $result['throws'] = array_values(array_unique(array_merge($result['throws'], $types))); + break; + } + } + + return $result; + } + + private function cutAtToplevelSpace(string $expr): string + { + $depth = 0; + $len = strlen($expr); + for ($i = 0; $i < $len; $i++) { + $c = $expr[$i]; + if ($c === '<' || $c === '{' || $c === '(' || $c === '[') { + $depth++; + } elseif ($c === '>' || $c === '}' || $c === ')' || $c === ']') { + $depth--; + } elseif (($c === ' ' || $c === "\t") && $depth === 0) { + return substr($expr, 0, $i); + } + } + + return $expr; + } + + /** + * @param array $useMap + * @return list + */ + private function classLikeTokens(string $typeExpr, string $namespace, array $useMap): array + { + // Constant references (self::STABILITY_*, BasePackage::STABILITIES, + // PATHINFO_EXTENSION|...) are not class names: drop everything after + // `::`, then drop all-caps tokens. + $typeExpr = preg_replace('/::[A-Za-z0-9_*]*/', '', $typeExpr) ?? $typeExpr; + + if (preg_match_all('/\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*/', $typeExpr, $m) === false) { + return []; + } + + $out = []; + foreach ($m[0] as $token) { + $isQualified = str_contains($token, '\\'); + if (!$isQualified) { + if (in_array($token, self::KEYWORDS, true) || in_array(strtolower($token), self::KEYWORDS, true)) { + continue; + } + // Array-shape keys and phpdoc keywords are lowercase; + // Composer class names are StudlyCaps. + if (!ctype_upper($token[0])) { + continue; + } + // All-caps tokens are constants, not classes. + if (preg_match('/[a-z]/', $token) !== 1) { + continue; + } + // Generic parameters and phpstan type aliases. + if (isset($this->aliasNames[$token])) { + continue; + } + } + $out[] = $this->resolve($token, $namespace, $useMap); + } + + return array_values(array_unique($out)); + } + + /** @param array $useMap */ + private function resolve(string $name, string $namespace, array $useMap): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $parts = explode('\\', $name); + $firstLower = strtolower($parts[0]); + if (isset($useMap[$firstLower])) { + $parts[0] = $useMap[$firstLower]; + + return implode('\\', $parts); + } + + if ($namespace === '') { + return $name; + } + + return $namespace . '\\' . $name; + } +} diff --git a/scripts/plugin-class-classifier/src/Lists.php b/scripts/plugin-class-classifier/src/Lists.php new file mode 100644 index 00000000..7e544a88 --- /dev/null +++ b/scripts/plugin-class-classifier/src/Lists.php @@ -0,0 +1,84 @@ + */ + public array $twoWorldPrefixes = []; + + /** @var array FQCN => 'memo-cache' | 'seed-once' | 'needs-sync' */ + public array $staticDispositions = []; + + /** @var array */ + public array $overrides = []; + + public static function load(string $dir): self + { + $lists = new self(); + + foreach (self::lines("$dir/two-world.list") as $line) { + $lists->twoWorldPrefixes[] = $line; + } + + foreach (self::lines("$dir/static-state.list") as $line) { + $parts = preg_split('/\s+/', $line, 2); + if (count($parts) !== 2 || !in_array($parts[1], ['memo-cache', 'seed-once', 'needs-sync'], true)) { + throw new \RuntimeException("static-state.list: malformed line: $line"); + } + $lists->staticDispositions[$parts[0]] = $parts[1]; + } + + foreach (self::lines("$dir/overrides.list") as $line) { + $parts = preg_split('/\s+/', $line, 3); + if (count($parts) !== 3) { + throw new \RuntimeException("overrides.list: malformed line (need FQCN, category, reason): $line"); + } + if (!in_array($parts[1], Classifier::CATEGORIES, true)) { + throw new \RuntimeException("overrides.list: unknown category {$parts[1]}: $line"); + } + $lists->overrides[$parts[0]] = ['category' => $parts[1], 'reason' => $parts[2]]; + } + + return $lists; + } + + public function isTwoWorld(string $fqcn): bool + { + foreach ($this->twoWorldPrefixes as $prefix) { + if ($fqcn === $prefix || str_starts_with($fqcn, rtrim($prefix, '\\') . '\\')) { + return true; + } + } + + return false; + } + + /** @return list */ + private static function lines(string $path): array + { + if (!is_file($path)) { + throw new \RuntimeException("missing exception list: $path"); + } + $out = []; + foreach (file($path, FILE_IGNORE_NEW_LINES) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $out[] = $line; + } + + return $out; + } +} diff --git a/scripts/plugin-class-classifier/src/MethodInfo.php b/scripts/plugin-class-classifier/src/MethodInfo.php new file mode 100644 index 00000000..a03c2126 --- /dev/null +++ b/scripts/plugin-class-classifier/src/MethodInfo.php @@ -0,0 +1,71 @@ +m(), self::m(), + * static::m(), parent::m()) with a literal name. Purity propagates + * through these; thisArgs lists 0-based argument positions holding + * $this-rooted expressions (checked against by-ref parameters). + * + * @var list}> + */ + public array $selfCalls = []; + + /** + * Calls on receivers whose class could be resolved statically (typed + * property or typed parameter) that pass $this-rooted expressions. + * Checked against the callee's by-ref parameter positions once the whole + * symbol table is available. + * + * @var list}> + */ + public array $externalCalls = []; + + /** + * True when the body contains a call whose signature cannot be resolved + * statically (dynamic method name, variable function, call_user_func, + * closure) with a $this-rooted expression (or $this itself) as argument, + * or passes a $this-rooted expression into a by-ref parameter position. + */ + public bool $thisEscapesUnresolved = false; + + public function __construct( + public readonly string $name, + /** 'public' | 'protected' | 'private' */ + public readonly string $visibility, + public readonly bool $static, + public readonly bool $abstract, + /** @var list */ + public readonly array $params, + /** Class-like FQCNs in the native return type. @var list */ + public readonly array $returnClassTypes, + /** Native return type is array/iterable/mixed/object or absent. */ + public readonly bool $returnExpandable, + /** Class-like FQCNs from the `@return` docblock. @var list */ + public readonly array $docblockReturnTypes, + /** Class-like FQCNs from `@throws` docblocks. @var list */ + public readonly array $docblockThrowsTypes, + ) { + } + + /** @return list positions of by-ref parameters */ + public function byRefParamPositions(): array + { + $positions = []; + foreach ($this->params as $i => $param) { + if ($param->byRef) { + $positions[] = $i; + } + } + + return $positions; + } +} 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; + } +} diff --git a/scripts/plugin-class-classifier/src/ParamInfo.php b/scripts/plugin-class-classifier/src/ParamInfo.php new file mode 100644 index 00000000..3caaa07f --- /dev/null +++ b/scripts/plugin-class-classifier/src/ParamInfo.php @@ -0,0 +1,24 @@ + */ + public readonly array $classTypes, + /** Native type is array/iterable/mixed/object or absent. */ + public readonly bool $expandable, + /** Class-like FQCNs from the `@param` docblock. @var list */ + public readonly array $docblockTypes, + /** Native type mentions callable or \Closure: the value is a + * callback that needs a handle across the boundary. */ + public readonly bool $callable = false, + ) { + } +} diff --git a/scripts/plugin-class-classifier/src/PropertyInfo.php b/scripts/plugin-class-classifier/src/PropertyInfo.php new file mode 100644 index 00000000..a15ddcf9 --- /dev/null +++ b/scripts/plugin-class-classifier/src/PropertyInfo.php @@ -0,0 +1,22 @@ + */ + public readonly array $classTypes, + /** Native type is array/iterable/mixed/object or absent. */ + public readonly bool $expandable, + /** Class-like FQCNs from the `@var` docblock. @var list */ + public readonly array $docblockTypes, + ) { + } +} 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; + } +} diff --git a/scripts/plugin-class-classifier/src/ReachabilityClosure.php b/scripts/plugin-class-classifier/src/ReachabilityClosure.php new file mode 100644 index 00000000..39214a4e --- /dev/null +++ b/scripts/plugin-class-classifier/src/ReachabilityClosure.php @@ -0,0 +1,221 @@ + composer-src FQCN => direction bits */ + public array $reachable = []; + + /** @var array vendor FQCN => direction bits */ + public array $vendorReachable = []; + + /** @var array global-namespace (builtin) name => direction bits */ + public array $builtinReachable = []; + + /** @var array unknown FQCN => direction bits (reported) */ + public array $unknownReachable = []; + + /** @var array> FQCN => transitive subtypes */ + private array $descendants = []; + + /** @var list */ + private array $worklist = []; + + public function __construct( + private readonly SourceParser $sources, + private readonly Lists $lists, + ) { + $this->buildDescendants(); + } + + public function run(): void + { + foreach ($this->sources->classes as $fqcn => $info) { + if (str_starts_with($fqcn, 'Composer\\Plugin\\')) { + $this->add($fqcn, $info->kind === 'interface' ? self::CONSUMED : self::PROVIDED); + } + } + $this->add('Composer\\EventDispatcher\\EventSubscriberInterface', self::CONSUMED); + $this->add('Composer\\EventDispatcher\\Event', self::PROVIDED); + foreach ($this->descendants['Composer\\EventDispatcher\\Event'] ?? [] as $sub) { + $this->add($sub, self::PROVIDED); + } + + while ($this->worklist !== []) { + [$fqcn, $bits] = array_pop($this->worklist); + $this->expand($fqcn, $bits); + } + } + + private function add(string $fqcn, int $bits): void + { + if ($fqcn === '') { + return; + } + + if (isset($this->sources->classes[$fqcn])) { + $current = $this->reachable[$fqcn] ?? 0; + $new = $current | $bits; + if ($new !== $current) { + $this->reachable[$fqcn] = $new; + $this->worklist[] = [$fqcn, $new & ~$current]; + } + + return; + } + + if (VendorPackages::lookup($fqcn) !== null) { + $this->vendorReachable[$fqcn] = ($this->vendorReachable[$fqcn] ?? 0) | $bits; + + return; + } + + if (!str_contains($fqcn, '\\')) { + $this->builtinReachable[$fqcn] = ($this->builtinReachable[$fqcn] ?? 0) | $bits; + + return; + } + + $this->unknownReachable[$fqcn] = ($this->unknownReachable[$fqcn] ?? 0) | $bits; + } + + /** Expand newly gained direction bits of a composer-src type. */ + private function expand(string $fqcn, int $newBits): void + { + // Two-world classes do not extend the shared boundary: the child + // process carries their real implementation wholesale, so their + // members are world-2-local, not graph seams. + if ($this->lists->isTwoWorld($fqcn)) { + return; + } + + $info = $this->sources->classes[$fqcn]; + + // Hierarchy carries the same direction both ways: ancestors so that + // instanceof works on the stub side, descendants because any + // concrete subtype can be the runtime instance behind the type. + if ($info->parent !== null) { + $this->add($info->parent, $newBits); + } + foreach ($info->interfaces as $iface) { + $this->add($iface, $newBits); + } + foreach ($this->descendants[$fqcn] ?? [] as $sub) { + $this->add($sub, $newBits); + } + + // The inverted expansion (params->provided, returns->consumed) + // models Composer calling a plugin's implementation, so it only + // applies to plugin-implementable types. A concrete class that + // gained a consumed mark (a plugin can construct and pass one in) + // still executes Composer's own method bodies. + $expandBits = $info->isContractLike() || $info->kind === 'trait' + ? $newBits + : ($newBits !== 0 ? self::PROVIDED : 0); + + foreach ($info->methods as $method) { + if ($method->visibility === 'private') { + continue; + } + $this->expandMethod($method, $expandBits); + } + + foreach ($info->properties as $prop) { + if ($prop->visibility === 'private') { + continue; + } + foreach ($this->propTypes($prop) as $type) { + // Property values are read by (sub)classing plugin code: + // they flow toward PHP regardless of the owner's direction. + $this->add($type, self::PROVIDED); + } + } + } + + private function expandMethod(MethodInfo $method, int $ownerBits): void + { + // On a provided type, plugins call the methods: arguments flow + // PHP->Rust (consumed), results flow Rust->PHP (provided). On a + // consumed type, Composer calls the plugin's implementation: the + // directions invert. + foreach ([self::PROVIDED => [self::CONSUMED, self::PROVIDED], self::CONSUMED => [self::PROVIDED, self::CONSUMED]] as $ownerDir => [$paramDir, $returnDir]) { + if (($ownerBits & $ownerDir) === 0) { + continue; + } + + foreach ($method->params as $param) { + foreach ($param->classTypes as $type) { + $this->add($type, $paramDir); + } + if ($param->expandable || $param->classTypes === []) { + foreach ($param->docblockTypes as $type) { + $this->add($type, $paramDir); + } + } + } + + foreach ($method->returnClassTypes as $type) { + $this->add($type, $returnDir); + } + if ($method->returnExpandable || $method->returnClassTypes === []) { + foreach ($method->docblockReturnTypes as $type) { + $this->add($type, $returnDir); + } + } + foreach ($method->docblockThrowsTypes as $type) { + $this->add($type, $returnDir); + } + } + } + + /** @return list */ + private function propTypes(PropertyInfo $prop): array + { + $types = $prop->classTypes; + if ($prop->expandable || $types === []) { + $types = array_merge($types, $prop->docblockTypes); + } + + return $types; + } + + private function buildDescendants(): void + { + $direct = []; + foreach ($this->sources->classes as $fqcn => $info) { + if ($info->parent !== null) { + $direct[$info->parent][] = $fqcn; + } + foreach ($info->interfaces as $iface) { + $direct[$iface][] = $fqcn; + } + } + + foreach (array_keys($direct) as $root) { + $seen = []; + $stack = $direct[$root]; + while ($stack !== []) { + $cur = array_pop($stack); + if (isset($seen[$cur])) { + continue; + } + $seen[$cur] = true; + foreach ($direct[$cur] ?? [] as $child) { + $stack[] = $child; + } + } + $this->descendants[$root] = array_keys($seen); + sort($this->descendants[$root]); + } + } +} diff --git a/scripts/plugin-class-classifier/src/Report.php b/scripts/plugin-class-classifier/src/Report.php new file mode 100644 index 00000000..e24363fe --- /dev/null +++ b/scripts/plugin-class-classifier/src/Report.php @@ -0,0 +1,103 @@ +classifier->closure->vendorReachable as $fqcn => $bits) { + $info = VendorPackages::lookup($fqcn); + $vendor[] = [ + 'fqcn' => $fqcn, + 'package' => $info['package'], + 'category' => $info['category'], + 'direction' => $bits === 3 ? 'both' : ($bits === 1 ? 'provided' : 'consumed'), + ]; + } + usort($vendor, static fn (array $a, array $b) => strcmp($a['fqcn'], $b['fqcn'])); + + $builtins = array_keys($this->classifier->closure->builtinReachable); + sort($builtins); + + $vendorPackages = []; + foreach (VendorPackages::PREFIXES as $prefix => $info) { + $vendorPackages[$info['package']] = $info['category']; + } + ksort($vendorPackages); + + $data = [ + 'categories' => $this->countByCategory(), + 'classes' => array_values($this->classifier->rows), + 'vendorPackages' => $vendorPackages, + 'vendorTypes' => $vendor, + 'reachableBuiltins' => $builtins, + 'violations' => $this->classifier->violations, + ]; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($path, $json . "\n") === false) { + throw new \RuntimeException("cannot write $path"); + } + } + + public function printSummary(): void + { + $counts = $this->countByCategory(); + echo "# Plugin boundary classification\n\n"; + echo "| category | classes |\n|---|---|\n"; + foreach ($counts as $category => $count) { + echo "| $category | $count |\n"; + } + + echo "\n## Reachable surface\n\n"; + foreach (['rust-proxy', 'rust-snapshot', 'contract'] as $category) { + $names = []; + foreach ($this->classifier->rows as $row) { + if ($row['category'] === $category && $row['reachable']) { + $names[] = $row['fqcn'] . ($row['direction'] === 'both' ? ' (both)' : ''); + } + } + echo '### ' . $category . ' (' . count($names) . ")\n\n"; + foreach ($names as $name) { + echo "- $name\n"; + } + echo "\n"; + } + + $overridden = array_filter($this->classifier->rows, static fn (array $r) => isset($r['computedCategory'])); + if ($overridden !== []) { + echo "## Overridden\n\n"; + foreach ($overridden as $row) { + echo "- {$row['fqcn']}: {$row['computedCategory']} -> {$row['category']}\n"; + } + echo "\n"; + } + + if ($this->classifier->violations !== []) { + echo "## Violations\n\n"; + foreach ($this->classifier->violations as $violation) { + echo "- $violation\n"; + } + echo "\n"; + } + } + + /** @return array */ + private function countByCategory(): array + { + $counts = array_fill_keys(Classifier::CATEGORIES, 0); + foreach ($this->classifier->rows as $row) { + $counts[$row['category']]++; + } + + return $counts; + } +} diff --git a/scripts/plugin-class-classifier/src/SourceParser.php b/scripts/plugin-class-classifier/src/SourceParser.php new file mode 100644 index 00000000..591c54b1 --- /dev/null +++ b/scripts/plugin-class-classifier/src/SourceParser.php @@ -0,0 +1,416 @@ + FQCN (case-preserved) => info */ + public array $classes = []; + + /** @var array lowercase FQCN => case-preserved FQCN */ + public array $lowercaseIndex = []; + + /** @var list docblock tokens that resolved to nothing known; reported, not fatal */ + public array $unresolvedDocblockTypes = []; + + private BuiltinSignatures $builtins; + + public function __construct() + { + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + $this->docblocks = new DocblockTypeExtractor(); + $this->builtins = new BuiltinSignatures(); + } + + public function parseTree(string $root): void + { + $files = []; + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + // PHPStan extensions are dev-only tooling, never shipped at + // runtime; they reference phpstan types and only add noise. + if (str_contains($file->getPathname(), '/PHPStan/')) { + continue; + } + $files[] = $file->getPathname(); + } + sort($files); + + // Pre-scan for @template / @phpstan-type alias names so the + // docblock type extractor can ignore them tree-wide. + foreach ($files as $path) { + $code = file_get_contents($path); + if ($code !== false) { + $this->docblocks->collectAliases($code); + } + } + + foreach ($files as $path) { + $this->parseFile($path); + } + } + + public function finalize(): void + { + $this->mergeTraits(); + } + + private function parseFile(string $path): void + { + $code = file_get_contents($path); + if ($code === false) { + throw new \RuntimeException("cannot read $path"); + } + + $stmts = $this->parser->parse($code); + if ($stmts === null) { + throw new \RuntimeException("cannot parse $path"); + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $useCollector = new class extends NodeVisitorAbstract { + public string $namespace = ''; + + /** @var array lowercase alias => FQCN */ + public array $useMap = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Stmt\Namespace_) { + $this->namespace = $node->name?->toString() ?? ''; + } elseif ($node instanceof Stmt\Use_ && $node->type === Stmt\Use_::TYPE_NORMAL) { + foreach ($node->uses as $use) { + $alias = $use->alias?->toString() ?? $use->name->getLast(); + $this->useMap[strtolower($alias)] = $use->name->toString(); + } + } elseif ($node instanceof Stmt\GroupUse) { + foreach ($node->uses as $use) { + if ($use->type !== Stmt\Use_::TYPE_NORMAL && $node->type !== Stmt\Use_::TYPE_NORMAL) { + continue; + } + $alias = $use->alias?->toString() ?? $use->name->getLast(); + $this->useMap[strtolower($alias)] = $node->prefix->toString() . '\\' . $use->name->toString(); + } + } + + return null; + } + }; + $traverser->addVisitor($useCollector); + + $classCollector = new class extends NodeVisitorAbstract { + /** @var list */ + public array $classLikes = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Stmt\ClassLike && $node->name !== null) { + $this->classLikes[] = $node; + } + + return null; + } + }; + $traverser->addVisitor($classCollector); + + $traverser->traverse($stmts); + + foreach ($classCollector->classLikes as $node) { + $this->collectClass($node, $path, $useCollector->namespace, $useCollector->useMap); + } + } + + /** @param array $useMap */ + private function collectClass(Stmt\ClassLike $node, string $file, string $namespace, array $useMap): void + { + $fqcn = $node->namespacedName?->toString() ?? $node->name->toString(); + + $kind = match (true) { + $node instanceof Stmt\Interface_ => 'interface', + $node instanceof Stmt\Trait_ => 'trait', + $node instanceof Stmt\Enum_ => 'enum', + default => 'class', + }; + + $parent = null; + $interfaces = []; + if ($node instanceof Stmt\Class_) { + $parent = $node->extends?->toString(); + foreach ($node->implements as $iface) { + $interfaces[] = $iface->toString(); + } + } elseif ($node instanceof Stmt\Interface_) { + foreach ($node->extends as $iface) { + $interfaces[] = $iface->toString(); + } + } elseif ($node instanceof Stmt\Enum_) { + foreach ($node->implements as $iface) { + $interfaces[] = $iface->toString(); + } + } + + $info = new ClassInfo( + $fqcn, + $kind, + $node instanceof Stmt\Class_ && $node->isAbstract(), + $node instanceof Stmt\Class_ && $node->isFinal(), + $parent, + $interfaces, + $file, + ); + + foreach ($node->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $trait) { + $info->traitUses[] = $trait->toString(); + } + } + + foreach ($node->getProperties() as $propNode) { + $doc = $this->docblocks->extract($propNode->getDocComment()?->getText(), $namespace, $useMap); + [$classTypes, $expandable] = $this->typeClassNames($propNode->type, $fqcn, $parent); + foreach ($propNode->props as $prop) { + $info->properties[] = new PropertyInfo( + $prop->name->toString(), + $propNode->isPrivate() ? 'private' : ($propNode->isProtected() ? 'protected' : 'public'), + $propNode->isStatic(), + $classTypes, + $expandable, + $doc['var'], + ); + } + } + + foreach ($node->getMethods() as $methodNode) { + $this->collectMethod($info, $methodNode, $namespace, $useMap); + } + + $this->classes[$fqcn] = $info; + $this->lowercaseIndex[strtolower($fqcn)] = $fqcn; + } + + /** @param array $useMap */ + private function collectMethod(ClassInfo $info, Stmt\ClassMethod $node, string $namespace, array $useMap): void + { + $doc = $this->docblocks->extract($node->getDocComment()?->getText(), $namespace, $useMap); + + $params = []; + foreach ($node->params as $paramNode) { + $name = $paramNode->var instanceof Node\Expr\Variable && is_string($paramNode->var->name) + ? $paramNode->var->name + : ''; + [$classTypes, $expandable] = $this->typeClassNames($paramNode->type, $info->fqcn, $info->parent); + $params[] = new ParamInfo( + $name, + $paramNode->byRef, + $paramNode->variadic, + $classTypes, + $expandable, + $doc['params'][$name] ?? [], + $this->typeIsCallable($paramNode->type), + ); + + // Constructor property promotion. + if ($paramNode->flags !== 0) { + $info->properties[] = new PropertyInfo( + $name, + ($paramNode->flags & \PhpParser\Modifiers::PRIVATE) !== 0 ? 'private' + : (($paramNode->flags & \PhpParser\Modifiers::PROTECTED) !== 0 ? 'protected' : 'public'), + false, + $classTypes, + $expandable, + $doc['params'][$name] ?? [], + ); + } + } + + [$returnClassTypes, $returnExpandable] = $this->typeClassNames($node->returnType, $info->fqcn, $info->parent); + + $method = new MethodInfo( + $node->name->toString(), + $node->isPrivate() ? 'private' : ($node->isProtected() ? 'protected' : 'public'), + $node->isStatic(), + $node->isAbstract() || $node->stmts === null, + $params, + $returnClassTypes, + $returnExpandable, + $doc['return'], + $doc['throws'], + ); + + $propertyTypes = []; + foreach ($info->properties as $prop) { + $single = $this->singleClassType($prop->classTypes, $prop->docblockTypes); + if ($single !== null) { + $propertyTypes[$prop->name] = $single; + } + } + $paramTypes = []; + foreach ($params as $param) { + $single = $this->singleClassType($param->classTypes, $param->docblockTypes); + if ($single !== null) { + $paramTypes[$param->name] = $single; + } + } + + $analyzer = BodyAnalyzer::analyze($node, $method, $info->fqcn, $propertyTypes, $paramTypes, $this->builtins); + foreach ($analyzer->newRefs as $ref) { + $info->newRefs[] = ltrim($ref, '\\'); + } + foreach ($analyzer->staticRefs as $ref) { + $info->staticRefs[] = ltrim($ref, '\\'); + } + foreach ($analyzer->benignRefs as $ref) { + $info->benignBodyRefs[] = ltrim($ref, '\\'); + } + if ($analyzer->writesOwnStaticProps) { + $info->writesOwnStaticProps = true; + } + + $info->methods[strtolower($method->name)] = $method; + } + + /** + * @param list $native + * @param list $docblock + */ + private function singleClassType(array $native, array $docblock): ?string + { + if (count($native) === 1) { + return $native[0]; + } + if ($native === [] && count($docblock) === 1) { + return $docblock[0]; + } + + return null; + } + + /** + * Extracts class-like FQCNs from a native type node and reports whether + * the type invites docblock refinement (array/iterable/mixed/object or + * no type at all). + * + * @return array{0: list, 1: bool} + */ + private function typeClassNames(?Node $type, string $currentClass, ?string $parentClass): array + { + if ($type === null) { + return [[], true]; + } + + $classes = []; + $expandable = false; + + $walk = function (Node $t) use (&$walk, &$classes, &$expandable, $currentClass, $parentClass): void { + if ($t instanceof Node\NullableType) { + $walk($t->type); + } elseif ($t instanceof Node\UnionType || $t instanceof Node\IntersectionType) { + foreach ($t->types as $sub) { + $walk($sub); + } + } elseif ($t instanceof Node\Identifier) { + if (in_array($t->toLowerString(), ['array', 'iterable', 'mixed', 'object'], true)) { + $expandable = true; + } + } elseif ($t instanceof Name) { + $name = $t->toString(); + $lower = strtolower($name); + if ($lower === 'self' || $lower === 'static') { + $classes[] = $currentClass; + } elseif ($lower === 'parent') { + if ($parentClass !== null) { + $classes[] = $parentClass; + } + } else { + $classes[] = ltrim($name, '\\'); + } + } + }; + $walk($type); + + return [array_values(array_unique($classes)), $expandable]; + } + + private function typeIsCallable(?Node $type): bool + { + if ($type === null) { + return false; + } + if ($type instanceof Node\NullableType) { + return $this->typeIsCallable($type->type); + } + if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { + foreach ($type->types as $sub) { + if ($this->typeIsCallable($sub)) { + return true; + } + } + + return false; + } + if ($type instanceof Node\Identifier) { + return $type->toLowerString() === 'callable'; + } + if ($type instanceof Name) { + return strtolower($type->toString()) === 'closure'; + } + + return false; + } + + private function mergeTraits(): void + { + foreach ($this->classes as $info) { + foreach ($info->traitUses as $traitName) { + $trait = $this->classes[$traitName] ?? null; + if ($trait === null) { + continue; + } + foreach ($trait->methods as $lname => $method) { + if (!isset($info->methods[$lname])) { + $info->methods[$lname] = $method; + } + } + foreach ($trait->properties as $prop) { + $info->properties[] = $prop; + } + foreach ($trait->newRefs as $ref) { + $info->newRefs[] = $ref; + } + foreach ($trait->staticRefs as $ref) { + $info->staticRefs[] = $ref; + } + foreach ($trait->benignBodyRefs as $ref) { + $info->benignBodyRefs[] = $ref; + } + if ($trait->writesOwnStaticProps) { + $info->writesOwnStaticProps = true; + } + } + } + } +} diff --git a/scripts/plugin-class-classifier/src/VendorPackages.php b/scripts/plugin-class-classifier/src/VendorPackages.php new file mode 100644 index 00000000..768a9c17 --- /dev/null +++ b/scripts/plugin-class-classifier/src/VendorPackages.php @@ -0,0 +1,69 @@ + prefix => info */ + public const PREFIXES = [ + 'Composer\\Semver\\' => ['package' => 'composer/semver', 'category' => 'php-native'], + 'Composer\\Pcre\\' => ['package' => 'composer/pcre', 'category' => 'php-native'], + 'Composer\\CaBundle\\' => ['package' => 'composer/ca-bundle', 'category' => 'php-native'], + 'Composer\\ClassMapGenerator\\' => ['package' => 'composer/class-map-generator', 'category' => 'php-native'], + 'Composer\\MetadataMinifier\\' => ['package' => 'composer/metadata-minifier', 'category' => 'php-native'], + 'Composer\\Spdx\\' => ['package' => 'composer/spdx-licenses', 'category' => 'php-native'], + 'Composer\\XdebugHandler\\' => ['package' => 'composer/xdebug-handler', 'category' => 'php-native'], + 'JsonSchema\\' => ['package' => 'justinrainbow/json-schema', 'category' => 'php-native'], + 'Seld\\JsonLint\\' => ['package' => 'seld/jsonlint', 'category' => 'php-native'], + 'Seld\\PharUtils\\' => ['package' => 'seld/phar-utils', 'category' => 'php-native'], + 'Seld\\Signal\\' => ['package' => 'seld/signal-handler', 'category' => 'php-native'], + 'Psr\\Log\\' => ['package' => 'psr/log', 'category' => 'php-native'], + 'React\\Promise\\' => ['package' => 'react/promise', 'category' => 'contract'], + 'Symfony\\Component\\Console\\' => ['package' => 'symfony/console', 'category' => 'two-world'], + 'Symfony\\Component\\Process\\' => ['package' => 'symfony/process', 'category' => 'php-native'], + 'Symfony\\Component\\Filesystem\\' => ['package' => 'symfony/filesystem', 'category' => 'php-native'], + 'Symfony\\Component\\Finder\\' => ['package' => 'symfony/finder', 'category' => 'php-native'], + 'Symfony\\Polyfill\\' => ['package' => 'symfony/polyfill', 'category' => 'php-native'], + ]; + + /** + * By-ref parameter positions of vendor methods, needed by the purity + * analysis when composer code passes $this-rooted expressions to them. + * Vendor packages other than composer/pcre expose no by-ref parameters + * in APIs composer calls; composer/pcre's output parameter is pervasive. + * + * @var array>> FQCN => lowercase method => positions + */ + public const BY_REF = [ + 'Composer\\Pcre\\Preg' => [ + 'match' => [2], + 'matchstrictgroups' => [2], + 'ismatch' => [2], + 'ismatchstrictgroups' => [2], + 'matchall' => [2], + 'matchallstrictgroups' => [2], + 'ismatchall' => [2], + 'ismatchallstrictgroups' => [2], + ], + 'Composer\\Pcre\\Regex' => [], + ]; + + /** @return array{package: string, category: string}|null */ + public static function lookup(string $fqcn): ?array + { + foreach (self::PREFIXES as $prefix => $info) { + if (str_starts_with($fqcn, $prefix)) { + return $info; + } + } + + return null; + } +} -- cgit v1.3.1