aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src
AgeCommit message (Collapse)Author
2026-08-18perf(metadata-minifier): defer copying versions out of expandnsfisis
MetadataMinifier::expand now returns ExpandedVersions, which holds the minified input plus, for each expanded version, a table of references to where its fields live. A version is copied only when materialize() asks for it. ComposerRepository::load_async_packages runs its constraint and stability filters straight off that view through the new VersionFields trait, so the versions it rejects are never copied at all. Benchmarks are new under crates/shirabe/benches. load_packages against real packagist p2 metadata, before -> after: symfony/console (768 versions) 0 accepted 9.01 ms -> 4.40 ms -51% 50 accepted 10.70 ms -> 6.30 ms -41% 147 accepted 13.17 ms -> 9.62 ms -27% 329 accepted 18.58 ms -> 16.89 ms -9% 663 accepted 27.27 ms -> 27.89 ms +2% laravel/framework (1277 versions) 0 accepted 39.44 ms -> 11.22 ms -72% 81 accepted 44.76 ms -> 18.25 ms -59% 840 accepted 125.44 ms -> 120.12 ms -4% 1266 accepted 163.18 ms -> 182.01 ms +12% The crossover sits near 80% acceptance. Past it the view loses, because materialize rebuilds a map where the old code cloned one, and the minified input stays alive alongside the copies; the 1266-of-1277 case measured between +5% and +12% across runs. Loads with a real constraint sit far below the crossover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18fix(input): return a bool|string|string[]|null enum from get_optionnsfisis
`InputInterface::get_option` returned `PhpMixed`, so the negation branch of `Input::get_option` reproduced PHP's `return !$value;` as `!value.as_bool().unwrap_or(false)`, which inverts the result for a string value instead of leaving it `false`. It now returns `InputOptionValue`, whose `to_bool` is PHP's truthiness cast. The narrowing also removes the `Vec<PhpMixed>` element handling the commands carried for array options: `as_array` hands back `&[String]`, so the `filter_map(|v| v.as_string())` chains at eight call sites collapse. Its other accessors keep `PhpMixed`'s names and meanings (`is_null`, `as_bool`, `as_string`, `to_bool`), and `From<InputOptionValue> for PhpMixed` covers the callers that feed the value back into an `IndexMap<String, PhpMixed>` or a `PhpMixed` parameter. `Input` keeps its parsed options and `InputOption` its defaults as `PhpMixed`, so `Input::get_option` is where the narrowing happens and where a value outside the domain panics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(check-platform-reqs-command): model the JSON output as structsnsfisis
Replace the hand-built `IndexMap<String, PhpMixed>` rows behind `check-platform-reqs --format=json` with structs deriving `serde::Serialize`, so key order and the `null` values PHP keeps for a missing failed requirement or provider live in the type rather than being rebuilt at every insertion site. Test the provider against its raw value the way PHP's `$provider === ''` does, instead of against the `strip_tags` result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(diagnose-command): model check results as dedicated enumsnsfisis
The `check_*` methods returned `PhpMixed`, and `output_result` reproduced PHP's truthiness over it incorrectly: only `PhpMixed::Bool(false)` took the falsey path, so an empty string or an empty list printed a blank line instead of reporting `FAIL` and raising the exit code. `CheckResult` and `GithubRateLimit` make the shapes PHP returns explicit, and `check_platform` stores the detail of an error or warning as `Option<String>` rather than `true`-or-string. `check_http` and `check_composer_repo` also built the exception label with `std::any::type_name_of_val`, printing the Rust type path where PHP prints `get_class($e)`; they now go through `AnyThrowable::php_class_name` like the rest of the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(show-command): model the JSON output as dedicated structsnsfisis
Replace the `IndexMap<String, PhpMixed>` maps behind `show`'s three output shapes with structs deriving `serde::Serialize`, so field order, key omission and PHP's rule that an empty array encodes as `[]` live in the type instead of being rebuilt at every insertion site. Typing the fields after PHP's own signatures corrects three divergences: `description`, `source.url`/`source.reference` and `dist.url`/`dist.reference` now encode as `null` rather than `""` where the getter returns null, and the package list follows PHP's `??` chain for the homepage fallback instead of also skipping empty strings. Record with TODO(port) that `get_links_for_type` matches composer.json key names while `show` passes `Link::TYPE_*`, so its link sections never print. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(zip): return a ZipEntryStat struct from stat_indexnsfisis
The stat array was modelled as IndexMap<String, PhpMixed>, forcing callers through untyped lookups with an unwrap_or(0) fallback PHP has no counterpart for. Only `size` and `comp_size` are kept: statIndex has a single call site in Composer and it reads nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix: propagate ported exceptions instead of flattening themnsfisis
`ProcessExecutor::execute_args` existed only to turn `execute`'s `anyhow::Result` into an exit code of 1, so every one of its ~87 call sites silently took the "command failed" branch on an error PHP would have thrown. It is gone; callers use `execute` and propagate with `?`. Where the enclosing function had no `Result` to propagate into, its signature grew one, up to and including `Git::get_version`, `Svn::binary_version`, `GitHub`/`GitLab`/`Bitbucket::authorize_oauth`, `InitCommand::get_git_config` and `DiagnoseCommand::check_git`. The VCS drivers had the same problem in the other direction: their `get_contents` returned `Result<Response, Box<TransportException>>`, a type too narrow for the PHP method, which lets any Throwable out of the `catch (TransportException $e)` block. Every non-transport error was therefore rewritten into a `TransportException` with code 0, which the callers switch on. They now return `anyhow::Result<Result<Response, Box<TransportException>>>`: the outer `Result` carries what PHP does not catch, the inner one the exception the drivers handle. That signature also restores `GitLabDriver::getContents`: the 400/401 `TransportException`s it raises to force authentication are thrown inside its own `try` block and handled by its own `catch`, but the port returned them straight to the caller, so the authentication flow behind them never ran. `impl_php_exception!` gains `From<Box<$ty>> for anyhow::Error` so a caught exception can be re-propagated with `?`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18fix(process-executor): model commands as a CommandLine enumnsfisis
Commands were carried as `PhpMixed`, whose `String`/`List` variants do not tell a shell command line apart from an argv list at the type level. `Perforce::execute_command` and `Git::run_command` therefore funnelled string commands through `execute_args`, spawning `p4 set` or `git command` as a single argument instead of running it through a shell as PHP does; their tests were written against that shape. Introduce `CommandLine::{Shell, Args}` and use it for every `ProcessExecutor` entry point, the mock expectation queue and the `Git::run_command` callables. The unreachable "Invalid command type" branches disappear with it, and the affected tests go back to the string expectations the PHP suite uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18refactor(function-exists): drop checks for always-present capabilitiesnsfisis
function_exists() returns false in PHP when a function is blocked by disable_functions, when its extension is not compiled in, or when the PHP version predates it. None of those apply to a native binary.
2026-08-18fix(cache): allow underscores in metadata cache keysnsfisis
Cache::read()/write() rewrite every character outside the allowlist to "-", so a package name containing "_" was stored and looked up under a file name Composer never writes: the repository metadata cache was never hit for those packages, and "vendor/foo_bar" collided with "vendor/foo-bar". With no cached copy there is no last-modified date to send either, so a degraded repository resolved such packages as not found instead of serving the cached metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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): return the preg_* $matches instead of filling an out-paramnsfisis
PHP fills `$matches` through a by-ref parameter, which the port mirrored with a `&mut` out-param plus a bool or count return. Every caller then had to declare an empty binding one line ahead of the call, and nothing in the type said the binding is only meaningful when the call succeeded. Return the matches instead: preg_match() and preg_match2() hand back an Option, and the three preg_match_all* functions hand back the collection they used to fill. The occurrence count the two map-shaped preg_match_all* functions used to return is the length of any one of the map's columns, so it is not lost -- Preg::match_all() and friends derive it via occurrence_count(). preg_replace2() keeps its `count: Option<&mut usize>`: that one is not derivable from the replaced string, and callers that do not want it pay nothing for passing None. 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): split the PREG_UNMATCHED_AS_NULL preg_* by flagnsfisis
preg_match2() and preg_match_all_offset_capture() each become a pair over a shared private impl, and their flags arguments are gone: no caller passed anything but 0 or PREG_UNMATCHED_AS_NULL. Preg::match5()/is_match5() lose their own flags argument for the same reason -- both call sites passed 0, and the value had nowhere left to go -- so they are renumbered to match4()/is_match4(). PREG_UNMATCHED_AS_NULL itself is now unreferenced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17fix(platform): expand the %VAR% form in expand_path()nsfisis
The alternation that stands in for the original conditional subpattern reports the branch it did not take as an empty string, so `dvar` was always present and won over `pvar`, leaving %VAR% unexpanded. `\w+` cannot capture an empty string, so an empty branch means it did not participate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17fix(no-proxy-pattern): drop empty entries when splitting NO_PROXYnsfisis
The PHP splits with PREG_SPLIT_NO_EMPTY, but the port dropped that flag, so a leading separator in NO_PROXY kept an empty first entry. That made "empty($hostNames) || '*' === $hostNames[0]" false for values such as " *", turning "bypass the proxy for every host" into "use the proxy for every host". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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-16fix(autoload): expand the dirsep placeholder in target-dir patternsnsfisis
AutoloadGenerator.php builds the target-dir pattern by quoting the path with a <dirsep> marker in place of the separators, then replacing the quoted marker with [\\/]. The port kept that shape, but preg_quote() here deliberately leaves < and > alone so the regex crate does not read \< as a word boundary, so the replacement never matched and the pattern came out as the literal {^Main<dirsep>Foo<dirsep>}. A root package's target-dir was therefore never stripped from files, classmap or exclude-from-classmap, and dumping one failed outright. Split on the separators and quote each segment instead, so the marker never passes through preg_quote() at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16fix(php-shim): fail RecursiveIteratorFileInfo::get_size on a failed statnsfisis
This is the type Filesystem::directory_size() actually iterates, and it swallowed a failed stat as 0 the same way. PHP's iterator yields \SplFileInfo there, so raise the same \RuntimeException it would. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(env): route Shirabe's own env reads through the shimnsfisis
These sites have no PHP counterpart to mirror, so they read std::env directly. Going through the shim's getenv() keeps every environment read in one place and lets a lint forbid the direct form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16fix(proxy-manager): read proxy settings from $_SERVERnsfisis
ProxyManager::initProxyData() reads $_SERVER[$name], a startup snapshot, while the port read the live process environment. A putenv() issued after startup changed the proxy the port picked but not the one PHP would pick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16fix(factory): detect XDG from $_SERVERnsfisis
Factory::useXdg() enumerates array_keys($_SERVER). The port enumerated the live process environment instead, so a COMPOSER_HOME resolved against keys the PHP side never sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16fix(php-shim): accept negative offsets in substr_replacensfisis
The signature took usize, so PHP's negative $start and $length, which count from the end of the string, could not be expressed. Take i64 and an optional length, and apply PHP's clamping rules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16feat(plugin): proxy Composer\Util\Filesystem into the plugin workernsfisis
The class was shadowed by a guard, so a plugin doing `new Filesystem()` got an explicit error. It is classified rust-proxy and plugin-constructible and the Rust port is complete, so listing it as a stub target and answering its public surface from the entity is all it takes. The constructor rejects a caller-supplied ProcessExecutor: that class has no proxy stub, so the argument could only be a second instance the Rust side never sees. findShortestPath re-checks its arguments at the boundary because the port panics where PHP throws, and a panic would take the process down instead of reaching the plugin's catch block. phpstan/extension-installer matches upstream Composer byte for byte again.
2026-08-16feat(plugin): guard Rust-owned classes the worker has no proxy fornsfisis
The worker's autoloader fell through to the real Composer source for every Rust-owned FQCN without a proxy stub, so plugin code doing `new Filesystem()` or subclassing `LibraryInstaller` silently ran on a second instance the Rust side never sees. An unimplemented part of the plugin API has to fail with an explicit error naming it, not quietly work on a disconnected copy. The stub generator now emits a guard class for each of those FQCNs: the real declaration, hierarchy and constants, with every constructor and method raising an explicit error. References satisfied by the declaration alone (`instanceof`, `X::class`, `Link::TYPE_REQUIRE`) keep working. Two FQCNs stay resolvable to the real class, each listed with the worker-side mechanism that makes a natively constructed instance correct. The error had nowhere to go: `Installer::run` dropped the `Result` of both `dispatch_script` calls, so an exception from a listener ended in exit 0. Both propagate now, the way the exception does upstream. Three real-plugin E2E comparisons stop at a guard and are ignored, each naming the class it needs. 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-16fix(config): stringify cache-files-maxsize like PHP's (string) castnsfisis
Config::get() resolves cache-files-maxsize by matching the stored value against a size regex. The port read that value with as_string(), which yields None for anything that is not a string, where PHP casts with (string). create-project feeds Config::all() back through Config::merge(), so the second read finds the byte count the first read produced rather than the original "300MiB". as_string() then yields an empty string, the regex fails, and the resulting RuntimeException is swallowed by Config::get(), which maps Err to null. FileDownloader turns that null into a zero max size via .as_int().unwrap_or(0), and Cache::gc() deletes every file in the download cache because the total always exceeds zero. Apply the same cast on the path branch, the other site where Config.php writes (string). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(php-shim): give fstat and lstat a typed FileStat resultnsfisis
fstat and lstat now return Option<FileStat> instead of a PhpMixed array, so Platform::is_tty and Filesystem::is_junction read `mode` as a field. The array carried each of the 13 values twice — once under its numeric index and once under its name — which no caller relied on, and building it spelled the field list out four times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(php-shim): give posix_getpwuid a typed PasswdEntry resultnsfisis
posix_getpwuid now returns Option<PasswdEntry> instead of a PhpMixed array, so Platform reads the field it wants rather than digging through the map. posix_getuid and posix_geteuid return u32, matching the uid PasswdEntry is looked up by. 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(io): take &str in ConsoleIO write and sanitizensfisis
doWrite, doOverwrite and sanitize accept PHP's string|list<string>, which this port modelled as PhpMixed. Every call site inside ConsoleIO passes a single string, so take &str and return String instead, dropping the (array) casts and the to_string_list helper. Auditor is the only caller that passed a list: it builds table rows, whose cells must stay separate, so it now sanitizes each cell. select() likewise sanitizes each choice while projecting them into the keyed form. 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-15refactor(php-shim): drop the unused opendir directory handlensfisis
PhpDirHandle recorded the opened path and nothing else, and its only caller asked just whether the open had succeeded. A resource type that has to gain readdir and closedir before it means anything is worse than the std::fs::read_dir call it wraps, so Filesystem::isReadable now makes that call directly. 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>