From dc1f030e3904677cd65eac0a90c1d850e0ad1bbf Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 31 Aug 2026 00:07:36 +0900 Subject: feat(plugin): serve HttpDownloader as a proxy stub A downloader carries its own options, TLS defaults and request backends, and what it shares with the graph is the IO it collects authentication into and the config it reads. Plugin code writing `new HttpDownloader($io, $config)` therefore allocates a Rust-side entity of its own rather than a second downloader the graph knows nothing about, and the guard that shadowed the class in the worker is gone. The request surface is not served yet: get() and copy() have no wire representation for the Response they return, and the async surface resolves its promises with one. Co-Authored-By: Claude Opus 5 (1M context) --- .../php/guards/Composer/Util/HttpDownloader.php | 86 -------- .../php/stubs/Composer/Util/HttpDownloader.php | 216 +++++++++++++++++++++ crates/shirabe-php-rpc/src/lib.rs | 4 + crates/shirabe/src/plugin/php_plugin_proxy.rs | 84 +++++++- .../tests/plugin/e2e_http_downloader_test.rs | 77 ++++++++ .../e2e-http-downloader/plugin/composer.json | 17 ++ .../e2e-http-downloader/plugin/src/Plugin.php | 94 +++++++++ .../e2e-http-downloader/project/composer.json | 24 +++ crates/shirabe/tests/plugin/main.rs | 1 + docs/dev/plugin-class-classification.md | 26 ++- scripts/plugin-stub-generator/targets.list | 1 + 11 files changed, 536 insertions(+), 94 deletions(-) delete mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/Util/HttpDownloader.php create mode 100644 crates/shirabe/tests/plugin/e2e_http_downloader_test.rs create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json diff --git a/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php b/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php deleted file mode 100644 index a2fad123..00000000 --- a/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php +++ /dev/null @@ -1,86 +0,0 @@ -__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, + ]; + } + + public function __clone() + { + // PHP has already shallow-copied this stub, so both copies would point at one + // entity and release it twice. The Rust side clones the entity instead, applying + // whatever __clone semantics the real class defines, and this copy rebinds to the + // fresh handle. Entities without clone semantics answer with an explicit error. + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function __get($name) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__get', [$name]); + } + + public function __set($name, $value): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]); + } + + public function __isset($name): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__isset', [$name]); + } + + public function __unset($name): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__unset', [$name]); + } + + public function __construct(IOInterface $io, Config $config, array $options = [], bool $disableTls = false) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$io, $config, $options, $disableTls]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + private const STATUS_QUEUED = 1; + private const STATUS_STARTED = 2; + private const STATUS_COMPLETED = 3; + private const STATUS_FAILED = 4; + private const STATUS_ABORTED = 5; + + public static function outputWarnings(IOInterface $io, string $url, $data): void + { + $cleanMessage = static function ($msg) use ($io) { + if (!$io->isDecorated()) { + $msg = Preg::replace('{'.chr(27).'\\[[;\d]*m}u', '', $msg); + } + + return $msg; + }; + + // legacy warning/info keys + foreach (['warning', 'info'] as $type) { + if (empty($data[$type])) { + continue; + } + + if (!empty($data[$type . '-versions'])) { + $versionParser = new VersionParser(); + $constraint = $versionParser->parseConstraints($data[$type . '-versions']); + $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); + if (!$constraint->matches($composer)) { + continue; + } + } + + $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($data[$type]).''); + } + + // modern Composer 2.2+ format with support for multiple warning/info messages + foreach (['warnings', 'infos'] as $key) { + if (empty($data[$key])) { + continue; + } + + $versionParser = new VersionParser(); + foreach ($data[$key] as $spec) { + $type = substr($key, 0, -1); + $constraint = $versionParser->parseConstraints($spec['versions']); + $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion())); + if (!$constraint->matches($composer)) { + continue; + } + + $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($spec['message']).''); + } + } + } + + public static function getExceptionHints(\Throwable $e): ?array + { + if (!$e instanceof TransportException) { + return null; + } + + if ( + false !== strpos($e->getMessage(), 'Resolving timed out') + || false !== strpos($e->getMessage(), 'Could not resolve host') + ) { + Silencer::suppress(); + $testConnectivity = file_get_contents('https://8.8.8.8', false, stream_context_create([ + 'ssl' => ['verify_peer' => false], + 'http' => ['follow_location' => false, 'ignore_errors' => true], + ])); + Silencer::restore(); + if (false !== $testConnectivity) { + return [ + 'The following exception probably indicates you have misconfigured DNS resolver(s)', + ]; + } + + return [ + 'The following exception probably indicates you are offline or have misconfigured DNS resolver(s)', + ]; + } + + return null; + } + + public static function isCurlEnabled(): bool + { + return \extension_loaded('curl') && \function_exists('curl_multi_exec') && \function_exists('curl_multi_init'); + } + + public function get(string $url, array $options = []) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'get', [$url, $options]); + } + + public function add(string $url, array $options = []) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'add', [$url, $options]); + } + + public function copy(string $url, string $to, array $options = []) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'copy', [$url, $to, $options]); + } + + public function addCopy(string $url, string $to, array $options = []) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'addCopy', [$url, $to, $options]); + } + + public function getOptions() + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOptions', []); + } + + public function setOptions(array $options) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'setOptions', [$options]); + } + + public function wait(?int $index = null) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'wait', [$index]); + } + + public function enableAsync(): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'enableAsync', []); + } + + public function countActiveJobs(?int $index = null): int + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'countActiveJobs', [$index]); + } +} diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index c898fb82..3731c691 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -1040,6 +1040,10 @@ const STUB_FILES: &[(&str, &str)] = &[ "Composer/Util/ProcessExecutor.php", include_str!("../php/stubs/Composer/Util/ProcessExecutor.php"), ), + ( + "Composer/Util/HttpDownloader.php", + include_str!("../php/stubs/Composer/Util/HttpDownloader.php"), + ), ]; /// Hand-written worker-side classes (two-world implementations with behavior of their own, not diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index a1233391..491a320c 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -59,6 +59,7 @@ enum RustEntity { Operation(std::rc::Rc), Plugin(std::rc::Rc>), ProcessExecutor(std::rc::Rc>), + HttpDownloader(std::rc::Rc>), } /// The pointer identity backing R-table interning: the same shared instance must always cross @@ -82,6 +83,9 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize, RustEntity::Plugin(plugin) => std::rc::Rc::as_ptr(plugin) as *const () as usize, RustEntity::ProcessExecutor(process) => std::rc::Rc::as_ptr(process) as *const () as usize, + RustEntity::HttpDownloader(downloader) => { + std::rc::Rc::as_ptr(downloader) as *const () as usize + } } } @@ -382,6 +386,9 @@ pub(crate) fn dispatch_r_table_method( Some(RustEntity::ProcessExecutor(process)) => { dispatch_process_executor_method(&process, method_name, args, out_params) } + Some(RustEntity::HttpDownloader(downloader)) => { + dispatch_http_downloader_method(&downloader, method_name, args) + } None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } @@ -416,6 +423,9 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result>>(&class, ctor_args, position) + }; let alias_package_arg = |position: usize| -> Result { package_arg(position)? @@ -527,6 +537,20 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result { + let rhandle = register_entity(RustEntity::HttpDownloader(std::rc::Rc::new( + std::cell::RefCell::new(crate::util::HttpDownloader::new( + io_arg(0)?, + config_arg(1)?, + arg_or::>(&class, ctor_args, 2, IndexMap::new())?, + arg_or::(&class, ctor_args, 3, false)?, + )), + ))); + return Ok(construction_result(rhandle)); + } // TODO(plugin): the remaining proxied classes get a construction story on demand, // driven by explicit errors from real plugins. Each one has to decide what a // plugin-built instance means for the Rust-side graph, which is why none of them is @@ -578,7 +602,8 @@ fn clone_entity(entity: &RustEntity) -> Result { | RustEntity::EventDispatcher(_) | RustEntity::Operation(_) | RustEntity::Plugin(_) - | RustEntity::ProcessExecutor(_) => { + | RustEntity::ProcessExecutor(_) + | RustEntity::HttpDownloader(_) => { return Err(runtime_throw( "cloning this Rust-side entity over RPC is not supported".to_string(), )); @@ -1134,6 +1159,41 @@ fn dispatch_process_executor_method( } } +/// Serves the `HttpDownloader` proxy stub, whether the downloader behind it belongs to the object +/// graph or was built by plugin code writing `new HttpDownloader(...)`. Both run their requests +/// out of the Rust process, which is what keeps the authentication a run collects, the TLS +/// defaults and the parallel-request budget single-sourced across the boundary. +fn dispatch_http_downloader_method( + downloader: &std::rc::Rc>, + method_name: &str, + args: &[PluginValue], +) -> Result { + match method_name { + "getOptions" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( + downloader.borrow().get_options().clone(), + ))), + "setOptions" => { + let options = arg::>(method_name, args, 0)?; + downloader.borrow_mut().set_options(options); + Ok(PluginValue::Null) + } + // TODO(plugin): a `Composer\Util\Http\Response` has no representation on the wire, so + // the two synchronous request methods have nothing to answer with. + "get" | "copy" => Err(runtime_throw(format!( + "Shirabe does not support HttpDownloader::{method_name}() from a plugin yet" + ))), + // TODO(plugin,async): the async surface resolves its promises with a Response the wire + // cannot carry, and driving it needs a promise representation that crosses the boundary + // unresolved. Neither exists yet. + "add" | "addCopy" | "wait" | "enableAsync" | "countActiveJobs" => Err(runtime_throw( + format!("Shirabe does not support HttpDownloader::{method_name}() from a plugin yet"), + )), + other => Err(runtime_throw(format!( + "unknown HttpDownloader method `{other}`" + ))), + } +} + /// Decodes the `string|non-empty-list` a process executor takes as its command. fn exec_command_arg( method: &str, @@ -1626,6 +1686,28 @@ impl FromPluginArg for std::rc::Rc> { } } +/// Resolves a config argument back to the Rust-side entity its proxy stub stands for. +impl FromPluginArg for std::rc::Rc> { + fn from_arg( + method: &str, + position: usize, + value: Option<&PluginValue>, + ) -> Result { + match value { + Some(PluginValue::RustHandle(handle)) => { + match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) { + Some(RustEntity::Config(config)) => Ok(config), + _ => Err(runtime_throw(format!( + "{method} expects a Config handle, got Rust handle {}", + handle.rhandle + ))), + } + } + other => Err(arg_throw(method, position, "a Config", other)), + } + } +} + /// Resolves a process executor argument back to the Rust-side entity its proxy stub stands for. impl FromPluginArg for std::rc::Rc> { fn from_arg( diff --git a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs new file mode 100644 index 00000000..ecb0b34c --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs @@ -0,0 +1,77 @@ +//! HttpDownloader E2E compatibility check: upstream Composer and Shirabe each install a fixture +//! project whose plugin builds its own `HttpDownloader` and writes what every call on it reports +//! to a trace file. Upstream has no test that drives a downloader from plugin code, so the whole +//! fixture is Shirabe-authored (`fixtures/e2e-http-downloader/`) and nothing has to be fetched; +//! the test skips only while the PHP runtime or the Composer checkout is missing. + +use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin}; +use crate::php_worker::{lock_php_worker, php_runtime_available}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-http-downloader") +} + +struct Run { + exit_code: i32, + trace: String, +} + +/// Runs `install` in a fresh copy of the fixture and returns the exit code with the plugin's trace. +fn install(program: &str, prefix_args: &[&str]) -> Run { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let project = work.path().join("project"); + let output = std::process::Command::new(program) + .args(prefix_args) + .arg("install") + .current_dir(&project) + .env("COMPOSER_HOME", work.path().join("home")) + .env("COMPOSER_CACHE_DIR", work.path().join("cache")) + .env("COMPOSER_NO_INTERACTION", "1") + .env("COLUMNS", "120") + .env("LINES", "30") + .output() + .unwrap(); + Run { + exit_code: output.status.code().unwrap_or(-1), + trace: std::fs::read_to_string(project.join("http-downloader-trace.txt")) + .unwrap_or_default(), + } +} + +#[test] +fn test_plugin_owned_http_downloader_matches_upstream_composer() { + if !php_runtime_available() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let upstream = install("php", &[composer_bin.as_str()]); + let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(0, upstream.exit_code, "upstream install must succeed"); + assert_eq!(upstream.exit_code, shirabe.exit_code); + assert_eq!(upstream.trace, shirabe.trace); + + // Pinned as well as compared, so a run where neither side wrote a trace cannot pass. The + // second options line is the evidence that both worlds merge into one value rather than each + // holding its own copy of the map. + assert_eq!( + "\ +event=post-update-cmd +class=\"Composer\\\\Util\\\\HttpDownloader\" instanceof=true +options header=[\"X-Probe: 1\"] +options merged=[\"X-Probe: 2\"] +isCurlEnabled=true +hints other=null transport=null +outputWarnings=ok +", + upstream.trace + ); +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json new file mode 100644 index 00000000..08eec6ce --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json @@ -0,0 +1,17 @@ +{ + "name": "shirabe-test/http-downloader-probe", + "version": "1.0.0", + "type": "composer-plugin", + "description": "Fixture plugin driving an HttpDownloader it constructs itself.", + "autoload": { + "psr-4": { + "ShirabeTest\\HttpDownloader\\": "src/" + } + }, + "require": { + "composer-plugin-api": "^2.0" + }, + "extra": { + "class": "ShirabeTest\\HttpDownloader\\Plugin" + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php new file mode 100644 index 00000000..504c2108 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php @@ -0,0 +1,94 @@ +io = $io; + } + + public function deactivate(Composer $composer, IOInterface $io): void + { + } + + public function uninstall(Composer $composer, IOInterface $io): void + { + } + + public static function getSubscribedEvents() + { + // Whether an install resolves or replays a lock file decides which of the two fires, so + // both are subscribed and the trace records the one that ran. + return [ + ScriptEvents::POST_INSTALL_CMD => 'onPostCommand', + ScriptEvents::POST_UPDATE_CMD => 'onPostCommand', + ]; + } + + public function onPostCommand(Event $event): void + { + $downloader = new HttpDownloader($this->io, $event->getComposer()->getConfig()); + $lines = ['event=' . $event->getName()]; + + $lines[] = 'class=' . json_encode(\get_class($downloader)) + . ' instanceof=' . json_encode($downloader instanceof HttpDownloader); + + // Only the key the plugin set is compared: the rest of the map is the TLS defaults, whose + // CA paths depend on the machine rather than on the implementation. + $downloader->setOptions(['http' => ['header' => ['X-Probe: 1']]]); + $options = $downloader->getOptions(); + $lines[] = 'options header=' . json_encode($options['http']['header'] ?? null); + $downloader->setOptions(['http' => ['header' => ['X-Probe: 2']]]); + $lines[] = 'options merged=' . json_encode($downloader->getOptions()['http']['header'] ?? null); + + $lines[] = 'isCurlEnabled=' . json_encode(HttpDownloader::isCurlEnabled()); + + // getExceptionHints() only inspects the exception it is handed; both arguments below stay + // clear of the branch that probes connectivity. + $lines[] = 'hints other=' . json_encode(HttpDownloader::getExceptionHints(new \RuntimeException('x'))) + . ' transport=' . json_encode(HttpDownloader::getExceptionHints(new TransportException('plain', 400))); + + // The version constraint cannot match, so this reaches Composer::getVersion() and the + // version parser without writing anything to the terminal. + $lines[] = 'outputWarnings=' . $this->describe(static function () use ($event): void { + HttpDownloader::outputWarnings( + $event->getIO(), + 'https://example.org/packages.json', + ['warning' => 'unreachable', 'warning-versions' => '^0.0.1'] + ); + }); + + file_put_contents('http-downloader-trace.txt', implode("\n", $lines) . "\n"); + } + + private function describe(callable $call): string + { + try { + $call(); + + return 'ok'; + } catch (\Throwable $e) { + return \get_class($e) . ': ' . $e->getMessage(); + } + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json new file mode 100644 index 00000000..42318a10 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json @@ -0,0 +1,24 @@ +{ + "name": "shirabe/e2e-http-downloader", + "description": "E2E fixture project: record what a plugin's own HttpDownloader reports.", + "repositories": [ + { + "type": "path", + "url": "../plugin", + "options": { + "symlink": false + } + }, + { + "packagist.org": false + } + ], + "require": { + "shirabe-test/http-downloader-probe": "1.0.0" + }, + "config": { + "allow-plugins": { + "shirabe-test/http-downloader-probe": true + } + } +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index 3ef3f647..c62499e5 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -9,6 +9,7 @@ mod alias_package_test; mod e2e_command_provider_test; mod e2e_exception_test; mod e2e_extension_installer_test; +mod e2e_http_downloader_test; mod e2e_installer_test; mod e2e_installers_test; mod e2e_normalize_test; diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md index 516cf226..6f52bd62 100644 --- a/docs/dev/plugin-class-classification.md +++ b/docs/dev/plugin-class-classification.md @@ -399,9 +399,20 @@ can reach the async path on an instance of its own, since `enableAsync()`, `wait()` and `countActiveJobs()` are all public (`@internal` is a docblock note). -`HttpDownloader` and `Loop` remain guarded, so `Composer::getLoop()` is still -an explicit error and the graph's own executor is not reachable yet. Serving -it needs only a `Loop` stub, since the executor's surface is already served. +`HttpDownloader` is a proxy stub on the same terms and for the same reasons: +its options, its TLS defaults and the authentication it collects into the +run's IO are state the two worlds have to share, and a plugin-`new`ed one +allocates a Rust-side entity rather than a second downloader the graph knows +nothing about. What it does not serve is the request surface itself — +`get()` and `copy()` have no wire representation for the +`Composer\Util\Http\Response` they return, and +`add()`/`addCopy()`/`wait()`/`enableAsync()`/`countActiveJobs()` resolve +promises with one. + +`Loop` remains guarded, so `Composer::getLoop()` is still an explicit error +and neither the graph's own executor nor its downloader is reachable through +it. Serving it needs only a `Loop` stub, since both surfaces it hands out are +already served. ### Dual instantiation @@ -423,8 +434,9 @@ the entity and answers with its handle, and the plugin-`new`ed object is then the same entity the graph sees. It is filled in per class, driven by the explicit errors real plugins hit — today `Package`, `CompletePackage`, the three alias packages, the five solver operations, -`Composer\Util\Filesystem` (with or without its `ProcessExecutor` argument) -and `Composer\Util\ProcessExecutor` can be built this way. Every other +`Composer\Util\Filesystem` (with or without its `ProcessExecutor` argument), +`Composer\Util\ProcessExecutor` and `Composer\Util\HttpDownloader` can be +built this way. Every other proxied class answers with an explicit error naming it. `JsonFile` and `ArrayLoader` are still undecided, so the classes above stay @@ -438,8 +450,8 @@ service can now be built. `Composer\Package\Archiver\ArchivableFilesFinder` is `unsupported` for `new Filesystem` alone, and the VCS/auth belt is `unsupported` for `new ProcessExecutor` alone; neither forks any state any more. Promoting them means feeding the per-class construction stories back -into the rule — and, for the belt, deciding `HttpDownloader` too, since -those classes take one and no route to it exists yet. +into the rule; the `HttpDownloader` those classes take as a constructor +argument is one a plugin can now build. ### Package and CompletePackage diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 2c9609ce..22faf744 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -35,3 +35,4 @@ Composer\DependencyResolver\Operation\MarkAliasInstalledOperation Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation Composer\Util\Filesystem Composer\Util\ProcessExecutor +Composer\Util\HttpDownloader -- cgit v1.3.1-4-g156e