aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Composer.php11
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php14
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php22
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php4
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php53
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php4
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php13
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php10
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs5
-rw-r--r--crates/shirabe-php-rpc/tests/generated_stubs.rs40
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs4
-rw-r--r--docs/dev/php-rpc.md7
-rw-r--r--docs/dev/plugin-stub-generation.md86
-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
24 files changed, 1099 insertions, 47 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php b/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php
index 1bb9f64c..de5a6d1b 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php
@@ -1,17 +1,16 @@
<?php
-// Hand-written proxy stub for Composer\Composer, kept in the shape the future stub generator
-// will output. Class constants and static methods are materialized (they read no instance
-// state); instance methods forward to the Rust-side entity.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\Composer: the public surface forwards to the Rust-side entity over RPC.
namespace Composer;
-use Composer\Autoload\AutoloadGenerator;
-use Composer\Package\Archiver\ArchiveManager;
use Composer\Package\Locker;
-use Composer\Downloader\DownloadManager;
use Composer\Pcre\Preg;
use Composer\Plugin\PluginManager;
+use Composer\Downloader\DownloadManager;
+use Composer\Autoload\AutoloadGenerator;
+use Composer\Package\Archiver\ArchiveManager;
class Composer extends PartialComposer
{
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
index 9ce9c12f..fea84c75 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
@@ -1,7 +1,7 @@
<?php
-// Hand-written proxy stub for Composer\EventDispatcher\Event, kept in the shape the future stub
-// generator will output: the real public methods, each forwarding to the Rust-side entity.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\EventDispatcher\Event: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\EventDispatcher;
@@ -12,8 +12,16 @@ class Event implements \ShirabeRustStub
/** @var int */
protected $__epoch;
- public function __construct(int $rhandle, int $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;
}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php
index 51cdbe08..55da6415 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php
@@ -1,9 +1,7 @@
<?php
-// Hand-written proxy stub for Composer\IO\BaseIO, kept in the shape the future stub generator
-// will output: every public method of the real class (its own and the IOInterface /
-// LoggerInterface surface) forwards to the Rust-side entity. Loading this stub pulls the real
-// IOInterface contract from the Composer PHP runtime, which must already be required.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\IO\BaseIO: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\IO;
@@ -129,22 +127,17 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getAuthentications', []);
}
- public function resetAuthentications()
- {
- return \ShirabeRpcRuntime::callRust($this->__rhandle, 'resetAuthentications', []);
- }
-
- public function hasAuthentication(string $repositoryName)
+ public function hasAuthentication($repositoryName)
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasAuthentication', [$repositoryName]);
}
- public function getAuthentication(string $repositoryName)
+ public function getAuthentication($repositoryName)
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getAuthentication', [$repositoryName]);
}
- public function setAuthentication(string $repositoryName, string $username, ?string $password = null)
+ public function setAuthentication($repositoryName, $username, $password = null)
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'setAuthentication', [$repositoryName, $username, $password]);
}
@@ -198,4 +191,9 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub
{
\ShirabeRpcRuntime::callRust($this->__rhandle, 'log', [$level, $message, $context]);
}
+
+ public function resetAuthentications()
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'resetAuthentications', []);
+ }
}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php
index e95fb2da..d5e59e90 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php
@@ -1,7 +1,7 @@
<?php
-// Hand-written proxy stub for Composer\IO\BufferIO, kept in the shape the future stub
-// generator will output.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\IO\BufferIO: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\IO;
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php
index 6a4c2ac4..0041363a 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php
@@ -1,15 +1,62 @@
<?php
-// Hand-written proxy stub for Composer\IO\ConsoleIO, kept in the shape the future stub
-// generator will output. The IOInterface surface is inherited from the BaseIO stub; only the
-// public methods ConsoleIO adds are declared here.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\IO\ConsoleIO: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\IO;
+use Composer\Pcre\Preg;
use Symfony\Component\Console\Helper\Table;
class ConsoleIO extends BaseIO
{
+ public static function sanitize($messages, bool $allowNewlines = true)
+ {
+ // Match ANSI escape sequences:
+ // - CSI (Control Sequence Introducer): ESC [ params intermediate final
+ // - OSC (Operating System Command): ESC ] ... ESC \ or BEL
+ // - Other ESC sequences: ESC followed by any character
+ $escapePattern = '\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|\x1B\].*?(?:\x1B\\\\|\x07)|\x1B.';
+ $pattern = $allowNewlines ? "{{$escapePattern}|[\x01-\x09\x0B\x0C\x0E-\x1A]|\r(?!\n)}u" : "{{$escapePattern}|[\x01-\x1A]}u";
+ if (is_string($messages)) {
+ $messages = self::ensureValidUtf8($messages);
+ return Preg::replace($pattern, '', $messages);
+ }
+
+ $sanitized = [];
+ foreach ($messages as $key => $message) {
+ $message = self::ensureValidUtf8($message);
+ $sanitized[$key] = Preg::replace($pattern, '', $message);
+ }
+
+ return $sanitized;
+ }
+
+ private static function ensureValidUtf8(string $string): string
+ {
+ // Quick check: if string is already valid UTF-8, return as-is
+ if (function_exists('mb_check_encoding') && mb_check_encoding($string, 'UTF-8')) {
+ return $string;
+ }
+
+ // Use mb_convert_encoding to replace invalid sequences with '?'
+ // This makes it visible when data quality issues occur
+ if (function_exists('mb_convert_encoding')) {
+ return (string) mb_convert_encoding($string, 'UTF-8', 'UTF-8');
+ }
+
+ // Fallback to iconv if mbstring unavailable
+ if (function_exists('iconv')) {
+ $cleaned = @iconv('UTF-8', 'UTF-8//TRANSLIT', $string);
+ if ($cleaned !== false) {
+ return $cleaned;
+ }
+ }
+
+ // Last resort: return as-is (should never happen - Composer requires mbstring OR iconv)
+ return $string;
+ }
+
public function enableDebugging(float $startTime)
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'enableDebugging', [$startTime]);
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php
index 92e01700..1234ad4a 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php
@@ -1,7 +1,7 @@
<?php
-// Hand-written proxy stub for Composer\IO\NullIO, kept in the shape the future stub generator
-// will output. The whole surface is inherited from the BaseIO stub.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\IO\NullIO: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\IO;
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php
index 2641b267..588800dc 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php
@@ -1,18 +1,15 @@
<?php
-// Hand-written proxy stub for Composer\PartialComposer, kept in the shape the future stub
-// generator will output: the real public methods, each forwarding to the Rust-side entity.
-// Methods the Rust dispatcher does not support yet surface as explicit RPC errors.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\PartialComposer: the public surface forwards to the Rust-side entity over RPC.
namespace Composer;
-use Composer\Autoload\AutoloadGenerator;
-use Composer\Config;
-use Composer\EventDispatcher\EventDispatcher;
-use Composer\Installer\InstallationManager;
use Composer\Package\RootPackageInterface;
-use Composer\Repository\RepositoryManager;
use Composer\Util\Loop;
+use Composer\Repository\RepositoryManager;
+use Composer\Installer\InstallationManager;
+use Composer\EventDispatcher\EventDispatcher;
class PartialComposer implements \ShirabeRustStub
{
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
index 80afe1b7..a93d0dfc 100644
--- a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
@@ -1,20 +1,22 @@
<?php
-// Hand-written proxy stub for Composer\Script\Event, kept in the shape the future stub
-// generator will output. See Composer/EventDispatcher/Event.php.
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\Script\Event: the public surface forwards to the Rust-side entity over RPC.
namespace Composer\Script;
+use Composer\Composer;
+use Composer\IO\IOInterface;
use Composer\EventDispatcher\Event as BaseEvent;
class Event extends BaseEvent
{
- public function getComposer(): \Composer\Composer
+ public function getComposer(): Composer
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []);
}
- public function getIO(): \Composer\IO\IOInterface
+ public function getIO(): IOInterface
{
return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []);
}
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 9c5dae55..23996a95 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -539,8 +539,9 @@ fn call(name: &str, arg: &str) -> PhpMixed {
const GLUE_SCRIPT: &str = include_str!("../php/worker.php");
-/// Hand-written proxy stub classes made autoloadable inside the worker, written in the shape the
-/// future stub generator will output.
+/// Proxy stub classes made autoloadable inside the worker. Generated by
+/// `scripts/plugin-stub-generator/generate-stubs`; this list must cover its targets.list (the
+/// generator's `--check` mode verifies both the file contents and this list).
const STUB_FILES: &[(&str, &str)] = &[
(
"Composer/EventDispatcher/Event.php",
diff --git a/crates/shirabe-php-rpc/tests/generated_stubs.rs b/crates/shirabe-php-rpc/tests/generated_stubs.rs
new file mode 100644
index 00000000..b4ff8197
--- /dev/null
+++ b/crates/shirabe-php-rpc/tests/generated_stubs.rs
@@ -0,0 +1,40 @@
+//! Freshness check for the generated proxy stubs: `generate-stubs --check` verifies that the
+//! committed stub files and the `STUB_FILES` list in `lib.rs` match what the generator emits
+//! from the current Composer checkout and classifier report.
+//!
+//! The generator needs a PHP interpreter, its composer vendor directory and the classifier
+//! report; when any of those is missing the test returns early, following the non-mock test
+//! convention of this crate.
+
+use shirabe_external_packages::symfony::process::PhpExecutableFinder;
+use std::path::Path;
+
+#[test]
+fn generated_stubs_are_fresh() {
+ let Some(php) = PhpExecutableFinder::new().find(false) else {
+ return;
+ };
+ let tool_dir =
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/plugin-stub-generator");
+ if !tool_dir.join("vendor/autoload.php").is_file() {
+ return;
+ }
+ if !tool_dir
+ .join("../plugin-class-classifier/report.json")
+ .is_file()
+ {
+ return;
+ }
+
+ let output = std::process::Command::new(php)
+ .arg(tool_dir.join("generate-stubs"))
+ .arg("--check")
+ .output()
+ .expect("failed to spawn the stub generator");
+ assert!(
+ output.status.success(),
+ "generate-stubs --check failed; regenerate the stubs with \
+ scripts/plugin-stub-generator/generate-stubs:\n{}",
+ String::from_utf8_lossy(&output.stderr),
+ );
+}
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index 09867079..2b823fa5 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -90,8 +90,8 @@ pub(crate) fn io_stub_class(
} else if any.downcast_ref::<crate::io::null_io::NullIO>().is_some() {
Ok("Composer\\IO\\NullIO")
} else {
- // TODO(plugin): only the IO classes with hand-written proxy stubs can cross the
- // boundary until a stub generator exists.
+ // TODO(plugin): only IO implementations with a generated proxy stub can cross the
+ // boundary; the rest are an explicit error until stubs of their own are generated.
Err(anyhow::anyhow!(
"no proxy stub class is available for this IO implementation"
))
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index f8f42e90..a1093ec5 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -131,10 +131,11 @@ function; an unknown name is an explicit error. Notable internal helpers:
## Proxy stubs
-`php/stubs/` holds hand-written proxy stub classes (`Composer\EventDispatcher\Event`,
+`php/stubs/` holds the proxy stub classes (`Composer\EventDispatcher\Event`,
`Composer\Script\Event`, `Composer\PartialComposer`, `Composer\Composer`, and the
-`Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy), written in the shape the future
-stub generator will output. They are autoloaded with highest priority so a proxied FQCN can
+`Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy). They are generated by
+`scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand; see
+`docs/dev/plugin-stub-generation.md`. They are autoloaded with highest priority so a proxied FQCN can
never be shadowed by the real implementation; `__shirabe_require` restores that priority after
loading code that prepends its own autoloader. Stubs are interned per rhandle
(`WeakReference`-based registry) so identity (`===`) holds, and their destructors send
diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md
new file mode 100644
index 00000000..1fb77ff4
--- /dev/null
+++ b/docs/dev/plugin-stub-generation.md
@@ -0,0 +1,86 @@
+# Plugin proxy stub generation
+
+## Purpose
+
+The plugin PHP worker resolves proxied Composer FQCNs to thin stub classes whose
+public methods forward to the Rust-side entity over RPC (see
+`docs/dev/php-rpc.md`, "Proxy stubs"). These stub files, committed under
+`crates/shirabe-php-rpc/php/stubs/` and embedded into the binary by the
+`STUB_FILES` list in `crates/shirabe-php-rpc/src/lib.rs`, are *generated* by
+`scripts/plugin-stub-generator` — never edited by hand. Generating them from the
+Composer sources keeps every stub signature mechanically faithful to the real
+class and turns "the stub is missing something the real class has" into a
+generation failure instead of silent breakage.
+
+## Running
+
+```
+cd scripts/plugin-stub-generator
+composer install # once; vendor/ is git-ignored
+./generate-stubs # rewrites crates/shirabe-php-rpc/php/stubs/
+./generate-stubs --check # verifies committed stubs are fresh; exit 1 otherwise
+```
+
+Inputs:
+
+* `targets.list` — the FQCNs to emit, stub base classes before their subclasses.
+ Growing the stub set means adding a line here and regenerating.
+* the Composer checkout (`composer/`, override with `--composer-root=`); the
+ generator locates sources through the checkout's own PSR-4 autoload map, so
+ interfaces from vendor packages (e.g. `Psr\Log\LoggerInterface`) resolve too.
+* the classifier report (`scripts/plugin-class-classifier/report.json`, override
+ with `--report=`). Run `scripts/plugin-class-classifier/classify` first; the
+ report is git-ignored. Every target must be classified `rust-proxy` or
+ `contract`, and a report with classification violations is rejected.
+
+The freshness check runs in `cargo test` as
+`crates/shirabe-php-rpc/tests/generated_stubs.rs`; it returns early when PHP,
+the generator's vendor directory or the classifier report is unavailable.
+
+## What the generator emits
+
+* **Root stubs** (targets whose parent class is not itself a target) carry the
+ proxy boilerplate: `__rhandle`/`__epoch` properties, a constructor that
+ accepts `(rhandle, epoch)` from proxy instantiation and throws a diagnosable
+ `RuntimeException` when plugin code tries to `new` the class directly, a
+ destructor releasing the Rust handle, and the wire descriptor helper. The
+ real `extends`/`implements` hierarchy is preserved and `\ShirabeRustStub` is
+ appended to the interface list.
+* **Instance methods** forward via `\ShirabeRpcRuntime::callRust`. For a root
+ stub the emitted surface is the interface closure (each interface before the
+ ones it extends, methods in declaration order; a concrete redeclaration in
+ the class wins over the interface signature) followed by the class's own
+ remaining public methods. A subclass stub declares only the methods whose
+ *name* is new relative to the inherited stub surface; an omitted override
+ must match the inherited parameter list (names, arity, defaults, passing
+ modes — type declarations may differ), otherwise generation fails.
+* Methods returning `self`/`static` perform the RPC and then `return $this;`
+ to preserve identity instead of round-tripping the handle.
+* **Class constants and static methods** are materialized verbatim from the
+ real source (they read no instance state and run locally in the worker),
+ together with any non-public static helpers they call.
+* **Imports**: the original file's `use` statements are kept in their original
+ order, restricted to names the emitted stub references; signatures declared
+ elsewhere (interface files) are re-spelled through that import table.
+
+## Coverage assertions
+
+Generation fails — instead of emitting something quietly wrong — on:
+
+* a target missing from the classifier report, classified other than
+ `rust-proxy`/`contract`, or a report carrying violations,
+* public properties (a stub cannot forward property access),
+* by-ref or variadic parameters, magic methods, static interface methods,
+* an omitted override diverging from the inherited stub signature,
+* a subclass target listed before its base class, or extending a class that is
+ not a target,
+* non-public class constants (materializing them is unsupported so far).
+
+`generate-stubs` (in both modes) additionally fails when a `.php` file exists
+under the stubs directory that no target produces, or when `STUB_FILES` in
+`crates/shirabe-php-rpc/src/lib.rs` does not embed every generated file.
+
+When a future Composer release adds a public member the emitter cannot handle,
+these assertions surface it at generation time; extending the emitter (or
+deciding the porting policy) is then an explicit step, mirroring the
+completeness stance of `docs/dev/plugin-class-classification.md`.
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