aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
AgeCommit message (Collapse)Author
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 in the vec-shaped preg_*nsfisis
preg_match_all() and preg_match_all_set_order() were the last preg_* functions handing back a bare Vec<String>, where a group that did not participate is indistinguishable from one that captured "". Hand back Option<String> as the map-shaped functions already do; php_match_row(), the last of the truncate-then-pad helpers, goes with them. preg_split_delim_capture() keeps its Vec<String>. preg_split() accepts no PREG_UNMATCHED_AS_NULL, so there is no null form to move it to, and its result interleaves split segments -- which can never be absent -- with the captured delimiters. 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-17docs(output-formatter): drop the stale possessive-quantifier TODOnsfisis
The possessive quantifiers in Symfony's tag-matching patterns only suppress backtracking: the open-tag class excludes backslash, so giving a character back can never let the \\. alternative or the closing > match, and the same holds for the close-tag class and >. Removing them is the documented port for performance-only possessive quantifiers, not an approximation waiting on a PCRE engine. 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-17refactor(php-shim): split single_match_map() by unmatched_as_nullnsfisis
Each branch of the flag is now its own function. The PREG_UNMATCHED_AS_NULL variant needs neither the trailing-group truncation nor the empty-string fallback, so it loses the break condition and the last_participating scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(pcre): drop the guards for unsupported preg flagsnsfisis
Preg::match5() rejected PREG_OFFSET_CAPTURE and check_set_order() rejected PREG_SET_ORDER, mirroring the PHP where those flags would change the type of $matches. The Rust ports of both take a typed `matches` out-param instead, so neither flag can reach them; check_set_order() already had no caller. The three constants are unreferenced once the guards are gone. 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-17refactor(preg): drop pass-through private helpersnsfisis
match_all5() was identical to match_all3(), and replace_impl() only forwarded its arguments to preg_replace2(). Call the destinations directly.
2026-08-17refactor(preg): merge preg_match_all_offset_capture2() into its siblingnsfisis
The two functions ran the same search and differed only in how they reported a non-participating group: one as ("", 0) in a bespoke struct, the other as (null, -1) in a capture-key map. Neither shape covered both callers, because PHP reaches preg_match_all() with different flags from each: Preg::matchAllWithOffsets() always ORs in PREG_UNMATCHED_AS_NULL, while OutputFormatter::formatAndWrap() passes PREG_OFFSET_CAPTURE alone. Keep the capture-key map, which also exposes named groups, and take the flags that decide between null and "" for an unmatched group. The offset is -1 either way, so the ("", 0) approximation is gone. 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-17refactor(preg): drop unused args of preg_match_all_offset_capture2()nsfisis
The sole caller, Preg::match_all_with_offsets5(), always passed flags=0 and offset=0, so PREG_UNMATCHED_AS_NULL is now unconditional and the subject is scanned from the beginning. That method is only reached from Preg::match_all_with_offsets(), so it is no longer public either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17refactor(preg): drop unused flags and offset of preg_match_all2()nsfisis
Every caller reached preg_match_all2() through Preg::match_all5(), which always passed flags = PREG_UNMATCHED_AS_NULL and offset = 0. Inline those constants and make match_all5() private, since the two public wrappers are its only callers. 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(preg): re-order preg_*() functions in shimnsfisis
2026-08-16refactor(preg): merge preg_replace_callback2 into preg_replace_callbacknsfisis
preg_replace_callback2 had one caller, Preg::replace_callback6, which was itself reached only from Preg::replace_callback with the default limit, count and flags. Fold the two shim functions into one and drop those parameters along with replace_callback6. The surviving callback takes callback2's IndexMap of matches: it can be keyed by group name, and it omits trailing non-participating groups the way PHP does, which is what the callbacks in Process and ProgressBar test for.
2026-08-16perf(php-shim): scan the phar stub token with memchrnsfisis
The stub token search compared every 18-byte window of the file case insensitively. The token starts with a byte that ASCII case folding leaves alone, so memchr can pick out the candidate offsets and leave only those to the case-insensitive compare. Over the 35 MB executable, finding the token in the embedded bundle stub drops from 2.3 ms to 0.1 ms, and a scan that finds nothing drops from 24 ms to 3.6 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16test: correct stale #[ignore] reasonsnsfisis
Running the ignored tests shows several reasons naming a blocker the test never reaches. The plugin hooks in all_functional_test do run, and what stops both cases is the worker's Composer\InstalledVersions; class_loader_test stops at include_file, not class_exists; auth_helper_test's wrapper is ported and the blocker is trigger_error; composerRequire is ported as __shirabe_composer_require. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16refactor(tests): share the PHP worker helpers with the plugin binarynsfisis
plugin_installer_test.rs carried its own copy of php_runtime_available, lock_php_worker and load_composer_php_runtime, and the other ten files in the binary imported them from there. The bodies matched tests/common/php_worker.rs, so include that instead. 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-16fix(symfony-finder): fail SplFileInfo::get_size on a failed statnsfisis
\SplFileInfo::getSize() throws a \RuntimeException when stat fails, and neither Symfony's subclass nor Composer's Filesystem::directorySize() catches it. Returning 0 turned an unreadable file into an empty one. 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(ca-bundle): resolve SSL_CERT_* the way CaBundle doesnsfisis
CaBundle::getEnvVariable() prefers $_SERVER and falls back to getenv() only under the CLI SAPI. The port read the live process environment for both SSL_CERT_FILE and SSL_CERT_DIR, skipping the snapshot entirely. 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-16fix(php-shim): render floats through php_gcvtnsfisis
serialize(), var_export() and the string cast each hand-rolled `format!("{}", f)`, which never emits PHP's exponential spelling and carries the wrong precision for the string cast. PHP writes 1.0E+20 where Rust writes 1e20, and (string) 1/3 is 0.33333333333333, not the shortest round-trip form. Delegate all three to smart_str_append_double, which shirabe-php-src already provides and shirabe-php-rpc's codec already used: precision -1 for serialize() and var_export(), 14 for the string cast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16fix(php-src): round dtoa ties to even and port gcvt mode 2nsfisis
Rust's shortest float formatting rounds a tie away from zero where zend_dtoa mode 0 rounds to even, so digits taken straight from `{:e}` diverged from PHP on values such as -166050639803968.125. Take only the digit count from `{:e}` and re-format at that fixed precision, which rounds the exact decimal value half to even. The same routine supplies the fixed-precision mode `php_gcvt` previously refused with an assert, so both modes now share one path. 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(php-rpc): replay Rust-side env writes into the PHP workernsfisis
The worker is a long-lived child holding the environment it was handed at spawn, so `@putenv`, the bin dir the event dispatcher prepends to PATH, and COMPOSER_DEV_MODE never reached the PHP code running in it. The shim now journals every write to the three storages PHP exposes, and the outermost rpc_call replays the entries the worker has not seen yet through __shirabe_sync_env. Replaying the writes rather than pushing a whole snapshot keeps the worker's own $_SERVER entries intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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): decode JSON straight into PhpMixednsfisis
json_decode built a serde_json::Value first and then converted it. Drive serde_json's Deserializer with a DeserializeSeed instead, so the value is built in one pass without the intermediate representation. 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): drop the unused SymfonyStyle methodsnsfisis
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-16test(event-dispatcher): run PHPUnit-class listeners in the PHP workernsfisis
The listeners of five tests are methods of `Composer\Test\EventDispatcher\EventDispatcherTest`, which the worker could not load because the class extends `PHPUnit\Framework\TestCase` and phpunit is not part of the Composer runtime bundle. An empty stand-in for that base class is enough: a parent class only has to exist at declaration time, and method bodies and type declarations resolve lazily. The upstream file is then required into the worker unchanged, so the listener bodies and their `__DIR__` stay what Composer ships. The two assertions those bodies call are the only PHPUnit members the stand-in has to implement. `remove_listener` now compares a handle for an object the worker really holds instead of a hand-written one, and `create_composer_instance` wires the collaborators the autoloader-rebuild path reaches for, as the PHP helper does. Two tests stay ignored for a different reason: `Platform::put_env` writes only the Shirabe process environment, while the listeners read `getenv()` inside the long-lived worker. 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>