aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/plugin-stub-generator/.gitignore1
-rw-r--r--scripts/plugin-stub-generator/composer.json14
-rw-r--r--scripts/plugin-stub-generator/composer.lock78
-rwxr-xr-xscripts/plugin-stub-generator/generate-stubs97
-rw-r--r--scripts/plugin-stub-generator/src/GenerationError.php14
-rw-r--r--scripts/plugin-stub-generator/src/Generator.php383
-rw-r--r--scripts/plugin-stub-generator/src/NamePrinter.php92
-rw-r--r--scripts/plugin-stub-generator/src/Project.php59
-rw-r--r--scripts/plugin-stub-generator/src/Report.php37
-rw-r--r--scripts/plugin-stub-generator/src/SourceFile.php86
-rw-r--r--scripts/plugin-stub-generator/targets.list12
11 files changed, 873 insertions, 0 deletions
diff --git a/scripts/plugin-stub-generator/.gitignore b/scripts/plugin-stub-generator/.gitignore
new file mode 100644
index 00000000..48b8bf90
--- /dev/null
+++ b/scripts/plugin-stub-generator/.gitignore
@@ -0,0 +1 @@
+vendor/
diff --git a/scripts/plugin-stub-generator/composer.json b/scripts/plugin-stub-generator/composer.json
new file mode 100644
index 00000000..a995443b
--- /dev/null
+++ b/scripts/plugin-stub-generator/composer.json
@@ -0,0 +1,14 @@
+{
+ "name": "shirabe/plugin-stub-generator",
+ "description": "Deterministic emitter for the proxy stub classes loaded into the plugin PHP worker",
+ "license": "MIT",
+ "require": {
+ "php": ">=8.1",
+ "nikic/php-parser": "^5.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Shirabe\\PluginStubGenerator\\": "src/"
+ }
+ }
+}
diff --git a/scripts/plugin-stub-generator/composer.lock b/scripts/plugin-stub-generator/composer.lock
new file mode 100644
index 00000000..7de72585
--- /dev/null
+++ b/scripts/plugin-stub-generator/composer.lock
@@ -0,0 +1,78 @@
+{
+ "_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": "c9443c83d078e8787f1c9d4f8a693c96",
+ "packages": [
+ {
+ "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-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs
new file mode 100755
index 00000000..c6b03c97
--- /dev/null
+++ b/scripts/plugin-stub-generator/generate-stubs
@@ -0,0 +1,97 @@
+#!/usr/bin/env php
+<?php
+
+declare(strict_types=1);
+
+require __DIR__ . '/vendor/autoload.php';
+
+use Shirabe\PluginStubGenerator\GenerationError;
+use Shirabe\PluginStubGenerator\Generator;
+use Shirabe\PluginStubGenerator\Report;
+
+$repoRoot = dirname(__DIR__, 2);
+$composerRoot = $repoRoot . '/composer';
+$reportPath = $repoRoot . '/scripts/plugin-class-classifier/report.json';
+$stubsDir = $repoRoot . '/crates/shirabe-php-rpc/php/stubs';
+$libRs = $repoRoot . '/crates/shirabe-php-rpc/src/lib.rs';
+$check = false;
+
+foreach (array_slice($argv, 1) as $arg) {
+ if (str_starts_with($arg, '--composer-root=')) {
+ $composerRoot = substr($arg, strlen('--composer-root='));
+ } elseif (str_starts_with($arg, '--report=')) {
+ $reportPath = substr($arg, strlen('--report='));
+ } elseif (str_starts_with($arg, '--stubs-dir=')) {
+ $stubsDir = substr($arg, strlen('--stubs-dir='));
+ } elseif ($arg === '--check') {
+ $check = true;
+ } else {
+ fwrite(STDERR, "usage: generate-stubs [--composer-root=DIR] [--report=FILE] [--stubs-dir=DIR] [--check]\n");
+ exit(2);
+ }
+}
+
+$targets = [];
+foreach (file(__DIR__ . '/targets.list', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
+ $line = trim($line);
+ if ($line !== '' && !str_starts_with($line, '#')) {
+ $targets[] = $line;
+ }
+}
+
+try {
+ $generator = new Generator($composerRoot, Report::load($reportPath), $targets);
+ $files = $generator->generate();
+} catch (GenerationError $e) {
+ foreach ($e->errors as $error) {
+ fwrite(STDERR, "error: $error\n");
+ }
+ exit(1);
+}
+
+$problems = [];
+
+$iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($stubsDir, FilesystemIterator::SKIP_DOTS)
+);
+foreach ($iterator as $entry) {
+ if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) {
+ $relative = substr($entry->getPathname(), strlen($stubsDir) + 1);
+ if (!isset($files[$relative])) {
+ $problems[] = "stale stub not covered by targets.list: $stubsDir/$relative";
+ }
+ }
+}
+
+// The Rust side embeds every stub with include_str!; the two lists must not drift apart.
+$libRsText = (string) file_get_contents($libRs);
+foreach ($files as $relative => $_) {
+ if (!str_contains($libRsText, "include_str!(\"../php/stubs/$relative\")")) {
+ $problems[] = "STUB_FILES in $libRs does not embed $relative";
+ }
+}
+
+if ($check) {
+ foreach ($files as $relative => $content) {
+ $current = @file_get_contents("$stubsDir/$relative");
+ if ($current === false) {
+ $problems[] = "missing stub (regenerate): $stubsDir/$relative";
+ } elseif ($current !== $content) {
+ $problems[] = "stale stub (regenerate): $stubsDir/$relative";
+ }
+ }
+} else {
+ foreach ($files as $relative => $content) {
+ $path = "$stubsDir/$relative";
+ if (!is_dir(dirname($path))) {
+ mkdir(dirname($path), 0777, true);
+ }
+ file_put_contents($path, $content);
+ }
+ fwrite(STDERR, count($files) . " stubs written to $stubsDir\n");
+}
+
+foreach ($problems as $problem) {
+ fwrite(STDERR, "error: $problem\n");
+}
+exit($problems === [] ? 0 : 1);
diff --git a/scripts/plugin-stub-generator/src/GenerationError.php b/scripts/plugin-stub-generator/src/GenerationError.php
new file mode 100644
index 00000000..c85bede4
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/GenerationError.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+final class GenerationError extends \RuntimeException
+{
+ /** @param list<string> $errors */
+ public function __construct(public readonly array $errors)
+ {
+ parent::__construct(implode("\n", $errors));
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php
new file mode 100644
index 00000000..9ab16ed8
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Generator.php
@@ -0,0 +1,383 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\Class_;
+use PhpParser\Node\Stmt\ClassMethod;
+use PhpParser\Node\Stmt\Interface_;
+
+/**
+ * Emits the proxy stub files deterministically from the Composer sources and the classifier
+ * report. Anything the emitter cannot faithfully proxy (by-ref or variadic parameters,
+ * magic methods, public properties, non-public constants) fails generation instead of
+ * degrading silently.
+ */
+final class Generator
+{
+ private const BOILERPLATE = <<<'PHP'
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ public function __construct(int $rhandle = 0, int $epoch = 0)
+ {
+ if (func_num_args() < 2) {
+ // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`)
+ // is an open question of the plugin design; only proxy instantiation passes a
+ // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError.
+ throw new \RuntimeException(
+ 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet'
+ );
+ }
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+ PHP;
+
+ private Project $project;
+
+ private NamePrinter $printer;
+
+ /** @var list<string> */
+ private array $errors = [];
+
+ /** @var array<string, true> */
+ private array $targetSet = [];
+
+ /**
+ * Emitted method records per stub class: fqcn => name => fingerprint. A subclass stub
+ * omits overrides whose parameter list matches the record; the record therefore doubles
+ * as the divergence detector for omitted overrides.
+ *
+ * @var array<string, array<string, list<array{string, bool, string, bool, bool}>>>
+ */
+ private array $surfaces = [];
+
+ /** @param list<string> $targets */
+ public function __construct(
+ string $composerRoot,
+ private readonly Report $report,
+ private readonly array $targets,
+ ) {
+ $this->project = new Project($composerRoot);
+ $this->printer = new NamePrinter();
+ foreach ($targets as $fqcn) {
+ $this->targetSet[$fqcn] = true;
+ }
+ }
+
+ /** @return array<string, string> relative stub path => file content */
+ public function generate(): array
+ {
+ $files = [];
+ foreach ($this->targets as $fqcn) {
+ $files[str_replace('\\', '/', $fqcn) . '.php'] = $this->emitClass($fqcn);
+ }
+ if ($this->errors !== []) {
+ throw new GenerationError($this->errors);
+ }
+ return $files;
+ }
+
+ private function emitClass(string $fqcn): string
+ {
+ $file = $this->project->sourceFor($fqcn);
+ $class = $file->classLike;
+ if (!$class instanceof Class_) {
+ $this->errors[] = "$fqcn is not a class";
+ return '';
+ }
+ $category = $this->report->category($fqcn);
+ if (!in_array($category, ['rust-proxy', 'contract'], true)) {
+ $this->errors[] = "$fqcn is classified as " . ($category ?? 'nothing')
+ . '; only rust-proxy and contract classes can become proxy stubs';
+ }
+
+ $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends);
+ $isRoot = $parentFqcn === null;
+ if ($parentFqcn !== null && !isset($this->targetSet[$parentFqcn])) {
+ $this->errors[] = "$fqcn extends $parentFqcn, which is not a stub target";
+ $isRoot = true;
+ }
+ if (!$isRoot && !isset($this->surfaces[$parentFqcn])) {
+ $this->errors[] = "$fqcn must come after its base class $parentFqcn in targets.list";
+ $isRoot = true;
+ }
+
+ foreach ($class->getProperties() as $property) {
+ if ($property->isPublic()) {
+ $this->errors[] = "$fqcn declares a public property; a proxy stub cannot forward property access";
+ }
+ }
+
+ $constants = [];
+ foreach ($class->getConstants() as $constant) {
+ if (!$constant->isPublic()) {
+ $this->errors[] = "$fqcn declares a non-public constant; materializing it is not supported";
+ continue;
+ }
+ $constants[] = $file->verbatim($constant->getStartLine(), $constant->getEndLine());
+ }
+
+ $publicStatics = [];
+ $nonPublicStatics = [];
+ $ownInstanceMethods = [];
+ foreach ($class->getMethods() as $method) {
+ $name = $method->name->toString();
+ if ($name === '__construct') {
+ continue;
+ }
+ if (str_starts_with($name, '__')) {
+ if ($method->isPublic()) {
+ $this->errors[] = "$fqcn::$name: magic methods cannot be proxied";
+ }
+ continue;
+ }
+ if ($method->isStatic()) {
+ if ($method->isPublic()) {
+ $publicStatics[$name] = $method;
+ } else {
+ $nonPublicStatics[$name] = $method;
+ }
+ } elseif ($method->isPublic()) {
+ $ownInstanceMethods[$name] = $method;
+ }
+ }
+ $staticMethods = $this->materializeStatics($file, $publicStatics, $nonPublicStatics);
+
+ $surface = [];
+ $emitted = [];
+ if ($isRoot) {
+ foreach ($this->interfaceClosure($class) as $interface) {
+ foreach ($interface->classLike->getMethods() as $method) {
+ $name = $method->name->toString();
+ if ($method->isStatic()) {
+ $this->errors[] = "$fqcn: static interface method $name is not supported";
+ continue;
+ }
+ $emitted[$name] ??= $method;
+ }
+ }
+ // A concrete redeclaration wins over the interface signature (it may widen
+ // defaults); it keeps the interface's position in the emission order.
+ foreach ($ownInstanceMethods as $name => $method) {
+ $emitted[$name] = $method;
+ }
+ } else {
+ $surface = $this->surfaces[$parentFqcn];
+ if ($class->implements !== []) {
+ $this->errors[] = "$fqcn adds interfaces to a stub base class; this is not supported yet";
+ }
+ foreach ($ownInstanceMethods as $name => $method) {
+ if (isset($surface[$name])) {
+ $this->checkOmittedOverride($fqcn, $name, $method, $file, $surface[$name]);
+ } else {
+ $emitted[$name] = $method;
+ }
+ }
+ }
+
+ $methodTexts = [];
+ foreach ($emitted as $name => $method) {
+ $methodTexts[] = $this->renderProxyMethod($fqcn, $name, $method, $file);
+ $surface[$name] = $this->fingerprint($method, $file);
+ }
+ $this->surfaces[$fqcn] = $surface;
+
+ $decl = ($class->isAbstract() ? 'abstract ' : '') . 'class ' . $class->name?->toString();
+ if ($class->extends !== null) {
+ $decl .= ' extends ' . $this->printer->renderName($class->extends, $file);
+ }
+ if ($isRoot) {
+ $interfaces = array_map(fn (Name $n): string => $this->printer->renderName($n, $file), $class->implements);
+ $interfaces[] = '\ShirabeRustStub';
+ $decl .= ' implements ' . implode(', ', $interfaces);
+ }
+
+ $members = [];
+ if ($isRoot) {
+ $members[] = self::BOILERPLATE;
+ }
+ if ($constants !== []) {
+ $members[] = implode("\n", $constants);
+ }
+ $members = array_merge($members, $staticMethods, $methodTexts);
+ $body = implode("\n\n", $members);
+
+ $header = "// Generated by scripts/plugin-stub-generator; do not edit by hand.\n"
+ . "// Proxy stub for $fqcn: the public surface forwards to the Rust-side entity over RPC.";
+ $text = "<?php\n\n$header\n\nnamespace {$file->namespace};\n\n";
+ $uses = $this->usedImports($file, $decl . "\n" . $body);
+ if ($uses !== '') {
+ $text .= $uses . "\n\n";
+ }
+ return $text . $decl . "\n{\n" . ($body === '' ? '' : $body . "\n") . "}\n";
+ }
+
+ /**
+ * Static methods read no instance state; their real implementation is materialized
+ * verbatim so they run locally in the worker. Non-public static helpers they call
+ * (through `self::`, `static::` or the class name) are materialized along with them.
+ *
+ * @param array<string, ClassMethod> $publicStatics
+ * @param array<string, ClassMethod> $nonPublicStatics
+ * @return list<string>
+ */
+ private function materializeStatics(SourceFile $file, array $publicStatics, array $nonPublicStatics): array
+ {
+ $texts = [];
+ foreach ($publicStatics as $name => $method) {
+ $texts[$name] = $file->verbatim($method->getStartLine(), $method->getEndLine());
+ }
+ $scan = array_values($texts);
+ while ($scan !== []) {
+ $text = array_shift($scan);
+ foreach ($nonPublicStatics as $name => $method) {
+ if (isset($texts[$name])) {
+ continue;
+ }
+ $receiver = '(?:self|static|' . preg_quote($file->classLike->name?->toString() ?? '', '/') . ')';
+ if (preg_match('/(?<![\w$])' . $receiver . '::' . preg_quote($name, '/') . '\s*\(/', $text) === 1) {
+ $scan[] = $texts[$name] = $file->verbatim($method->getStartLine(), $method->getEndLine());
+ }
+ }
+ }
+ return array_values($texts);
+ }
+
+ /** Interfaces implemented by the class, each interface preceding the ones it extends. */
+ private function interfaceClosure(Class_ $class): array
+ {
+ $out = [];
+ $seen = [];
+ $visit = function (string $fqcn) use (&$visit, &$out, &$seen): void {
+ if (isset($seen[$fqcn])) {
+ return;
+ }
+ $seen[$fqcn] = true;
+ $file = $this->project->sourceFor($fqcn);
+ if (!$file->classLike instanceof Interface_) {
+ $this->errors[] = "$fqcn is implemented as an interface but is not one";
+ return;
+ }
+ $out[] = $file;
+ foreach ($file->classLike->extends as $parent) {
+ $visit($this->resolvedName($parent));
+ }
+ };
+ foreach ($class->implements as $interface) {
+ $visit($this->resolvedName($interface));
+ }
+ return $out;
+ }
+
+ private function renderProxyMethod(string $fqcn, string $name, ClassMethod $method, SourceFile $target): string
+ {
+ $params = [];
+ $args = [];
+ foreach ($method->params as $param) {
+ $paramName = $param->var->name;
+ if ($param->byRef) {
+ $this->errors[] = "$fqcn::$name: by-ref parameter \$$paramName cannot be proxied yet";
+ }
+ if ($param->variadic) {
+ $this->errors[] = "$fqcn::$name: variadic parameter \$$paramName cannot be proxied yet";
+ }
+ $rendered = '';
+ if ($param->type !== null) {
+ $rendered = $this->printer->renderType($param->type, $target) . ' ';
+ }
+ $rendered .= '$' . $paramName;
+ if ($param->default !== null) {
+ $rendered .= ' = ' . $this->printer->renderExpr($param->default, $target);
+ }
+ $params[] = $rendered;
+ $args[] = '$' . $paramName;
+ }
+
+ $returnType = $this->printer->renderType($method->returnType, $target);
+ $signature = "public function $name(" . implode(', ', $params) . ')'
+ . ($returnType === '' ? '' : ": $returnType");
+ $call = "\\ShirabeRpcRuntime::callRust(\$this->__rhandle, '$name', [" . implode(', ', $args) . '])';
+ // `self`/`static` returns are fluent interfaces; the local stub itself is returned to
+ // preserve identity instead of round-tripping the handle.
+ $body = match ($returnType) {
+ 'void' => " $call;",
+ 'self', 'static' => " $call;\n return \$this;",
+ default => " return $call;",
+ };
+ return " $signature\n {\n$body\n }";
+ }
+
+ /**
+ * An override that is omitted from a subclass stub must take exactly the parameters the
+ * inherited stub method declares: same names, arity, defaults and passing modes. Type
+ * declarations are allowed to differ (overrides may widen them; the forwarding body is
+ * type-agnostic either way).
+ */
+ private function checkOmittedOverride(
+ string $fqcn,
+ string $name,
+ ClassMethod $method,
+ SourceFile $file,
+ array $inherited,
+ ): void {
+ if ($this->fingerprint($method, $file) !== $inherited) {
+ $this->errors[] = "$fqcn::$name: override diverges from the inherited stub signature"
+ . ' (names, arity, defaults or passing modes); it can no longer be omitted';
+ }
+ }
+
+ /** @return list<array{string, bool, string, bool, bool}> */
+ private function fingerprint(ClassMethod $method, SourceFile $file): array
+ {
+ $fingerprint = [];
+ foreach ($method->params as $param) {
+ $fingerprint[] = [
+ $param->var->name,
+ $param->default !== null,
+ $param->default === null ? '' : $this->printer->renderExpr($param->default, $file),
+ $param->byRef,
+ $param->variadic,
+ ];
+ }
+ return $fingerprint;
+ }
+
+ /** The original file's imports, restricted to names the emitted stub actually uses. */
+ private function usedImports(SourceFile $file, string $emittedText): string
+ {
+ $kept = [];
+ foreach ($file->aliases as $alias => $fqcn) {
+ if (preg_match('/(?<![\\\\$\w])' . preg_quote($alias, '/') . '\b/', $emittedText) === 1) {
+ $kept[] = 'use ' . $fqcn
+ . (str_ends_with($fqcn, '\\' . $alias) || $fqcn === $alias ? '' : " as $alias") . ';';
+ }
+ }
+ return implode("\n", $kept);
+ }
+
+ private function resolvedName(Name $name): string
+ {
+ $resolved = $name->getAttribute('resolvedName');
+ return $resolved instanceof Name ? $resolved->toString() : $name->toString();
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/NamePrinter.php b/scripts/plugin-stub-generator/src/NamePrinter.php
new file mode 100644
index 00000000..1675fee1
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/NamePrinter.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node;
+use PhpParser\Node\Identifier;
+use PhpParser\Node\IntersectionType;
+use PhpParser\Node\Name;
+use PhpParser\Node\NullableType;
+use PhpParser\Node\UnionType;
+use PhpParser\PrettyPrinter\Standard;
+
+/**
+ * Renders type and default-value nodes into a target file context: class names resolve to
+ * their FQCN and are re-spelled through the target's import table (alias if imported, bare
+ * name if in the stub's namespace, `\FQCN` otherwise). This lets signatures declared in one
+ * file (an interface) be emitted into a stub that carries another file's `use` block.
+ */
+final class NamePrinter extends Standard
+{
+ private SourceFile $context;
+
+ public function __construct()
+ {
+ parent::__construct(['shortArraySyntax' => true]);
+ }
+
+ public function renderType(?Node $type, SourceFile $context): string
+ {
+ if ($type === null) {
+ return '';
+ }
+ if ($type instanceof Identifier) {
+ return $type->toString();
+ }
+ if ($type instanceof NullableType) {
+ return '?' . $this->renderType($type->type, $context);
+ }
+ if ($type instanceof UnionType) {
+ return implode('|', array_map(fn (Node $t): string => $this->renderType($t, $context), $type->types));
+ }
+ if ($type instanceof IntersectionType) {
+ return implode('&', array_map(fn (Node $t): string => $this->renderType($t, $context), $type->types));
+ }
+ if ($type instanceof Name) {
+ return $this->renderName($type, $context);
+ }
+ throw new GenerationError(['unsupported type node ' . get_class($type)]);
+ }
+
+ public function renderName(Name $name, SourceFile $context): string
+ {
+ if ($name->isSpecialClassName()) {
+ return $name->toString();
+ }
+ $resolved = $name->getAttribute('resolvedName');
+ $fqcn = $resolved instanceof Name ? $resolved->toString() : $name->toString();
+ if ($name->isUnqualified() && !str_contains($fqcn, '\\')) {
+ // Names that resolved into the global namespace only occur in constant space
+ // (true, false, null, ...); they keep their source spelling.
+ return $name->toString();
+ }
+ return $this->renderFqcn($fqcn, $context);
+ }
+
+ public function renderFqcn(string $fqcn, SourceFile $context): string
+ {
+ foreach ($context->aliases as $alias => $target) {
+ if ($target === $fqcn) {
+ return $alias;
+ }
+ }
+ $prefix = $context->namespace === null ? '' : $context->namespace . '\\';
+ if ($prefix !== '' && str_starts_with($fqcn, $prefix) && !str_contains(substr($fqcn, strlen($prefix)), '\\')) {
+ return substr($fqcn, strlen($prefix));
+ }
+ return '\\' . $fqcn;
+ }
+
+ public function renderExpr(Node\Expr $expr, SourceFile $context): string
+ {
+ $this->context = $context;
+ return $this->prettyPrintExpr($expr);
+ }
+
+ protected function pName(Name $node): string
+ {
+ return $this->renderName($node, $this->context);
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Project.php b/scripts/plugin-stub-generator/src/Project.php
new file mode 100644
index 00000000..13f1067d
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Project.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+
+/**
+ * Locates and parses classes of the Composer checkout (composer/src and composer/vendor),
+ * using the checkout's own PSR-4 autoload map.
+ */
+final class Project
+{
+ private Parser $parser;
+
+ /** @var array<string, list<string>> namespace prefix => base dirs, longest prefix first */
+ private array $psr4;
+
+ /** @var array<string, SourceFile> */
+ private array $cache = [];
+
+ public function __construct(string $composerRoot)
+ {
+ $this->parser = (new ParserFactory())->createForNewestSupportedVersion();
+
+ $mapFile = $composerRoot . '/vendor/composer/autoload_psr4.php';
+ if (!is_file($mapFile)) {
+ throw new GenerationError(["missing $mapFile (run composer install in the checkout)"]);
+ }
+ $map = require $mapFile;
+ $map['Composer\\'] ??= [$composerRoot . '/src/Composer'];
+ uksort($map, static fn (string $a, string $b): int => strlen($b) <=> strlen($a));
+ $this->psr4 = $map;
+ }
+
+ public function sourceFor(string $fqcn): SourceFile
+ {
+ return $this->cache[$fqcn] ??= new SourceFile($this->fileFor($fqcn), $fqcn, $this->parser);
+ }
+
+ private function fileFor(string $fqcn): string
+ {
+ foreach ($this->psr4 as $prefix => $dirs) {
+ if (!str_starts_with($fqcn, $prefix)) {
+ continue;
+ }
+ $relative = str_replace('\\', '/', substr($fqcn, strlen($prefix))) . '.php';
+ foreach ($dirs as $dir) {
+ $path = $dir . '/' . $relative;
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+ }
+ throw new GenerationError(["cannot locate a source file for $fqcn"]);
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Report.php b/scripts/plugin-stub-generator/src/Report.php
new file mode 100644
index 00000000..92e0565e
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Report.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+/** The classifier report (scripts/plugin-class-classifier/report.json). */
+final class Report
+{
+ /** @param array<string, string> $categories fqcn => category */
+ private function __construct(private readonly array $categories)
+ {
+ }
+
+ public static function load(string $path): self
+ {
+ if (!is_file($path)) {
+ throw new GenerationError([
+ "missing classifier report $path (run scripts/plugin-class-classifier/classify first)",
+ ]);
+ }
+ $data = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
+ if (($data['violations'] ?? null) !== []) {
+ throw new GenerationError(["$path records classification violations; fix the classifier lists first"]);
+ }
+ $categories = [];
+ foreach ($data['classes'] as $class) {
+ $categories[$class['fqcn']] = $class['category'];
+ }
+ return new self($categories);
+ }
+
+ public function category(string $fqcn): ?string
+ {
+ return $this->categories[$fqcn] ?? null;
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/SourceFile.php b/scripts/plugin-stub-generator/src/SourceFile.php
new file mode 100644
index 00000000..771e83a8
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/SourceFile.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node\Stmt\ClassLike;
+use PhpParser\Node\Stmt\GroupUse;
+use PhpParser\Node\Stmt\Namespace_;
+use PhpParser\Node\Stmt\Use_;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\NameResolver;
+use PhpParser\Parser;
+
+/**
+ * A parsed source file: the class-like it declares, its namespace and its import table.
+ * All Name nodes in the AST carry a `resolvedName` attribute (NameResolver with
+ * replaceNodes disabled), so signatures can be re-rendered under another file's context.
+ */
+final class SourceFile
+{
+ public ?string $namespace = null;
+
+ /** @var array<string, string> alias as written => FQCN, in declaration order */
+ public array $aliases = [];
+
+ public ClassLike $classLike;
+
+ /** @var list<string> */
+ public array $lines;
+
+ public function __construct(
+ public readonly string $path,
+ public readonly string $expectedFqcn,
+ Parser $parser,
+ ) {
+ $code = file_get_contents($path);
+ if ($code === false) {
+ throw new GenerationError(["cannot read $path"]);
+ }
+ $this->lines = explode("\n", $code);
+
+ $ast = $parser->parse($code);
+ if ($ast === null) {
+ throw new GenerationError(["cannot parse $path"]);
+ }
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
+ $ast = $traverser->traverse($ast);
+
+ $classLike = null;
+ foreach ($ast as $stmt) {
+ if (!$stmt instanceof Namespace_) {
+ continue;
+ }
+ $this->namespace = $stmt->name?->toString();
+ foreach ($stmt->stmts as $inner) {
+ if ($inner instanceof Use_) {
+ if ($inner->type !== Use_::TYPE_NORMAL) {
+ throw new GenerationError(["$path: function/const use statements are not supported"]);
+ }
+ foreach ($inner->uses as $use) {
+ $this->aliases[$use->getAlias()->toString()] = $use->name->toString();
+ }
+ } elseif ($inner instanceof GroupUse) {
+ throw new GenerationError(["$path: group use statements are not supported"]);
+ } elseif ($inner instanceof ClassLike) {
+ $fqcn = ($this->namespace === null ? '' : $this->namespace . '\\') . $inner->name?->toString();
+ if ($fqcn === $expectedFqcn) {
+ $classLike = $inner;
+ }
+ }
+ }
+ }
+ if ($classLike === null) {
+ throw new GenerationError(["$path does not declare $expectedFqcn"]);
+ }
+ $this->classLike = $classLike;
+ }
+
+ /** The declaration's verbatim source text, without any leading doc comment. */
+ public function verbatim(int $startLine, int $endLine): string
+ {
+ return implode("\n", array_slice($this->lines, $startLine - 1, $endLine - $startLine + 1));
+ }
+}
diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list
new file mode 100644
index 00000000..42794e13
--- /dev/null
+++ b/scripts/plugin-stub-generator/targets.list
@@ -0,0 +1,12 @@
+# Classes emitted as proxy stubs into crates/shirabe-php-rpc/php/stubs/. One FQCN per
+# line; the output path is derived from the FQCN. Every entry must be classified as
+# rust-proxy or contract in the classifier report, and stub base classes must precede
+# their subclasses.
+Composer\EventDispatcher\Event
+Composer\Script\Event
+Composer\PartialComposer
+Composer\Composer
+Composer\IO\BaseIO
+Composer\IO\ConsoleIO
+Composer\IO\BufferIO
+Composer\IO\NullIO