| Age | Commit message (Collapse) | Author |
|
|
|
Composer::getLoop() answers, so a plugin reaches the graph's own downloader
and executor through the route Composer's own docblocks point plugin authors
at, rather than through an instance of its own.
wait() drains the promises it is given and rethrows the first rejection once
the group is done, which is what React\Promise\all() hands PHP. abortJobs()
has nothing to cancel while every request settles before the call that
started it returns. A non-null $progress is an explicit error: a ProgressBar
is a symfony/console object each world runs its own implementation of, so
there is none to hand across.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
add() and addCopy() answer with a promise, and enableAsync(), wait() and
countActiveJobs() answer alongside them. The Rust future runs to completion
before the promise is handed over, so requests a plugin starts together run
one after another rather than overlapping; overlapping them needs a promise
representation that crosses the boundary unresolved.
Everything else the surface does is preserved. add() still refuses a
downloader outside a Loop, and it does so by throwing out of the call the way
PHP does, where a failed request instead arrives as a rejection the caller
handles — __shirabe_rejected_promise is the failure half of the resolved-
promise helper. wait() and countActiveJobs() answer for a downloader with no
outstanding job, which, once every request settles before its call returns,
it never has.
This is where HttpDownloader parts company with ProcessExecutor, whose async
surface stays an explicit error: executeAsync() resolves its promise with a
Symfony Process, whose state is the proc_open() resource of whichever process
called start(), where a request resolves its promise with a Response.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
A response is built for one request and the graph never retains it, so there
is no entity for a handle to point at. The child holds a real instance
instead, revived from the object record the wire carries, and collect() frees
the copy each world holds — which is what that method is for. The
value-object rule rejects the class only because collect() assigns to $this,
so the category comes from an overrides.list entry.
HttpDownloader::get() and copy() answer with one.
Two gaps stay: decodeJson() reaches Composer\Json\JsonFile, which a guard
shadows, and Composer answers a curl request with the CurlResponse subclass
where this port flattens the value into a Response.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>
|
|
A Rust-side failure reached plugin code as a RuntimeException whose message
carried the name of the call that failed, so `catch (TransportException $e)`
never matched and the status code the plugin branches on was gone.
The Throw frame now names the class the exception was thrown as and carries
the state that class declares beyond message and code.
\Shirabe\MaterializedThrowable rebuilds it in the child: `new $class($message,
$code)` for a class whose constructor has \Exception's shape, then the
properties by reflection. A class the child cannot build that way keeps the
RuntimeException shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Composer reaches this class two ways: the object graph hands one out through
Composer::getLoop()->getProcessExecutor(), and plugins write
`new ProcessExecutor($io)` freely. Both bind to a Rust-side entity, so the
timeout the run shares -- seeded from process-timeout and rewritten while the
run is in flight -- has one value instead of one per world, and the executor
can still be passed to the classes that take one (`new Filesystem($process)`).
Three things the stub generator was missing came with it:
- By-ref parameters. The call carries their positions and the answer carries
what each holds afterwards; a position the answer omits was never assigned
to, which is what PHP does with an untouched by-ref parameter.
ProcessExecutor::execute is the only one on a proxied class.
- Argument arity, reproduced where the real body reads func_num_args().
execute($cmd) forwards the child's output and execute($cmd, $out) captures
it, and nothing but the argument count separates the two.
- Static methods that cannot run in the worker. One that reads a static
property the Rust side owns, or that reaches a guarded class, forwards
through __shirabeCallStatic instead of being materialized. That also fixes
Filesystem::isLocalPath and getPlatformPath, whose materialized bodies
called the guarded Composer\Util\Platform.
The async surface stays an explicit error. executeAsync resolves its promise
with a Symfony Process, whose proc_open() resource and pipes belong to
whichever process called start(), so a Rust-side spawn has none to hand back;
running the real start() in the worker needs a promise representation that
crosses the boundary unresolved.
The fixture project drives the whole synchronous surface from plugin code and
compares the trace against upstream Composer byte for byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The write-back probe discarded the file_put_contents result and returned
a hard-coded false, so the comparison against false always held and the
error branch was taken for every path is_writable() rejected. Compare
the write result instead, matching the PHP original.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Silencer only lowers the PHP error_reporting() level and re-throws
whatever the guarded work raises. A region that never reaches the PHP
runtime has no level to lower and emits no diagnostic on failure, so
wrapping it is indistinguishable from running it unguarded.
The pair kept in Application::hint_common_errors brackets a
getComposer() call, which loads installed plugins and dispatches
PluginEvents::INIT.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
tokio's File returns from write_all once the blocking write is queued, so
the tail of the response body was still in flight when the download future
resolved. The caller then renamed the file, stat'd it and copied it into
the cache, so a package could land in the file cache truncated. The first
run still extracted the complete file and only a later run reading that
cache entry failed with "End-of-central-directory signature not found".
Flushing also surfaces the last write's error, which used to be dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Comments that pointed at design notes kept outside the repository are dead
ends for anyone reading only the tree, so what each of them explained now
lives in a tagged TODO at the site it applies to. Several of those sites
also stated something the implementation does not do, and the TODOs record
the actual gap instead: the two halves of the codec recognize handle
descriptors by different rules, the scripts Command path drops the exception
class and collects output in a BufferedOutput that cannot carry an
interactive command, find_shortest_path panics where PHP throws, and the
package dispatch hand-rolls the variant selection AnyPackage should own.
The classifier document likewise described rust-snapshot,
plugin-constructible and several of the open questions as designed rather
than as built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
file_get_contents() and file_get_contents_with_max_length() return
Vec<u8> instead of a from_utf8_lossy'd String.
Call sites whose consumer takes a &str still convert lossily and are
marked TODO(bytes).
file_get_contents_with_max_length() now reads at most the requested
number of bytes instead of the whole file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
`config --list` and `config <key>` rendered a value with `as_string()`,
which yields None for everything but a string, so every int-valued
setting printed as an empty string: `cache-ttl`, `cache-files-ttl` and
the resolved value of `cache-files-maxsize`. PHP builds that output by
concatenating the value into the message, which casts it, so the ports
now go through `php_to_string`.
Two more differences in the same rendering path:
The `[a, b]` branch flattened the array through `as_list()`, which sees
only `PhpMixed::List`, so a keyed array reaching it rendered as `[]`.
PHP takes that branch whenever the first key is numeric, so the values
now come from `array_values_mixed`, which covers both representations.
The raw-vs-resolved comparison deciding between `raw (value)` and
`value` compared the two as strings. PHP compares with `!==`, which
also compares the type: with a `cache-ttl` of `"15552000"` in
composer.json, the raw string and the resolved int are not identical
and Composer prints `15552000 (15552000)`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Composer shells out to `rm -rf` because PHP has no recursive directory removal.
Rust has one, and every package installed from a dist archive pays for a removal:
the extraction pipeline drops its temporary directory once per package, and
uninstalling a package removes its whole tree.
The asynchronous path goes through `tokio::fs` rather than `std::fs`, so the walk
runs on a blocking thread and the sibling installs the reactor is driving keep
making progress, the way they did while the subprocess was working.
Installing laravel/laravel (109 packages) from a warm cache drops from 3.85 to
3.19 CPU seconds. The removals run concurrently, so on an idle 16-core machine
they never reach the critical path and wall time is unchanged at 1.65 s; pinned
to two cores it falls from 2.33 s to 2.20 s. Pruning the 33 dev packages with
`install --no-dev`, where whole package trees are removed rather than empty
temporary directories, drops from 844 ms to 806 ms even on 16 cores.
Windows keeps the `rmdir /S /Q` subprocess, and both platforms keep falling back
to `remove_directory_php` when the fast path does not clear the directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
`fund_command.rs` claimed `CompleteAliasPackage` still had to be handled,
but `as_complete()` resolves through `is_complete()`, which already covers
`CompleteAliasPackage` and `RootAliasPackage` — the same set PHP's
`instanceof CompletePackageInterface` matches. The preceding alias guard
rules those out anyway, so both markers went away; the first condition is
now a single chain, matching the shape of the PHP original.
`Application::find()` skips commands it cannot borrow, where Symfony
registers the aliases of every command unconditionally. That is a real
divergence, so it gets `TODO(port)`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Composer requires Windows 7 or later with proc_open available before it
will use junctions, because a PHP bug (bugs.php.net #77552) makes
junction detection fragile and can lose the target content when a
package is removed. Junctions are created and removed here without
going through PHP, so that bug does not apply and the check always
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Auditor.php calls ConsoleIO::sanitize() with a single argument, so
$allowNewlines is the default true. The port passed false, stripping
newlines out of advisory titles, links and ignore reasons before they
reach the table renderer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
date() renders in the system default timezone, so the mismatch the ignore
described is gone; the test passes with the local date ahead of or behind
the UTC date.
test_show_outdated_deps_sorting_by_age still fails that way, because
ShowCommand::get_relative_time formats the release date in UTC while taking
"today" from date(). Its ignore now says so on its own.
|
|
PHP resolves a link kind through `$package->{'get'.ucfirst($linkType)}()`,
so the argument is a `Link::$TYPES` value ("requires", "devRequires", ...).
The helper matched the composer.json key names ("require", "require-dev",
...) instead, so every caller passing a `Link::TYPE_*` constant got an empty
map back: `show <package>` printed none of its requires/provides/conflicts/
replaces sections, and `--format=json` carried none of those keys.
The two callers that were passing composer.json keys now pass the matching
`method`, as PHP does.
|
|
The R-table dispatchers hand-rolled argument decoding and return encoding per
method, spread over 24 helpers. `FromPluginArg` and `ToPluginValue` replace
them: blanket impls for `Option<T>`, `Vec<T>` and `IndexMap<String, T>` compose
over a handful of leaf types, so a composite like `Vec<IndexMap<String, String>>`
needs no helper of its own, and the lossy UTF-8 conversion of a PHP string lives
in one impl instead of a dozen call sites.
Argument errors now read `{method} expects {expected} at position {position}`
rather than a message written per call site. `arg_or` takes its default for an
explicit null too, not only for an omitted argument; every parameter it serves
is declared non-nullable in the proxy stubs, so PHP never passes one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
`@composer <args>` and a bare `composer <args>` script both re-enter the
binary running the script, taken from COMPOSER_BINARY. That path used to
be prefixed with the PHP interpreter command, which produced
`php <shirabe path> install` and could not run: Shirabe ships as a native
executable, not a phar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Messages that instruct the user to run a command named the Composer
binary. Shirabe ships as its own binary, so the hints now name it.
File names (composer.json, composer.lock), the "composer" repository
type and prose about Composer itself are untouched. The installer and
functional integration fixtures live in the Composer submodule and
cannot be edited, so their expected output is normalized on load.
|
|
|
|
A PHP script listener that walks the event it receives (Laravel's
Illuminate\Foundation\ComposerScripts::postAutoloadDump asks for
$event->getComposer()->getConfig()->get('vendor-dir')) aborted the run
with `unknown Rust handle 2`.
The event's getComposer/getIO answers register an entity in the R table
and hand back its rhandle, but ScriptRpcDispatcher resolved only rhandle
0 and the one event handle of the call in flight, so it could not serve a
method on a handle it had just minted itself. The R-table lookup
PluginRpcDispatcher already does is now dispatch_r_table_method, shared
by both.
The stubs that graph hands out extend and implement the real Composer
contracts (Composer\Package\PackageInterface and the rest), which live in
the Composer PHP runtime and not among the generated stubs or guards, so
execute_event_php_script loads that runtime the way the plugin and
command-class paths do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
CompilingMatcher::match formats `<operator><constraint>;<version>` into a
fresh String on every call, including the cache hits the key exists to serve.
Profiling put the formatting at 2.9 % of self time, more than the match it
guards. Build the key into a reused thread-local buffer and copy it only when
there is a miss to record.
Taking the version as `&str` rather than `String` removes the copy the callers
made for the same reason: `optimize_by_identical_dependencies` allocated one
per candidate package in its innermost loop.
laravel/framework require --no-install (warm cache, network disabled):
instructions:u 9142799608 -> 8895247586 (-2.7 %)
cycles:u 4626440169 -> 4496152041 (-2.8 %)
wall (hyperfine, 25 runs)
1.239 s +- 0.014 s -> 1.227 s +- 0.016 s (-1.0 %)
monolog/monolog is unchanged (102.1 ms -> 101.6 ms, within noise).
composer.lock is byte-identical for both packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
isSubsetOf builds a throwaway `MultiConstraint([candidate, constraint])` and
hands it to `Intervals::get`, whose cache key is the constraint's string form.
That MultiConstraint is fresh on every call, so its memoized string form is
always cold and the whole intersection has to be stringified recursively --
profiling put `AnyConstraint: Display::fmt` at 3.6 % of self time, more than
the interval computation the cache exists to skip.
Cache the answer on the pair of operand strings instead. Both operands are
long-lived, so each one's own string memo stays warm and the throwaway
intersection is never built on a hit.
laravel/framework require --no-install (warm cache, network disabled):
instructions:u 9885142257 -> 9142874710 (-7.5 %)
cycles:u 4858265879 -> 4444135669 (-8.5 %)
wall (hyperfine, 20 runs)
1.322 s +- 0.018 s -> 1.224 s +- 0.011 s (-7.4 %)
monolog/monolog is unchanged (101.6 ms -> 100.3 ms, within noise).
composer.lock is byte-identical for both packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
startCachedAsyncDownload's cached branch decoded the provider JSON into an
owned value and then rebuilt it entry by entry, recursively cloning every
package definition only to drop the original. Match the decoded value by
value instead. PHP hands the decoded array to the closure by COW, so no copy
happens there either.
laravel/framework require --no-install (warm cache, network disabled):
instructions:u 10276126917 -> 9884102191 (-3.8 %)
cycles:u 5048262624 -> 4777881212 (-5.4 %)
wall (hyperfine, 20 runs)
1.345 s +- 0.015 s -> 1.316 s +- 0.018 s (-2.2 %)
monolog/monolog is unchanged (100.7 ms -> 101.7 ms, within noise).
composer.lock is byte-identical for both packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
PackageInterface::getRequires() and friends return the array of Link objects;
in PHP that is a copy-on-write array of object references, so a caller pays
nothing to look at it. The port returned IndexMap<String, Link> by value, so
every call deep-cloned the whole map, keys and constraints included. Pool
building calls these accessors once per package per candidate, which put
IndexMap::clone at 13.5% of `require laravel/laravel`.
Store the maps as Rc<IndexMap<String, Link>> and return a handle. Callers that
mutate the map clone it explicitly at the point of mutation, matching where PHP
would separate the array.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Intervals::get() memoizes generateIntervals() results, but the port deep-cloned
the IntervalCollection out of the cache on every hit, where PHP hands the array
back by copy-on-write. On `require laravel/laravel` that is 282k calls, 99% of
them hits.
Store and return Arc<IntervalCollection> so a hit costs a refcount bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
composer/semver memoizes MultiConstraint::__toString() into $this->string, but
the port recomputed it on every call. On `require laravel/laravel` that meant
837k stringifications visiting 1.7M child constraints, all of them feeding the
CompilingMatcher and Intervals cache keys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
php_version_compare canonicalized both operands into a Vec<&str> and copied
the canonicalized bytes a second time through from_utf8_lossy().into_owned().
Walk the split iterators directly and move the Vec<u8> into the String, which
takes the per-call allocation count from six down to two. Splits are only ever
inserted at ASCII digit boundaries, so the buffer is always valid UTF-8.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Options and arguments were stored and passed as PhpMixed even though
Symfony only ever puts a string, a bool, a list of strings or null in
one. get_option already narrowed to InputOptionValue at the boundary;
this widens that enum into InputValue and pushes it through
InputInterface, InputOption/InputArgument defaults, the Input storage,
ArgvInput/ArrayInput/StringInput/CompletionInput, Command::add_option
and add_argument, and the Composer-side wrappers.
Two neighbouring string|int unions get types of their own:
InputDefinition::{get_argument,has_argument} take an ArgumentName, and
ArrayInput keys its parameters by ParameterName. has_parameter_option
and get_parameter_option take the values they look for as &[&str],
which is what PHP's `(array) $values` cast produced anyway.
Two behaviours change along the way. Input::set_option on a negated
option now negates with PHP's loose bool cast rather than treating a
non-bool as false, matching `!$value`. ArrayInput::parse now resolves
an integer key to an argument position instead of looking up an
argument literally named "0".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
create_packages handed ArrayLoader::load_packages a clone of the package
array it had just built and then dropped its own copy, so every package
definition in the response was deep-copied once for nothing.
Measured on `require laravel/laravel` (77 packages) against a warm cache
with the network disabled: the create_packages span drops from 420 ms to
275 ms, and the run from 2.333 s to 2.192 s. Peak RSS is unchanged -- the
copy was transient. `composer.lock` is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
CurlDownloader built a fresh reqwest Client, and with it a fresh
connection pool, on every construction. A `require` run constructs two:
RequireCommand::doUpdate discards the Composer instance that
BaseCommand::initialize built and rebuilds it against the rewritten
composer.json, so the second one opened a second TCP+TLS connection to
the same repository and paid another CA bundle parse.
Nothing in the constructor varies the Client -- `options` and
`disable_tls` are not applied to it -- so it can be shared. The cache is
keyed by tokio runtime rather than by process: a pooled connection is
driven by a task on the runtime that opened it and hangs if it is later
handed to another one, and `sync_executor::block_on` builds a disposable
runtime per call outside `main`.
Measured on `require monolog/monolog` in a git-managed project against a
warm cache: TLS connections to the repository drop from 2 to 1, taking
the run from 646 ms to 529 ms with the network and from 104 ms to 101 ms
offline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Profiling `create-project` showed glibc malloc/free accounting for about
41% of samples: the work is dominated by a very large number of small,
short-lived allocations, mostly from decoding repository JSON into
PhpMixed.
`create-project laravel/laravel`, offline against a warmed cache, all
three variants measured in one hyperfine run (8 runs each, identical
composer.lock):
glibc 3.633 s +- 0.023 (user 3.580 s, sys 2.255 s)
mimalloc 3.096 s +- 0.054 (user 3.004 s, sys 2.315 s) -14.8%
jemalloc 3.177 s +- 0.028 (user 3.136 s, sys 2.285 s) -12.5%
The gain is entirely in user time; system time is unchanged, so the
extraction and file-writing half of the run is unaffected. Peak RSS drops
slightly, from 267 MB to 257 MB. mimalloc wins over jemalloc and, unlike
jemalloc, needs no C build of its own at every profile.
The allocator is declared in the library rather than in `main.rs` so that
the test and benchmark binaries, which link this crate but not `main.rs`,
run against the allocator the binary ships with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
`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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
`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>
|
|
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>
|
|
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.
|
|
The `single` group compares the argv form against the string form, which
goes through an extra `/bin/sh -c`; the `concurrent` group varies the job
count around `max_jobs` so the semaphore throttle is visible as a
throughput knee.
`execute_async`'s futures are `!Send`, so they run on a current-thread
runtime and overlap only through `join_all` within a single task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|