aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/plugin-class-classifier/.gitignore2
-rwxr-xr-xscripts/plugin-class-classifier/classify40
-rw-r--r--scripts/plugin-class-classifier/composer.json15
-rw-r--r--scripts/plugin-class-classifier/composer.lock122
-rw-r--r--scripts/plugin-class-classifier/lists/overrides.list7
-rw-r--r--scripts/plugin-class-classifier/lists/static-state.list21
-rw-r--r--scripts/plugin-class-classifier/lists/two-world.list6
-rw-r--r--scripts/plugin-class-classifier/src/BodyAnalyzer.php335
-rw-r--r--scripts/plugin-class-classifier/src/BuiltinSignatures.php126
-rw-r--r--scripts/plugin-class-classifier/src/ClassInfo.php63
-rw-r--r--scripts/plugin-class-classifier/src/Classifier.php508
-rw-r--r--scripts/plugin-class-classifier/src/DocblockTypeExtractor.php197
-rw-r--r--scripts/plugin-class-classifier/src/Lists.php84
-rw-r--r--scripts/plugin-class-classifier/src/MethodInfo.php71
-rw-r--r--scripts/plugin-class-classifier/src/NativeFixedPoint.php153
-rw-r--r--scripts/plugin-class-classifier/src/ParamInfo.php24
-rw-r--r--scripts/plugin-class-classifier/src/PropertyInfo.php22
-rw-r--r--scripts/plugin-class-classifier/src/PurityAnalyzer.php133
-rw-r--r--scripts/plugin-class-classifier/src/ReachabilityClosure.php221
-rw-r--r--scripts/plugin-class-classifier/src/Report.php103
-rw-r--r--scripts/plugin-class-classifier/src/SourceParser.php416
-rw-r--r--scripts/plugin-class-classifier/src/VendorPackages.php69
22 files changed, 2738 insertions, 0 deletions
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
+<?php
+
+declare(strict_types=1);
+
+require __DIR__ . '/vendor/autoload.php';
+
+use Shirabe\PluginClassifier\Classifier;
+use Shirabe\PluginClassifier\Lists;
+use Shirabe\PluginClassifier\Report;
+
+$repoRoot = dirname(__DIR__, 2);
+$composerSrc = $repoRoot . '/composer/src/Composer';
+$out = __DIR__ . '/report.json';
+
+foreach (array_slice($argv, 1) as $arg) {
+ if (str_starts_with($arg, '--composer-src=')) {
+ $composerSrc = substr($arg, strlen('--composer-src='));
+ } elseif (str_starts_with($arg, '--out=')) {
+ $out = substr($arg, strlen('--out='));
+ } else {
+ fwrite(STDERR, "usage: classify [--composer-src=DIR] [--out=FILE]\n");
+ exit(2);
+ }
+}
+
+if (!is_dir($composerSrc)) {
+ fwrite(STDERR, "not a directory: $composerSrc\n");
+ exit(2);
+}
+
+$classifier = new Classifier($composerSrc, Lists::load(__DIR__ . '/lists'));
+$classifier->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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\ClassMethod;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitorAbstract;
+
+/**
+ * Walks one method body and records everything the classifier needs from
+ * it: direct $this mutations, by-ref builtin usage, self-calls (for purity
+ * propagation), statically resolvable external calls passing $this-rooted
+ * arguments, referenced types, and static property writes.
+ */
+final class BodyAnalyzer extends NodeVisitorAbstract
+{
+ /** @var list<string> */
+ public array $newRefs = [];
+
+ /** @var list<string> */
+ public array $staticRefs = [];
+
+ /** @var list<string> */
+ public array $benignRefs = [];
+
+ public bool $writesOwnStaticProps = false;
+
+ public function __construct(
+ private readonly MethodInfo $method,
+ private readonly string $currentClass,
+ /** @var array<string, string> property name => single class type FQCN */
+ private readonly array $propertyTypes,
+ /** @var array<string, string> 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<Node\Arg|Node\VariadicPlaceholder> $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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+use JetBrains\PHPStormStub\PhpStormStubsMap;
+use PhpParser\Node;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitorAbstract;
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+
+/**
+ * By-ref parameter positions of PHP builtin functions, resolved from
+ * jetbrains/phpstorm-stubs instead of a hand-maintained table. Stub files
+ * are parsed lazily, one file per first lookup of any function it defines;
+ * a function absent from the stubs map is unknown and the caller must be
+ * conservative.
+ */
+final class BuiltinSignatures
+{
+ private Parser $parser;
+
+ /** @var array<string, string> lowercase function name => stub file relative path */
+ private array $functionFiles = [];
+
+ /** @var array<string, array{fixed: list<int>, variadicFrom: int|null}> lowercase function name => by-ref info */
+ private array $byRef = [];
+
+ /** @var array<string, true> */
+ 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<int>, 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<string, array{fixed: list<int>, 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class ClassInfo
+{
+ /** @var array<string, MethodInfo> keyed by lowercase method name */
+ public array $methods = [];
+
+ /** @var list<PropertyInfo> */
+ public array $properties = [];
+
+ /** @var list<string> 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<string>
+ */
+ 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<string>
+ */
+ public array $staticRefs = [];
+
+ /**
+ * Types referenced from method bodies in ways satisfied by a mere
+ * declaration: `instanceof`, `catch`, `X::class`, constant reads.
+ *
+ * @var list<string>
+ */
+ 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<string> */
+ 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 @@
+<?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";
+ }
+ }
+}
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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Extracts class-like type names from phpdoc @param / @return / @var /
+ * @throws tags and resolves them against the file's namespace and use map.
+ *
+ * This is deliberately not a full phpdoc type grammar. Composer's docblocks
+ * are PHPStan-checked, so tokenizing the type expression and keeping the
+ * class-like tokens is sufficient. Tokens are considered class-like only
+ * when they contain a namespace separator or start with an uppercase letter;
+ * this filters array-shape keys and scalar keywords. Resolved names that do
+ * not exist in the symbol table or a known vendor namespace are reported by
+ * the caller rather than silently dropped.
+ */
+final class DocblockTypeExtractor
+{
+ private const KEYWORDS = [
+ 'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean',
+ 'true', 'false', 'null', 'void', 'never', 'mixed', 'scalar', 'array',
+ 'iterable', 'object', 'callable', 'resource', 'self', 'static',
+ 'parent', 'this', 'list', 'non-empty-list', 'non-empty-array',
+ 'non-empty-string', 'class-string', 'callable-string',
+ 'numeric-string', 'lowercase-string', 'literal-string', 'key-of',
+ 'value-of', 'array-key', 'positive-int', 'negative-int',
+ 'non-negative-int', 'non-positive-int', 'int-mask-of', 'Closure',
+ 'Generator', 'Traversable', 'Iterator', 'IteratorAggregate',
+ 'ArrayAccess', 'Countable', 'Stringable', 'JsonSerializable',
+ 'Throwable', 'Exception', 'SplFileInfo', 'ArrayObject',
+ ];
+
+ /**
+ * Names declared by @template / @phpstan-type / @phpstan-import-type
+ * anywhere in the tree; they look like class names inside type
+ * expressions but are not classes. Filled by a pre-scan.
+ *
+ * @var array<string, true>
+ */
+ 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<string, string> $useMap lowercase alias => FQCN
+ * @return array{params: array<string, list<string>>, return: list<string>, var: list<string>, throws: list<string>}
+ */
+ 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<string, string> $useMap
+ * @return list<string>
+ */
+ 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<string, string> $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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * The three versioned exception lists. Formats are line-based; `#` starts a
+ * comment, blank lines are skipped.
+ *
+ * two-world.list one namespace prefix (or exact FQCN) per line
+ * static-state.list "FQCN memo-cache|seed-once|needs-sync" per line
+ * overrides.list "FQCN category reason..." per line
+ */
+final class Lists
+{
+ /** @var list<string> */
+ public array $twoWorldPrefixes = [];
+
+ /** @var array<string, string> FQCN => 'memo-cache' | 'seed-once' | 'needs-sync' */
+ public array $staticDispositions = [];
+
+ /** @var array<string, array{category: string, reason: string}> */
+ 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<string> */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class MethodInfo
+{
+ /** Filled by BodyAnalyzer. */
+ public bool $mutatesThisDirectly = false;
+
+ /**
+ * Calls to methods of the same object ($this->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<array{name: string, thisArgs: list<int>}>
+ */
+ 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<array{class: string, method: string, thisArgs: list<int>}>
+ */
+ 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<ParamInfo> */
+ public readonly array $params,
+ /** Class-like FQCNs in the native return type. @var list<string> */
+ 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<string> */
+ public readonly array $docblockReturnTypes,
+ /** Class-like FQCNs from `@throws` docblocks. @var list<string> */
+ public readonly array $docblockThrowsTypes,
+ ) {
+ }
+
+ /** @return list<int> 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Decides php-native vs unsupported for classes the closure never reached,
+ * per "Mechanical rules" step 6: leaf-first fixed point over body
+ * references.
+ *
+ * What a reference may legally target from real PHP running in the child
+ * process:
+ *
+ * - `new X` works when X exists there as executable-or-local code: a
+ * php-native class, a two-world class (real console code), a vendor
+ * class (real package code), a rust-snapshot value object (values are
+ * built locally), or a PHP builtin. Constructing a rust-proxy service
+ * is the unresolved dual-instantiation case and demotes.
+ * - `X::method()` / `X::$prop` additionally works when X is any stubbed
+ * reachable class (proxy stubs carry static methods as RPC), so only
+ * unsupported peers and unknown types demote.
+ */
+final class NativeFixedPoint
+{
+ /** @var array<string, string> unreachable FQCN => 'php-native' | 'unsupported' */
+ public array $categories = [];
+
+ /** @var array<string, list<string>> FQCN => reasons for demotion */
+ public array $reasons = [];
+
+ /** @var list<string> 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<string, string> 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class ParamInfo
+{
+ public function __construct(
+ public readonly string $name,
+ public readonly bool $byRef,
+ public readonly bool $variadic,
+ /** Class-like FQCNs in the native type. @var list<string> */
+ 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<string> */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class PropertyInfo
+{
+ public function __construct(
+ public readonly string $name,
+ /** 'public' | 'protected' | 'private' */
+ public readonly string $visibility,
+ public readonly bool $static,
+ /** Class-like FQCNs in the native type. @var list<string> */
+ 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<string> */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Per-method pure/mutator decision (see "pure/mutator" in
+ * docs/dev/plugin-class-classification.md): a method is a mutator iff it
+ * writes $this directly, passes a $this-rooted expression into a by-ref
+ * parameter, contains a statically unresolvable call that a $this-rooted
+ * expression escapes into, or (transitively) calls a mutator method on the
+ * same object.
+ */
+final class PurityAnalyzer
+{
+ /** @var array<string, string> "Fqcn::lowername" => 'pure' | 'mutator' | 'n/a' */
+ public array $verdicts = [];
+
+ public function __construct(private readonly SourceParser $sources)
+ {
+ }
+
+ public function run(): void
+ {
+ // Seed with local evidence.
+ foreach ($this->sources->classes as $fqcn => $info) {
+ foreach ($info->methods as $lname => $method) {
+ $key = "$fqcn::$lname";
+ if ($method->static || $method->abstract) {
+ $this->verdicts[$key] = 'n/a';
+ continue;
+ }
+ $this->verdicts[$key] = $this->locallyMutates($fqcn, $method) ? 'mutator' : 'pure';
+ }
+ }
+
+ // Propagate through same-object calls until stable.
+ do {
+ $changed = false;
+ foreach ($this->sources->classes as $fqcn => $info) {
+ foreach ($info->methods as $lname => $method) {
+ $key = "$fqcn::$lname";
+ if ($this->verdicts[$key] !== 'pure') {
+ continue;
+ }
+ foreach ($method->selfCalls as $call) {
+ $callee = $this->resolveMethod($fqcn, $call['name']);
+ if ($callee === null) {
+ // Magic __call or a method we cannot see.
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ [$calleeClass, $calleeMethod] = $callee;
+ $calleeVerdict = $this->verdicts["$calleeClass::" . strtolower($calleeMethod->name)] ?? 'mutator';
+ if ($calleeVerdict === 'mutator') {
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ if (array_intersect($call['thisArgs'], $calleeMethod->byRefParamPositions()) !== []) {
+ $this->verdicts[$key] = 'mutator';
+ $changed = true;
+ break;
+ }
+ }
+ }
+ }
+ } while ($changed);
+ }
+
+ private function locallyMutates(string $fqcn, MethodInfo $method): bool
+ {
+ if ($method->mutatesThisDirectly || $method->thisEscapesUnresolved) {
+ return true;
+ }
+
+ foreach ($method->externalCalls as $call) {
+ $positions = $this->byRefPositionsOf($call['class'], $call['method']);
+ if ($positions === null) {
+ // Callee signature unknown: conservative.
+ return true;
+ }
+ if (array_intersect($call['thisArgs'], $positions) !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @return list<int>|null null when the signature cannot be resolved */
+ private function byRefPositionsOf(string $class, string $lowerMethod): ?array
+ {
+ $resolved = $this->resolveMethod($class, $lowerMethod);
+ if ($resolved !== null) {
+ return $resolved[1]->byRefParamPositions();
+ }
+
+ $vendor = VendorPackages::BY_REF[$class] ?? null;
+ if ($vendor !== null) {
+ return $vendor[$lowerMethod] ?? [];
+ }
+ if (VendorPackages::lookup($class) !== null) {
+ // Vendor packages other than the ones tabled expose no by-ref
+ // parameters in APIs composer calls.
+ return [];
+ }
+
+ return null;
+ }
+
+ /** @return array{string, MethodInfo}|null resolved (declaring class, method) */
+ private function resolveMethod(string $class, string $lowerMethod): ?array
+ {
+ $seen = [];
+ $current = $class;
+ while ($current !== null && !isset($seen[$current])) {
+ $seen[$current] = true;
+ $info = $this->sources->classes[$current] ?? null;
+ if ($info === null) {
+ return null;
+ }
+ if (isset($info->methods[$lowerMethod])) {
+ return [$current, $info->methods[$lowerMethod]];
+ }
+ $current = $info->parent;
+ }
+
+ return null;
+ }
+}
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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Computes the plugin-reachable type set with direction marks, per the
+ * "Mechanical rules" steps 2-4 in docs/dev/plugin-class-classification.md.
+ */
+final class ReachabilityClosure
+{
+ public const PROVIDED = 1;
+ public const CONSUMED = 2;
+
+ /** @var array<string, int> composer-src FQCN => direction bits */
+ public array $reachable = [];
+
+ /** @var array<string, int> vendor FQCN => direction bits */
+ public array $vendorReachable = [];
+
+ /** @var array<string, int> global-namespace (builtin) name => direction bits */
+ public array $builtinReachable = [];
+
+ /** @var array<string, int> unknown FQCN => direction bits (reported) */
+ public array $unknownReachable = [];
+
+ /** @var array<string, list<string>> FQCN => transitive subtypes */
+ private array $descendants = [];
+
+ /** @var list<array{string, int}> */
+ 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<string> */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+final class Report
+{
+ public function __construct(private readonly Classifier $classifier)
+ {
+ }
+
+ public function writeJson(string $path): void
+ {
+ $vendor = [];
+ foreach ($this->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<string, int> */
+ 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+use PhpParser\Node;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\NameResolver;
+use PhpParser\NodeVisitorAbstract;
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+
+/**
+ * Parses a source tree into ClassInfo records. Names inside declarations
+ * and bodies are resolved by PhpParser's NameResolver; docblock types are
+ * resolved separately against the recorded per-file use map.
+ */
+final class SourceParser
+{
+ private Parser $parser;
+
+ private DocblockTypeExtractor $docblocks;
+
+ /** @var array<string, ClassInfo> FQCN (case-preserved) => info */
+ public array $classes = [];
+
+ /** @var array<string, string> lowercase FQCN => case-preserved FQCN */
+ public array $lowercaseIndex = [];
+
+ /** @var list<string> 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<string, string> 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<Stmt\ClassLike> */
+ 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<string, string> $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<string, string> $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<string> $native
+ * @param list<string> $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<string>, 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 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+/**
+ * Package-level classification of composer/composer's runtime vendor
+ * dependencies, applied by namespace prefix. See the "Mechanical rules"
+ * step 6 in docs/dev/plugin-class-classification.md for the criterion.
+ */
+final class VendorPackages
+{
+ /** @var array<string, array{package: string, category: string}> 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<string, array<string, list<int>>> 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;
+ }
+}