aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src/command
AgeCommit message (Collapse)Author
2026-08-18refactor(preg): make preg_match_all yield matches per occurrencensfisis
PHP's PREG_PATTERN_ORDER is column-oriented, but 7 of the 10 call sites read it row-wise, rebuilding each occurrence by indexing every column at the same offset. Return an iterator of PregMatches instead, which is also what the set-order and offset-capture variants were carrying, so the three functions collapse into one and PregMatchesAll, PregMatchesAllWithOffsets, CaptureKey and preg_match_map! all go away. The offset-capture call sites are served by the new PregMatches get_offset/name_offset accessors. The search stays eager: regex::Captures borrows only the subject, so the matches outlive the pattern resolved for the call, and PHP's preg_match_all is eager too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): add preg_is_match for existence-only call sitesnsfisis
The capture groups were discarded at 162 of the preg_match call sites, which only tested the Option. They now call preg_is_match, which lets the regex engine skip capture tracking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): drop the offset argument from preg_matchnsfisis
Every call site but one passed offset 0. The remaining one, the UTF-8 chunking loop in Application, slices the subject instead: its pattern has no anchor or lookaround, so matching a suffix is equivalent to starting the search at that offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): drop the Vec-returning preg_match_allnsfisis
The two preg_match_all variants took the same arguments and differed only in what they returned: a Vec of columns, or the named-and-numbered PregMatchesAll. The latter is the one all but two call sites already used, so preg_match_all2 takes over the plain PHP name and the Vec variant goes away. Its remaining readers only ever wanted group 0's column, which they now take through CaptureKey::ByIndex(0); in the formatter this replaces the array_shift that popped that column off the PREG_PATTERN_ORDER array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(pcre): inline Preg into its call sites and drop the cratensfisis
Preg had shed everything it owned: after the last few rounds its methods were one-line forwards to the shim's preg_*(), differing only in a default argument or a wrapper the caller unwrapped anyway. The 460 call sites now name the shim function, and shirabe-pcre is gone from the workspace along with its LICENSE entry. The forwards expand as they read: isMatch becomes preg_match2(.., 0).is_some() (is_none() where PHP negates it), isMatch3 and match3 drop the .is_some(), matchAll counts through preg_match_all2(..).occurrence_count(), and replace4/replace5 spell out the limit and count arguments preg_replace2 takes. Callbacks are the one place the shapes differ: preg_replace_callback carries an error out of the callback, so the fourteen infallible closures wrap their result in Ok() and expect() it back. Config::process() is the fifteenth, and it drops the `error` cell it captured to smuggle a failure past a closure that could only return a String. The `?` in the closure now carries it, which is what the PHP does -- a throw from the callback leaves preg_replace_callback at the failing match rather than running the remaining replacements and reporting the last error. The module doc that explained why composer/pcre's exceptions and *StrictGroups() variants have no counterpart moves to the shim's preg module, where the functions it describes live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): split PregMatches reads into get() and name()nsfisis
PregMatches keyed both forms of a capture group through CaptureKey, so every read built one: a usize wrapped in an enum, or worse, a String allocated to name a group that regex::Captures can look up from a &str. It now mirrors regex::Captures instead -- get() takes the group number, name() the group name -- and the enum drops out of the type entirely. That is 285 call sites across 59 files, and the named ones carry most of the win: `matches.get(&CaptureKey::ByName("host".to_string()))` reads as `matches.name("host")`. ProcessExecutor loses a `user_key` binding that existed only to build the key once. CaptureKey stays as the key type of PregMatchesAll and PregMatchesAllWithOffsets, where numbered and named entries share one IndexMap and a key type is the point. Five files still name it. Also retargets the two preg_match_all comments that described the occurrence count through `matches[&CaptureKey::ByIndex(0)].len()`, an Index impl these types no longer carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(pcre): drop the two bespoke isMatch variantsnsfisis
is_match_named and is_match_with_indexed_captures reshaped a match into a name-keyed map or a number-positioned vec, each allocating a String per group up front for callers that then read one or two of them. Every one of the eleven call sites ports a plain Preg::isMatch in PHP, so they now call is_match3 and reach for the group they want through get(&CaptureKey::ByIndex(N)) / get(&CaptureKey::ByName(..)), the same way the rest of the tree already reads a match. Falling out of that: PregNamedGroups existed only to type the first variant; PregMatches::iter() only to build both; and PregMatches::pattern only to give iter() the capture names. PregMatches is now a plain wrapper over regex::Captures, so preg_replace_callback no longer clones the resolved pattern for every match, and preg_match_map! is internal to the shim again. SvnDriver::get_file_content and get_change_date recover the flat `isMatch(..) && $match[2] !== null` condition the PHP has, which the vec shape had forced into a nested if. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(pcre): hand back the match instead of copying it outnsfisis
Preg::match4 and Preg::replace_callback gave callers a PregMatchedGroups: an IndexMap rebuilt from the match with an owned String per group, plus a second String for a named group's name key. That is the copy PregMatches shed when it started wrapping regex::Captures, reinstated one layer up -- and nearly every regex call in the tree goes through Preg rather than the shim's preg_* directly, so almost nothing saw the borrow. PregMatchedGroups existed only to drop the null (unmatched) groups the old PregMatches held as Option<String> values. PregMatches::get reports a non-participating group as None on its own, so the two read alike and the type collapses into it. Call sites still reach groups through get(&CaptureKey::ByIndex(N)); what changes is that the value arrives as a &str borrowed from the subject, which the signatures now carry as a lifetime. Three places needed the borrow reckoned with rather than a mechanical rewrite: PhpFileCleaner::clean and Problem::get_messages read their groups out before mutating what the match borrows, and Git::get_authentication_failure names the lifetime of its url argument, which the result borrows instead of self. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(preg): back PregMatches with regex::Capturesnsfisis
PregMatches was an IndexMap of owned Strings copied out of the match, so every preg_match2/preg_replace_callback call allocated a String per capture group (twice over for a named group) whether or not the caller read it. It now wraps the regex::Captures itself, held alongside the pattern it came from so groups stay reachable by both their named and their numbered form, and hands out &str borrowed from the subject. The subject's lifetime becomes a parameter of the type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(pcre): return the Preg $matches instead of filling an out-paramnsfisis
`Composer\Pcre\Preg` fills `$matches` through a by-ref parameter, and the port mirrored that with a `&mut` (or `Option<&mut>`) out-param plus a bool or count return. Callers had to declare an empty map one line ahead of the call, and the type never said the map is only meaningful when the call matched. Return the matches instead: - match3/match4/is_match3/is_match4 -> Option<PregMatchedGroups> - is_match_named -> Option<PregNamedGroups> - match_all2/is_match_all -> PregMatchesAll - is_match_all_with_offsets3 -> PregMatchesAllWithOffsets Nothing is lost: the bool is `Option::is_some()`, and the occurrence count is the length of any one column of a PREG_PATTERN_ORDER map, now spelled `PregMatchesAll::occurrence_count()`. is_match() still answers the bool question directly for callers that want no groups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(preg): report unmatched groups as null throughoutnsfisis
The shim carried two reporting modes for the preg_* $matches maps: PHP's default (trailing unmatched groups dropped, interior ones ""), and the PREG_UNMATCHED_AS_NULL form, picked by calling a *_unmatched_as_null() variant. The regex crate hands out Option<Match>, which maps onto the null form directly, and no caller distinguished a dropped group from a null one -- preg_match() and preg_match_all2() already reported nulls unconditionally. Keep only the null form; the shim API no longer mirrors PHP's flag set, which is intended. Preg::is_match_with_indexed_captures() modelled PHP's "unset" as a truncated Vec<String>, and now returns Vec<Option<String>>. That is what Composer actually does: Preg::isMatch() always sets PREG_UNMATCHED_AS_NULL, and its callers test groups with `!== null`. preg_match_all(), preg_match_all_set_order() and preg_split_delim_capture() still hand back Vec<String> and keep the "" form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(preg): wrap the preg_* $matches maps in newtypesnsfisis
The five IndexMap shapes that the preg_* functions and Preg fill in are now distinct types generated by preg_match_map!, so a matches map no longer interchanges with any other map of the same key and value type. Index<usize> is kept alongside Index<&Q> because call sites such as config_command and event_dispatcher reach for a group by its position in the map rather than by its capture key.
2026-08-17refactor(preg): replace Preg::split*() with shim preg_split*()nsfisis
preg_split2()'s limit was always -1 and its flags were always either 0 or PREG_SPLIT_DELIM_CAPTURE alone, so both arguments are gone: the shim now exposes preg_split() and preg_split_delim_capture() over a shared preg_split_impl(). That leaves Preg::split()/split4() as bare pass-throughs, so callers use the shim functions directly and the wrappers are dropped along with the now-unreferenced PREG_SPLIT_* constants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(preg): drop Option wrapper from matches arg of match_all*()nsfisis
Every caller of Preg::match_all3()/is_match_all3() passed Some(&mut _), so the argument is now a plain &mut. Preg::match_all() keeps the no-captures form with a local throwaway map, and the arity suffixes are renumbered accordingly (match_all2(), is_match_all()). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17fix(pcre): preserve unmatched groups in Preg::match_all*()nsfisis
PHP's Preg::matchAll() and matchAllWithOffsets() always set PREG_UNMATCHED_AS_NULL, so a non-participating group is `null` and its offset is -1. The Rust wrappers collapsed those to "" and 0, so callers could not tell a group that did not participate from one that matched an empty string at offset 0, and the offset value matched no PHP mode at all. Hand the shim's representation through unchanged and let each caller mirror what the PHP original does with it: `isset()` and `(string)` casts stay lenient, while `assert(is_string(...))` and the *StrictGroups() variants become `expect()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(preg): make preg_grep() return an iteratornsfisis
Callers had to build a temporary Vec<&str> at every call site to satisfy the &[&str] parameter. Taking IntoIterator and yielding the matched items lets them pass owned or borrowed strings directly. The flags variant and PREG_GREP_INVERT go away with it: no caller passes flags, and Composer's Preg::grep() has no such parameter either.
2026-08-16refactor(preg): import preg_*() instead of qualifying themnsfisis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(php-shim): split json_decode into assoc and obj variantsnsfisis
The assoc flag was always a literal at every call site, so the boolean carried no information the function name could not. json_decode_assoc and json_decode_obj make the resulting PhpMixed shape visible at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(php-shim): give parse_url a typed UrlComponents resultnsfisis
parse_url now returns Option<UrlComponents> instead of a PhpMixed array, and the component-selecting overload with the PHP_URL_* constants is gone: callers read the field they want. Two call sites change behaviour as a result, both towards PHP: * CurlDownloader::handle_redirect tested scheme and host with is_null(), so an unparsable Location header (PhpMixed::Bool(false)) counted as an absolute URL. PHP's truthiness test sends it to the relative-path branch. * Url::get_origin appended a literal port 0, which PHP treats as falsy and leaves off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(style): type SymfonyStyle messages as strings and cellsnsfisis
SymfonyStyle modelled PHP's string|array message parameters, its array of listing elements and its table headers/rows as PhpMixed, then normalised and stringified them at every entry point. Take &[String] for the message and listing parameters, Vec<Cell>/Vec<Row> for table (matching the horizontal_table signature) and Vec<String> for the choice options, so the is_array/is_iterable normalisation and the php_string helper both go away. PhpMixed stays where PHP is genuinely mixed: the question answers returned by ask/ask_hidden/choice, their validators, choice's string|int|null default, and the progress_iterate elements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor: narrow pub(crate) items to privatensfisis
Porting mapped every PHP `protected` member onto `pub(crate)`, which is wider than nearly all of them need. Each item demoted here is reached only from the module that defines it, so the crate-wide visibility conveyed nothing. Every `pub(crate)` that survives has at least one reader in another module of the same crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16feat(php-shim): render human-facing timestamps in the local timezonensfisis
Split date() into date_utc() and date_local(), the latter resolving the system's local timezone through the tzfile crate ($TZ, then /etc/localtime, falling back to UTC when neither is readable). The timestamps Composer renders for humans -- the GitHub OAuth token note, the GitHub API rate limit reset time, the Perforce client spec fields and the "today" check of the show command -- now go through date_local(). PHP resolves its default timezone from the date.timezone ini setting, which Shirabe does not read, so date_default_timezone_get/set have no input left to model and are dropped from the shim and its callers. The resulting difference is recorded in docs/known-incompatibilities.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15feat(cli): report Shirabe's own identity instead of Composer'snsfisis
The binary called itself Composer everywhere: the application name, the logo, --version, about, and every warning that talks about the running program. Prompts to file a bug also pointed at Composer's issue tracker. Add SHIRABE_VERSION and SHIRABE_RELEASE_DATE next to the Composer version constants and report those, naming the Composer version this port tracks alongside them. Composer::VERSION and getVersion() are untouched, so the composer platform package, composer-runtime-api and the HTTP User-Agent keep the value plugins and package repositories expect. build.rs stamps the release date with the UTC date of the HEAD commit, the way Composer's Compiler fills in @release_date@ when building the phar. It now also fails the build when git cannot be read, instead of letting COMPOSER_DEV_WARNING_TIME fall back to the tagged-release value and suppress the outdated-build warning forever. Messages about the Composer ecosystem keep their wording. Two of them are pinned by upstream installer fixtures (Rule's "cannot be modified by Composer" and SolverProblemsException's "you can run Composer with") and stay as they are so those fixtures can keep being used verbatim. The e2e list comparison against upstream Composer now skips the banner, which cannot match by design, and compares everything below it as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15feat(diagnose): audit the Composer runtime the executable carriesnsfisis
checkComposerAudit reported success instead of auditing anything, because the binary ships no vendor/composer/installed.json on disk. It reads the one in the embedded Composer PHP runtime now, and Composer's warning for a missing installed.json is back. Only that file leaves the bundle, into a temporary directory that goes away with the handle; the runtime is unpacked whole only for a worker that cannot read the bundle in place. Phar::extractTo's $files argument selects it, which the shim ignored so far. SHIRABE_COMPOSER_PHP_DIR moves into composer_runtime, so the worker and a reader on the Rust side resolve the runtime through the same branch. DiagnoseCommandTest::testCmdSuccess is ignored: packagist has advisories against composer/composer 2.9.7, the version Composer::VERSION reports, so diagnose exits 1 where the test expects 0. Upstream Composer 2.9.7 reports the same advisories. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12fix(command): port ScriptAliasCommand's ignoreValidationErrors() callnsfisis
The command's own definition covers only --dev/--no-dev and the args array, so any other application-level option reaching it (as when GlobalCommand re-parses `--no-interaction=1` out of ArrayInput's stringification) aborted the run with "does not accept a value". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12docs(todo): retag TODO markers by root causensfisis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11chore(php-shim): drop the substring predicate portsnsfisis
str_contains(), str_starts_with() and str_ends_with() were thin wrappers over the str methods of the same semantics. Call sites now use contains()/starts_with()/ends_with() directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11feat(signal): abort on SIGINT, SIGTERM and SIGHUP at checkpointsnsfisis
The SignalHandler port was a no-op stub, so all four of Composer's abort paths were dead code: nothing removed a half-created project, reverted composer.json, or cleaned up half-installed packages. Composer runs those handlers from pcntl callbacks, which a Rust signal handler cannot do -- it may touch nothing beyond atomics. SignalSubscription records the signal instead, and the abort runs from checkpoints on the normal call stack, where the clean-up can borrow the state it needs. That also resolves the closure-capture TODO(phase-c)s in RequireCommand and InstallationManager, and replaces exit_with_last_signal's exit(0) with the restore-and-re-raise Seld\Signal does. A subscription is live only inside the four abort regions, so elsewhere the signals keep their default disposition and kill the process at once. It is installed without SA_RESTART so a signal interrupts an interactive prompt rather than resuming the read. A signal reaches only the innermost subscription, reproducing SignalHandler's single-stack dispatch. Drop SignalRegistry, SignalableCommandInterface and the Application wiring for them: nothing in Composer reaches that path, and SignalHandler discards whatever they register. Signal handling from plugins and scripts is undefined behavior; see docs/dev/signals.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10refactor(symfony): hide leaf modules behind their parent re-exportsnsfisis
Every parent module in the symfony-* crates already re-exported its leaf modules with `pub use`, so each item was reachable by two paths. Make the leaf modules private and route all callers through the single re-exported path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10feat(xdebug): switch Xdebug off in the PHP workernsfisis
Composer restarts itself with Xdebug unloaded because Xdebug makes PHP several times slower. Xdebug is never loaded into this process, so what needs dealing with is the PHP worker: it is spawned with `-d xdebug.mode=off` and `XDEBUG_MODE=off` (Xdebug reads the environment variable first and lets it override every ini setting), which makes its module init return before it installs any executor, compile, error or opcode hook. Rewriting the ini files and re-executing, the way xdebug-handler does, would additionally cover Xdebug 2, which hooks unconditionally and has no equivalent setting. That is not worth its machinery here: Xdebug 2 caps out at PHP 7.4, while every PHP version Composer supports can run Xdebug 3. What remains of XdebugHandler is small enough to live beside the worker it governs, so its crate is gone and its callers inline it. isXdebugActive answers false without asking PHP whenever the worker is switched off, so a command that needs no PHP does not spawn one just for the Xdebug warning; diagnose reports what the worker measures instead, which still surfaces an Xdebug that ignores the setting. PlatformRepository has no unloaded extension to restore, since switching the mode off leaves it loaded. COMPOSER_ORIGINAL_INIS is neither written nor read: it exists so a restarted process can name the ini files it replaced, and IniHelper can report the worker's own. IniHelperTest injects through that variable, so none of its cases are ported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(xdebug-handler): extract composer/xdebug_handler into the ↵nsfisis
shirabe-xdebug-handler crate Move `Composer\XdebugHandler` out of shirabe-external-packages and into its own crate, so the path is `shirabe_xdebug_handler::XdebugHandler` instead of `shirabe_external_packages::composer::xdebug_handler::XdebugHandler`. Standing alone, the stub no longer sits in a crate that shirabe-php-rpc depends on, so drop the dependency-cycle rationale from the comments that explain why callers reach for shirabe_php_rpc directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(seld-signal): extract seld/signal into the shirabe-seld-signal cratensfisis
Move `Seld\Signal` out of shirabe-external-packages and into its own crate, so the path is `shirabe_seld_signal::SignalHandler` instead of `shirabe_external_packages::seld::signal::SignalHandler`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(symfony-console): extract symfony/console into the ↵nsfisis
shirabe-symfony-console crate Move `Symfony\Component\Console` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_console::application::Application` instead of `shirabe_external_packages::symfony::console::application::Application`. The `delegate_to_inner!` and `delegate_command_trait_impls_to_inner!` macros move with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(symfony-finder): extract symfony/finder into the ↵nsfisis
shirabe-symfony-finder crate Move `Symfony\Component\Finder` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_finder::Finder` instead of `shirabe_external_packages::symfony::finder::Finder`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(symfony-process): extract symfony/process into the ↵nsfisis
shirabe-symfony-process crate Move `Symfony\Component\Process` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_process::Process` instead of `shirabe_external_packages::symfony::process::Process`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(pcre): extract composer/pcre into the shirabe-pcre cratensfisis
Move `Composer\Pcre` out of shirabe-external-packages and into its own crate, so the path is `shirabe_pcre::preg::Preg` instead of `shirabe_external_packages::composer::pcre::preg::Preg`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(path): build paths with Path::joinnsfisis
Concatenating a directory, MAIN_SEPARATOR and a component reimplements what Path::join already does. Joining directly also drops the separator from the intermediate values it used to be baked into, such as ClassLoader's $pathEnd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09refactor(php-shim): use std::path::MAIN_SEPARATOR over a shim constantnsfisis
The shim's DIRECTORY_SEPARATOR was hardcoded to "/", so every ported `'\\' === DIRECTORY_SEPARATOR` check compared against a constant that does not track the target platform. std::path::MAIN_SEPARATOR and MAIN_SEPARATOR_STR carry the same meaning as PHP's constant and resolve per platform, so the Windows branches are selected on Windows targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09fix(exception): carry PHP's $previous through the ported throw sitesnsfisis
Composer hands the exception it caught to the one it throws in its place, so `getPrevious()` reaches the cause and Application's renderer prints the whole chain. Every ported site dropped it, because the flat exception structs had nowhere to put one. `AnyThrowable::into_previous` turns the caught error into that argument, and the 13 sites now pass it. `getCode()` came along for the ride at the four sites that derive the new exception's code from the caught one (PharArchiver, ArrayLoader x2), and ComposerRepository's message now names the caught exception's class instead of the literal "Exception". GitHubDriver::attemptCloneFallback took the previous exception's message and appended it to its own, which no `\RuntimeException('Fallback to git driver disabled')` in Composer ever says; it now chains it instead. Git::syncMirror restores what PHP's `finally` does to an exception in flight: the `git remote set-url` that scrubs credentials back out of the URL runs in a `finally`, and when it fails PHP propagates *its* exception over the one already leaving, chaining the displaced one as previous. The port discarded the finally's result, so a failure to scrub the URL was reported as a successful mirror sync. `AnyThrowable::set_previous` models the engine-level chaining. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08feat(php-shim): give ported exceptions PHP's class hierarchynsfisis
Ported exceptions were flat structs reached with `downcast_ref`, so Composer's `catch (\RuntimeException $e)` only matched the exact leaf type and `get_class($e)` had nothing to report. Each exception now embeds an instance of the class it extends and travels inside an `AnyThrowable`; `Catch::catch`/`catch_mut` walk that chain, and `PhpClass::php_class_name` yields the PHP FQCN. Dropping the `std::error::Error` impls from the exception types leaves `AnyThrowable` as the only route into an `anyhow::Error`, so the walk cannot be bypassed. A `no_exception_downcast` linter catches the `downcast::<X>()` calls that would now silently answer `None`. Three sites change behavior as a result: the `TransportException` exit-code override reaches `MaxFileSizeExceededException`, the `catch (\LogicException)` in findSimilar() reaches its subclasses, and rendered exception titles carry the real class name rather than a guess. `get_class_err()` is no longer a `todo!()`, which re-enables FilesystemRepositoryTest::testCorruptedRepositoryFile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08refactor(semver): replace Constraint's OP_*/STR_OP_* with CmpOpnsfisis
PHP's version_compare takes its operator as a string, so the shim's port did too, and Constraint carried two families of operator constants plus translation tables to convert between the string form and its own int codes. Five copies of those tables had accumulated across Constraint, CompilingMatcher and the plugin value bridge. version_compare now takes a CmpOp, which makes an invalid operator unrepresentable and removes the tables' reason to exist. Constraint stores a CmpOp and keeps only the string parsing its constructor needs; getOperator, compile and CompilingMatcher::match speak CmpOp as well. PHP's OP_* numbering stays observable: a plugin reads the raw integer off the Constraint object over RPC, so get_operator_constant and its new inverse hold that 0..5 mapping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07fix(autoload-generator): survive listeners that re-enter dump()nsfisis
AutoloadGenerator::dump dispatches PRE_AUTOLOAD_DUMP / POST_AUTOLOAD_DUMP, and EventDispatcher::make_autoloader answers those by walking back into the Composer graph -- the local repository, the installation manager, and the generator itself. Installer::run held mutable borrows of all three across the dump call, so any plugin subscribing to either event panicked with "RefCell already borrowed" before its listener ran. dump() and build_package_map() now take the local repository, the installation manager and the locker as shared handles instead of &mut dyn, and the borrow is taken where it is used. The generator itself is re-entered, not just borrowed twice: PHP listeners reach it through Composer::getAutoloadGenerator() and call setDevMode() while dump() is running, and make_autoloader asks the same object for a package map. That is not expressible behind &mut self, so the mutable state moves into Cell/RefCell fields and the whole AutoloadGeneratorInterface takes &self. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07refactor(platform-repository): fetch the PHP runtime in one RPC callnsfisis
The RuntimeInterface seam asked the worker one question at a time: a round trip per loaded extension, per ReflectionExtension::info() output and per constant, so a single `show --platform` cost 70 to 100 of them. A `platform` dispatch entry now answers all of it as one PHP array, which shirabe-php-rpc decodes into a OnceLock-cached PlatformInfo, the way the diagnose command already works. Composer\Platform\Runtime therefore has no Rust counterpart any more. Its work belongs to the running interpreter, and invoke()/construct() could only be ported as a whitelist that panicked on anything unlisted; it is ported as PHP into the worker instead, and PlatformRepository reads the answers off PlatformInfo. Accessors panic on a name the payload does not carry, so the worker and its consumers cannot drift apart unnoticed. The tests describe the runtime as payload data where they used to mock the seam, with the datasets unchanged. The one loss is the call-count assertion of test_inet_pton_regression: the payload reports the result of `@inet_pton('::')` rather than answering a call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07refactor: merge split inherent impl blocks into one per typensfisis
Enable clippy::multiple_inherent_impl and fix the 21 sites it reports. Types whose inherent methods were spread across two or three impl blocks now keep them in a single block; only the impl headers move, no method bodies change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07chore: use shirabe_php_shim::impl_php_classnsfisis
2026-08-06refactor: replace literal-list in_array_strict with matches!nsfisis
Call sites whose haystack was an inline array of literals (or a local built solely to feed one) had to wrap both sides in PhpMixed just to compare, allocating a String per element on every call. matches! does the same test against the underlying &str/i64/Option directly, so the PhpMixed round trip and its .to_string()/.clone()/.iter().map() conversions are gone. Sites whose haystack is a runtime value or a named constant array are left on in_array_strict: inlining a named constant would duplicate its contents at the call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06refactor(php-shim): split in_array into strict and loose variantsnsfisis
2026-08-06refactor(spdx-licenses): return a dedicated type from getLicenseByIdentifiernsfisis
PHP hands back a positional array, so every caller had to reach into a PhpMixed list by index and fall back to a default when an element was missing or the wrong variant -- fallbacks that could never fire, since the list shape is fixed. LicenseMetadata names the four elements and makes the accesses total. This also drops the crate's last use of PhpMixed, and with it the dependency on shirabe-php-shim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06chore: drop @param/@return tags that only restate Rust typesnsfisis
The ported docblocks copied @param and @return straight from the PHP source. When such a tag carries nothing but a type and an argument name, the Rust signature already states it, so the line is noise. Tags whose text adds prose beyond the type are kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06feat(plugin): run plugin-provided installers through the RPC workernsfisis
A plugin can now hand an InstallerInterface implementation to InstallationManager::addInstaller across the wire, and a legacy composer-installer package is loaded as one; both are backed by a PhpInstallerProxy forwarding the whole installer contract to the entity in the PHP worker. An installer returning a real promise is an explicit error until promises can cross the boundary. InstallationManager takes installers as shared handles instead of boxes, so the object identity removeInstaller and PluginManager's registeredPlugins compare against survives registration, and holds them in a RefCell: Installer::run keeps a shared borrow of the manager for the whole run, and a plugin activated inside it registers its installer from there. The type cache keys on the installer itself, like upstream, so re-entrant registration cannot leave a stale index behind. InstallerInterface::supports is fallible for the same reason getCapabilities and getCommands are: it answers over RPC. Cloning a proxy stub clones the Rust-side entity and rebinds the copy to the fresh handle. Previously only the classes declaring __clone got a throwing body, and the rest let two stubs share (and twice release) one handle. Package entities answer with AnyPackage::dup, which already carries BasePackage::__clone and the RootAliasPackage override; the others are an explicit error. The package proxy covers the whole PackageInterface surface; only the link maps and the release date still lack a wire image for their value objects. PluginManager gains a test-only seam for the reported Plugin API version, and the three PluginInstallerTest cases that need it are ported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>