From 9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 5 Aug 2026 03:58:03 +0900 Subject: feat(plugin): run plugin-provided commands in a worker-side application A same-FQCN Composer\Console\Application, hand-written under the new php/runtime/ tree, hosts CommandProvider commands inside the PHP worker: PhpCommandProxy overrides run() and forwards the stringified input, so the real Symfony machinery binds, validates and executes against the live command object, while help/list render Rust-side from a definition read back at construction. Reverse \Shirabe\RustCommandStub rows let a plugin command invoke built-in commands back in the Rust process, keeping every command on the side whose helper set it was written for. Composer\EventDispatcher\Event moves from a generated stub to a dual-mode runtime class: the real BaseCommand::initialize constructs a PreCommandRunEvent natively in the worker, which a proxy-only constructor guard rejected. Its PRE_COMMAND_RUN dispatch reaches a new EventDispatcher stub whose dispatch supports the observably-no-op no-listener case and fails explicitly otherwise. The stub generator now accepts runtime-provided classes as stub bases (never as targets) and cross-checks the Application handoff property table against the real class. Co-Authored-By: Claude Fable 5 --- scripts/plugin-stub-generator/generate-stubs | 61 +++++++++++++++++++++- scripts/plugin-stub-generator/src/Generator.php | 67 +++++++++++++++++++++++-- scripts/plugin-stub-generator/targets.list | 2 +- 3 files changed, 125 insertions(+), 5 deletions(-) (limited to 'scripts') diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs index c6b03c97..934577f3 100755 --- a/scripts/plugin-stub-generator/generate-stubs +++ b/scripts/plugin-stub-generator/generate-stubs @@ -7,6 +7,7 @@ require __DIR__ . '/vendor/autoload.php'; use Shirabe\PluginStubGenerator\GenerationError; use Shirabe\PluginStubGenerator\Generator; +use Shirabe\PluginStubGenerator\Project; use Shirabe\PluginStubGenerator\Report; $repoRoot = dirname(__DIR__, 2); @@ -39,8 +40,29 @@ foreach (file(__DIR__ . '/targets.list', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY } } +// Hand-written dual-mode classes (php/runtime/) resolve through the same worker autoloader as +// the generated stubs; the generator must know them so they can serve as stub base classes and +// so a targets.list entry can never shadow one. +$runtimeDir = $repoRoot . '/crates/shirabe-php-rpc/php/runtime'; +$runtimeProvided = []; +$runtimeFiles = []; +if (is_dir($runtimeDir)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($runtimeDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $entry) { + if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) { + $relative = substr($entry->getPathname(), strlen($runtimeDir) + 1); + $runtimeFiles[] = $relative; + $runtimeProvided[] = str_replace('/', '\\', substr($relative, 0, -strlen('.php'))); + } + } +} +sort($runtimeProvided); +sort($runtimeFiles); + try { - $generator = new Generator($composerRoot, Report::load($reportPath), $targets); + $generator = new Generator($composerRoot, Report::load($reportPath), $targets, $runtimeProvided); $files = $generator->generate(); } catch (GenerationError $e) { foreach ($e->errors as $error) { @@ -51,6 +73,38 @@ try { $problems = []; +// Handoff classification of Composer\Console\Application's declared properties. The worker-side +// runtime definition (php/runtime/Composer/Console/Application.php) hands off exactly the state +// a plugin-visible application exposes; a property the upstream class declares and this table +// does not know means the handoff decision has not been made, and generation fails so a +// Composer version bump surfaces it explicitly instead of silently dropping state. +$applicationPropertyTable = [ + 'composer' => 'handoff', // carried by __shirabe_console_application_boot + 'io' => 'handoff', // the IO seam proxy + 'initialWorkingDirectory' => 'handoff', // copied once at boot + 'disablePluginsByDefault' => 'handoff', // copied once at boot + 'disableScriptsByDefault' => 'handoff', // copied once at boot + 'hasPluginCommands' => 'rust-local', // Rust-side runtime state, never shared + 'logo' => 'static-config', // static rendering data, the worker never needs it +]; +$applicationFile = (new Project($composerRoot))->sourceFor('Composer\\Console\\Application'); +$declaredProperties = []; +foreach ($applicationFile->classLike->getProperties() as $property) { + foreach ($property->props as $prop) { + $declaredProperties[$prop->name->toString()] = true; + } +} +foreach ($declaredProperties as $name => $_) { + if (!isset($applicationPropertyTable[$name])) { + $problems[] = "Composer\\Console\\Application::\$$name is not classified in the handoff property table in generate-stubs"; + } +} +foreach ($applicationPropertyTable as $name => $_) { + if (!isset($declaredProperties[$name])) { + $problems[] = "the handoff property table in generate-stubs classifies Composer\\Console\\Application::\$$name, which the class no longer declares"; + } +} + $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($stubsDir, FilesystemIterator::SKIP_DOTS) ); @@ -70,6 +124,11 @@ foreach ($files as $relative => $_) { $problems[] = "STUB_FILES in $libRs does not embed $relative"; } } +foreach ($runtimeFiles as $relative) { + if (!str_contains($libRsText, "include_str!(\"../php/runtime/$relative\")")) { + $problems[] = "RUNTIME_FILES in $libRs does not embed $relative"; + } +} if ($check) { foreach ($files as $relative => $content) { diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php index b7cfc859..1e41eed8 100644 --- a/scripts/plugin-stub-generator/src/Generator.php +++ b/scripts/plugin-stub-generator/src/Generator.php @@ -93,22 +93,40 @@ final class Generator */ private array $surfaces = []; - /** @param list $targets */ + /** @var array */ + private array $runtimeSet = []; + + /** + * @param list $targets + * @param list $runtimeProvided FQCNs of the hand-written dual-mode classes under + * php/runtime/; they may serve as stub base classes + * but must never be generation targets themselves + */ public function __construct( string $composerRoot, private readonly Report $report, private readonly array $targets, + private readonly array $runtimeProvided = [], ) { $this->project = new Project($composerRoot); $this->printer = new NamePrinter(); foreach ($targets as $fqcn) { $this->targetSet[$fqcn] = true; } + foreach ($runtimeProvided as $fqcn) { + $this->runtimeSet[$fqcn] = true; + } } /** @return array relative stub path => file content */ public function generate(): array { + foreach ($this->runtimeProvided as $fqcn) { + if (isset($this->targetSet[$fqcn])) { + $this->errors[] = "$fqcn is both a stub target and provided by php/runtime/;" + . ' the runtime definition would be shadowed by the generated stub'; + } + } $files = []; foreach ($this->targets as $fqcn) { $files[str_replace('\\', '/', $fqcn) . '.php'] = $this->emitClass($fqcn); @@ -119,6 +137,45 @@ final class Generator return $files; } + /** + * The stub surface a runtime-provided (hand-written, dual-mode) base class exposes, + * computed from the real Composer class the runtime file mirrors, so a generated subclass + * stub can omit the methods it inherits — the same records emitClass builds for generated + * parents. + * + * @return array> + */ + private function surfaceFromRealClass(string $fqcn): array + { + $file = $this->project->sourceFor($fqcn); + $class = $file->classLike; + if (!$class instanceof Class_) { + $this->errors[] = "$fqcn is not a class"; + return []; + } + $surface = []; + $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends); + if ($parentFqcn !== null) { + $surface = $this->surfaces[$parentFqcn] ?? $this->surfaceFromRealClass($parentFqcn); + } + foreach ($this->interfaceClosure($class) as $interface) { + foreach ($interface->classLike->getMethods() as $method) { + if ($method->isStatic()) { + continue; + } + $surface[$method->name->toString()] ??= $this->fingerprint($method, $file); + } + } + foreach ($class->getMethods() as $method) { + $name = $method->name->toString(); + if ($method->isStatic() || !$method->isPublic() || str_starts_with($name, '__')) { + continue; + } + $surface[$name] = $this->fingerprint($method, $file); + } + return $surface; + } + private function emitClass(string $fqcn): string { $file = $this->project->sourceFor($fqcn); @@ -136,8 +193,12 @@ final class Generator $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 (isset($this->runtimeSet[$parentFqcn])) { + $this->surfaces[$parentFqcn] ??= $this->surfaceFromRealClass($parentFqcn); + } else { + $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"; diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 59656c6a..17f14615 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -2,7 +2,7 @@ # 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\EventDispatcher\EventDispatcher Composer\Script\Event Composer\PartialComposer Composer\Composer -- cgit v1.3.1