From 570b2381881cb89a673f900426fb6b7a48bbef5c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:22:23 +0600 Subject: [PATCH 01/26] :memo: docs(release): add 5.3.2 review and release plan - Add ArrayKit 5.3.2 review and release plan detailing audit evidence and corrections :memo: --- arraykit-5.3.2-review-and-release-plan.md | 180 ++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 arraykit-5.3.2-review-and-release-plan.md diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md new file mode 100644 index 0000000..b603cc1 --- /dev/null +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -0,0 +1,180 @@ +# ArrayKit 5.3.2 review and release plan + +Date: 2026-10-30 (Asia/Dhaka). + +Reviewed source: `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`, the published `5.3.1` tag. The working tree was clean before this report. This document records an audit and proposed work; none of the corrections below has been implemented by this review. + +This plan follows the installed [PHPForge engineering principles](vendor/infocyph/phpforge/resources/engineering-principles.md) and [agent workflow](vendor/infocyph/phpforge/resources/AGENTS.md). Correctness, data integrity, bounded work, public contracts and host ownership take precedence over throughput. Performance claims require measurements with correct outputs. + +## Release decision and scope + +Recommend **5.3.2**, a patch containing the nine confirmed corrections and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. Supporting another compatible optional provider version does not require a new ArrayKit API or an ArrayKit major release. + +Use **5.4.0** only if implementation introduces an intentional new public capability or API beyond these corrections. Do not remove Runwire 2.1.1 support, raise PHP 8.4, change named arguments, or turn Runwire into a mandatory runtime dependency as part of this patch. + +Scope is ArrayKit's code, tests, docs, Composer constraints, benchmark fixtures and CI. Foundation adapters and qualification of Runwire's HTTP protocols, native workers, transports or host drivers are separate projects and are not ArrayKit release blockers for this work. + +## Audit evidence + +| Check | Result and limitation | +| --- | --- | +| Source review | All 37 production PHP files: array/set/query/sort operations, dot paths, collections/pipeline/replay, config/layering/generated caches, dotenv/environment, DTOs, hooks, facade and helpers; associated tests, benchmarks and workflow. Graphify was used for navigation; claims were checked in source and independent probes. | +| Installed PHPForge setup | Doctor healthy; bundled configs active; PHPStan max, class complexity 80 and function complexity 12. No detector, scope or threshold was weakened. | +| Local detailed suite | `composer ic:tests:details` passed: 401 tests, 8,797 assertions on PHP 8.5.4, plus formatting, architecture, static/security analysis and refactoring checks. | +| Local final full suite | `composer ic:tests` passed all 13 tasks. | +| Composer | `composer validate --strict` and `composer ic:release:constraints` passed. | +| Live locked dependency audit | Zero published advisories; development-only `doctrine/annotations` remains abandoned through benchmark tooling. Raw Composer exits nonzero for abandonment; this is distinct from an advisory and is non-blocking under existing PHPForge policy. ArrayKit has no third-party runtime package requirements. | +| Exact-source CI | [Security & Standards run 40161657499](https://github.com/infocyph/ArrayKit/actions/runs/40161657499) succeeded for `e77bf72…`: PHP 8.4/8.5 analysis, stable/lowest QA, clean installation, benchmarks, security report and Release Acceptance. Recent Dependabot-only runs were not counted as QA evidence. | +| Runwire 3.0 publication | Live [Composer metadata](https://repo.packagist.org/p2/infocyph/runwire.json) and the [3.0 source tag](https://github.com/infocyph/Runwire/tree/3.0) identify `054737bc6a8d4025a8d05fabb8ad390f66ee47ae`, published 2026-10-29. PHP and 64-bit PHP remain `^8.4`. | +| Runwire 3.0 composition | All 401 tests / 8,797 assertions passed using the exact published source in an isolated `/tmp` autoloader. Reflection confirmed that runtime and coroutine classes came from that source, not installed 2.1.1. This is runtime/source compatibility evidence, not a clean Composer matrix or a native host certification. | +| Runwire 3.0 worker soak | The existing persistent CLI harness passed with the published 3.0 source: 310.06 seconds including warm-up, 300 samples, initial/peak RSS 51.39 MiB and 0.00 MiB growth under the unchanged 16 MiB ceiling. It exercises a CLI worker with capability metadata; it does not launch a native RoadRunner host. | +| Additional parity probes | 480 strict membership comparisons against native `in_array()` behavior passed across lookup thresholds, mixed scalars, ordinary arrays, object identity, NaN, infinities and signed zero. Escaped dot-path flatten/expand probes passed. | +| Adversarial probes | Reproduced R01–R09 below outside the green suite. Their existence means green baseline tests are insufficient for release acceptance. | + +This audit did not establish an authentication bypass or arbitrary code execution. R01 is a conditional resource-safety failure when object graphs reach the guarded sorter; R02/R03 are data-integrity failures. PHP configuration/cache artifacts, callbacks and user-defined property methods remain trusted host code, not sandboxed input. + +## Required corrections + +Priorities are engineering priorities, not CVSS scores. P1 means correct before publication; P2 is a narrower confirmed defect included in this patch. + +### R01 — P1: guarded sort compares uncounted object graphs + +Owners: `src/Array/Concerns/ArrayMultiQuerySortTrait.php:909` and `:1234`. + +`sortRecursiveWithGuards()` counts array entries and descends only into arrays. `compareSortValues()` subsequently uses PHP's regular comparison on object values, which can descend through arbitrarily large public object graphs outside the advertised budget. + +Reproduction: two distinct `stdClass` values, each with `self` pointing to itself and different `tail` values, passed to `sortRecursiveGuarded([$left, $right], maxNodes: 2, throwOnTooDeep: true)` produce `Error: Nesting level too deep - recursive dependency?` from the comparator instead of controlled guard handling. Wide acyclic object graphs cross the same boundary without requiring cyclic PHP references. The documentation recommends this guarded entry point for untrusted/deep input and does not exclude object values there. + +Correction: define and enforce a supported bounded-comparison domain before sorting. Charge supported object-data traversal to the same depth/node budget and detect active-path cycles, or reject object/comparison cases that cannot be bounded before invoking PHP comparison. Custom object/string conversion work must remain an explicit trusted-code boundary. Preserve normal `sortRecursive()` semantics. In non-throwing mode, propagate incompleteness to ancestors so no unsafe comparison runs; throwing mode must produce a controlled documented exception. + +Acceptance: cyclic/distinct/shared objects, wide public object graphs, arrays containing such objects, opaque internal objects, all sort flags, exact boundaries, throw/non-throw behavior, scalar/array equivalence below limits, and fixed-budget work as object width grows. Do not claim arbitrary user callbacks can be sandboxed. + +### R02 — P1: failed rename/move deletes the source + +Owner: `src/Array/Concerns/DotNotationPublicApiTrait.php:361`. + +`rename()` reads and removes the source before attempting the destination write. With `['source' => 42, 'target' => $object]` and a readonly `target.locked`, `rename($data, 'source', 'target.locked')` throws for the readonly property after `source` has already disappeared. `move()` and facade forwarding inherit the failure. + +Correction: preflight/stage ordinary destination changes and commit source removal only when the move can succeed. Preserve source data on rejected writes and preserve established parent/child path behavior. Avoid a blanket swap of the two existing calls: overlapping source/destination paths require deliberate handling. Do not promise rollback of arbitrary external side effects inside application magic setters. + +Acceptance: readonly/inaccessible/typed property failures, throwing setters, missing sources, same path, parent-to-child and child-to-parent moves, escaped paths, overwrite/fill flags, existing destination values, and native/facade named reference arguments. Failed supported moves must retain the source. + +### R03 — P2: snapshots alias externally referenced scalar/array state + +Owner: `src/Config/Concerns/BaseConfigTrait.php:498` and `:534`. + +Reproduction: load `['app' => ['v' => &$value]]` with `$value = 1`; call `snapshot()`, `set('app.v', 2)`, `changed()`, `restore()`. `changed()` returns false and the restored value remains 2. Assignment into the snapshot retains the PHP reference cell, so the live mutation changes the saved state as well. Config supports referenced reads and documents restore as rollback. + +Correction: make saved scalar/array state independent of live external reference cells, including repeated restores. Retain the established shallow identity contract for arbitrary objects; this is not a generic deep-clone feature. Handle internal aliases and cyclic arrays deliberately, with deterministic failure before replacing a valid snapshot if a graph cannot be represented under the chosen contract. Do not use `serialize()`/`unserialize()` as a generic copying mechanism or silently change ordinary live-reference reads/writes. + +Acceptance: top-level/nested references, external mutation after snapshot, internal aliases, multiple named snapshots, repeated restore/mutate/restore, changed/read-cache parity, readonly restore, and Config/LazyFileConfig/LayeredLazyFileConfig use. Document exactly what remains shallow for object values. + +### R04 — P2: explicit cache refresh retains old source-origin data + +Owner: `src/Config/Concerns/LazyFileConfigCacheTrait.php:189`. + +Reproduction: start with no cached `app` namespace, read source version 1, publish a generation containing source version 2 with a separate writer, then call `namespaceCache($sameRoot)` on the original reader. It still returns 1 while a fresh reader returns 2. Invalidation removes only cache-origin items and clears cache/missing flags; source-origin items survive and shadow the newly selected generation. Same-instance warm-up also needs coverage. + +Correction: explicit refresh/rebuild must invalidate affected source-derived state as well as cached/flat/memo state, while retaining deliberate constructor/runtime overrides. Keep generation pinning coherent across exact and structural reads. Ordinary pinned readers must remain stable until an explicit refresh; do not add pointer polling to each hot read. + +Acceptance: source-only reader before first publication; empty-cache generation; exact versus structural reads; same-root refresh, source/cache switching, warm-up and flush; multiple namespaces; ResilientLazyFileConfig; and preserved intentional runtime mutations. Do not infer a new public LayeredLazyFileConfig refresh API from this correction. + +### R05 — P2: canonical numeric namespace names become integer map keys + +Owners: `src/Config/LayeredLazyFileConfig.php:244`, `src/Config/Concerns/LazyFileConfigCacheTrait.php:167`, and namespace metadata in `src/Config/LazyFileConfig.php`. + +`0` is accepted by the namespace grammar, but PHP coerces associative key `'0'` to integer `0`. A layered config with `namespaces: ['0']` fails during `all()` because `materializeNamespace(string)` receives an integer. Discovery of `0.php` similarly makes no-argument `warmNamespaceCache()` call `namespaceCacheWarmValue(string)` with an integer. Numeric names can also violate the documented string list returned by `loadedNamespaces()`. + +Correction: normalize namespace identifiers to strings when crossing map-key/list boundaries, and consistently register numeric-looking constructor/runtime/source names. Preserve the current allowed namespace grammar and filename meaning; do not solve this by rejecting previously accepted names. + +Acceptance: `'0'`, positive and negative canonical integers, noncanonical numeric-looking strings such as `'01'`, ordinary names, discovery, explicit warm-up, full retrieval, snapshots/exports, loaded-name lists, and lazy/layered/resilient behavior. Invalid names still fail at the existing validation boundary. + +### R06 — P2: positive random sampling from an empty array misses validation + +Owner: `src/Array/BaseArrayHelper.php:223`. + +`random([], 1)` and `random([], 2)` return `[]`. Both requests exceed the available size, which the PHPDoc and user guide say must raise `InvalidArgumentException`. The empty-array early return bypasses that validation. + +Correction: validate positive requested counts before the empty-result shortcut. Preserve empty single-pick behavior (`null`) and the established zero/nonpositive-count behavior unless a separate contract change is approved. + +Acceptance: empty/nonempty arrays; omitted, zero, negative, exact-size and oversized counts; preserved keys; facade calls; no process-global randomness changes. + +### R07 — P2: averaging can overflow or cast a valid float outside integer range + +Owner: `src/Array/ArraySingle.php:21`, also exposed through the single-array facade. No new pipeline API is proposed. + +`avg([1e20])` returns integer `7766279631452241920` on the reviewed 64-bit runtime instead of `1e20`. `avg([PHP_INT_MAX, PHP_INT_MAX])` returns `PHP_INT_MIN`, flipping the sign. The result is blindly cast to integer whenever `fmod($avg, 1.0)` is zero, without proving integer representability. PHP 8.5 also emits a warning. Separately, adding two finite `1e308` values overflows the intermediate total and returns infinity even though their mean is finite. + +Correction: retain the ordinary fast path and established integer/float return behavior for representable results, but cast only after a range-safe check and use a numerically safe path when intermediate accumulation would overflow. Do not assume a float comparison with `PHP_INT_MAX` proves representability: converting the integer bound to float can round it upward. No arbitrary-precision dependency or broad aggregate rewrite is required. + +Acceptance: positive/negative values outside integer range, both integer bounds and nearby floats, repeated `PHP_INT_MAX`, finite large floats whose sum overflows, mixed signs, infinities/NaN, numeric strings, fractional results, empty/non-numeric input, facade calls and `E_ALL` on both supported PHP versions. Preserve existing finite ordinary cases and define special-value behavior explicitly. + +### R08 — P2: enum getters do not consistently use the supplied fallback + +Owner: `src/Config/Concerns/BaseConfigTrait.php:186`. + +The backed-enum branch accepts either integer or string values and calls `tryFrom()` in strict mode. An integer-backed enum with stored string `'1'`, or a string-backed enum with stored integer `1`, raises `TypeError`. The getter otherwise returns its supplied default for missing, unsupported and unmatched values, so a wrong backing scalar unexpectedly escapes that typed-getter boundary. + +Correction: validate the enum's backing scalar type before calling `tryFrom()` and return the default on mismatch, without introducing implicit scalar coercion. Preserve the error for a nonexistent enum class and preserve unit-enum lookup by case name. Avoid repeated discovery/reflection on a hot lookup path when a simple bounded metadata reuse strategy is warranted. + +Acceptance: integer-backed, string-backed and unit enums; correct values, unmatched values, numeric-looking strings, wrong backing scalar, null, boolean/array/object input, explicit defaults, missing config, and all config variants. A valid lookup continues to return the exact enum singleton. + +### R09 — P2: NaN is classified as both positive and negative + +Owner: `src/Array/ArraySingle.php:304` and `:329`. + +`isPositive([NAN])` and `isNegative([NAN])` both return true, while native `NAN > 0` and `NAN < 0` are false. These functions reject only the opposite comparison; NaN is unordered, so it escapes both checks. + +Correction: require the intended positive/negative predicate for every included numeric value. NaN must not establish either sign. Preserve ignored non-numeric values, the all-nonnumeric/empty result, zero handling and infinity signs; do not globally change `numericValue()` or unrelated arithmetic contracts merely to fix these predicates. + +Acceptance: NaN alone and mixed with signed values, both infinities, positive/negative zero, finite values, numeric strings, empty/all-nonnumeric inputs and facade calls. Assert that NaN is neither sign. + +## Runwire 3.0 support + +### Compatibility and ownership decision + +The current production binding consumes `RuntimeContext`, `RequestContext`, `CancellationToken`, `CoroutineScope`, `TaskLocal`, runtime coroutine capability, and their lifecycle/checkpoint methods. Those APIs remain available in the exact 3.0 source and all existing tests pass against them. + +1. Expand development compatibility to `^2.1.1 || ^3.0`, with separate matrix jobs forcing exact 2.1.1 and the published 3.0 version. Cover newest supported compatible releases as appropriate without presenting untested versions as certified. +2. Update the optional Composer suggestion and integration docs to describe the tested versions. Keep the production dependency set unchanged. +3. Keep `withRunwire($runtime, $request, $scope, $checkpointEvery)` and explicit intermediary forwarding unchanged. No ambient/global runtime discovery, implicit binding, worker startup or event-loop creation. +4. Continue capability-gated yielding and request/scope cancellation/completion checks before source consumption and after resumption. Keep closed/invalid supplied lifecycle errors distinct from the normal missing-capability fallback. Preserve `take(0)` laziness. +5. Exercise genuine host-owned `CoroutineRuntime`/scope tasks, interleaved requests, cancellation/deadlines, fail-fast groups, source/callback errors, early abandonment, rebinding and reference release under both supported Runwire versions. The host completes requests and closes scopes. +6. Verify installed/unbound and clean Runwire-absent production installs. Avoid reflection of private scheduler state or version checks on each item. + +The relevant advantage is retaining cooperative fairness and cancellation while hosts adopt Runwire 3.0's runtime improvements. Array/DTO/config algorithms remain ArrayKit's responsibility. Runwire's outbound HTTP, WebSocket, transport, TLS and worker-pool features do not justify new integration in a pure-data path. Async file APIs do not make executable PHP config `include` asynchronous. Do not add an async config adapter, background worker or offload layer without a concrete caller need and representative throughput evidence. + +## Execution sequence and structural constraints + +| Batch | Work | Exit condition | +| --- | --- | --- | +| A | Add regressions for R01/R02, then fix guarded comparison and failed move handling. | Unsafe comparisons stop under the declared budget; rejected moves retain their source; ordinary APIs and facade contracts pass. | +| B | Add regressions and correct R03–R05 in existing config owners. | Snapshot rollback and source/cache generation coherence hold across all config variants; numeric namespaces work consistently. | +| C | Correct R06–R09 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries pass; 2.1.1/3.0 and absent/unbound compositions pass without host ownership changes. | +| D | Run quality, performance, persistent-worker and final-revision CI acceptance. | Every applicable repository-owned gate below is satisfied and reported accurately. | + +Expected public API additions: **zero**. Expected new production classes/interfaces/enums: **zero**. Keep behavior in the current cohesive owners, prefer private helpers only where they reduce actual duplication or make a safety boundary clear, and do not split types just to evade complexity limits. Any necessary departure must identify its concrete contract/value and measured cost before being included. Public signatures, named arguments, synchronous defaults, supported PHP versions and normal return/key ordering remain compatibility constraints. + +Do not bundle unrelated formatter churn, generic serialization infrastructure, framework adapters or a runtime rewrite into these fixes. Review processor output and retain every active detector; no suppressions, baselines, exclusions or threshold increases. + +## Release acceptance + +- [ ] Regressions reproduce each confirmed trigger on `5.3.1` and pass after its correction. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. +- [ ] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. +- [ ] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. +- [ ] Make the optional integration a reproducible Composer matrix for 2.1.1 and 3.0, rather than relying only on this audit's temporary source-autoloader experiment. +- [ ] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with both supported Runwire versions and correct host cleanup. Do not weaken the ceiling to pass a test. +- [ ] Use **5.3.1** as the before/after performance baseline for this patch. The current representative harness hardcodes 5.2.0; update the benchmark input/baseline selection as part of release evidence, not production behavior. Validate equivalent correct outputs before comparing throughput. +- [ ] Measure changed hot paths, config cold/warm/generated reads and snapshot cost, bounded failures, array/reference/one-shot replay, and bound/unbound Runwire pipelines. Include input-size scaling and a host-owned yielding workload so fairness/cancellation are measured, not merely synchronous materialization. +- [ ] Keep component evidence labelled component evidence. For an application-RPM claim, use an ArrayKit-owned minimal request/task consumer with warmed OPcache and optimized production loading, sustained synchronized measurement windows, several concurrency levels, at least three trials, validated successful RPM, p50/p95/p99, errors/timeouts, CPU and peak/steady RSS. Exclude setup/warm-up/cleanup from the steady-state denominator. Record exact source IDs, PHP/extensions/INI, OS/hardware, datasets, variance, queue/task state and runner stability. +- [ ] Preserve the configured 2% throughput budget wherever a controlled matching stable environment is available. Predeclare workload latency/memory limits from baseline/host capacity. On a noisy/shared runner, report the comparator as skipped/unverified; never switch the stable flag on just to obtain a green badge. A host harness owned by ArrayKit can qualify library impact without making Foundation or other runtimes part of this task. +- [ ] Keep user docs focused on behavior and migration. Put transient verification logs/results in CI or review artifacts, not in release documentation. Record limitations in the release decision without claiming unavailable external certification. +- [ ] Confirm every required workflow job against the final committed candidate; a green baseline or a newer Dependabot-only run is insufficient. Create/tag/publish the patch only as a separate authorized release action. + +## Optional follow-ups, not patch requirements + +- Unguarded cyclic/reference-containing membership/fingerprint inputs remain outside the current documented contract. An isolated cyclic `diff()` probe succeeded below the lookup threshold and exhausted a 32 MiB process at the optimized threshold. This is not counted among R01–R06 because the contract excludes that input; consider deterministic rejection only as a separately scoped hardening change with compatibility/performance tests. +- No compatible removal of the benchmark tool's abandoned development dependency was established here; track that upstream without removing benchmarks or weakening the audit. +- Broad class/file-count reduction, multibyte text matching, new async APIs and new host-driver adapters have no demonstrated requirement in this audit and should not be added to the patch. + +Temporary local audit artifacts use `/tmp/arraykit-audit-*`; they are supporting logs/probes, not committed acceptance tests. The implementation must turn the required reproductions into repository tests before closing R01–R09. From a017d1d4e5a7db63dafad0baf7c800e42d78a825 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:47:54 +0600 Subject: [PATCH 02/26] docs: extend ArrayKit 5.3.2 release review with median and dispatch regressions --- arraykit-5.3.2-review-and-release-plan.md | 35 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index b603cc1..44002fd 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -4,11 +4,11 @@ Date: 2026-10-30 (Asia/Dhaka). Reviewed source: `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`, the published `5.3.1` tag. The working tree was clean before this report. This document records an audit and proposed work; none of the corrections below has been implemented by this review. -This plan follows the installed [PHPForge engineering principles](vendor/infocyph/phpforge/resources/engineering-principles.md) and [agent workflow](vendor/infocyph/phpforge/resources/AGENTS.md). Correctness, data integrity, bounded work, public contracts and host ownership take precedence over throughput. Performance claims require measurements with correct outputs. +This plan follows the [PHPForge engineering principles](https://github.com/infocyph/PHPForge/blob/main/resources/engineering-principles.md) and [agent workflow](https://github.com/infocyph/PHPForge/blob/main/resources/AGENTS.md). Correctness, data integrity, bounded work, public contracts and host ownership take precedence over throughput. Performance claims require measurements with correct outputs. ## Release decision and scope -Recommend **5.3.2**, a patch containing the nine confirmed corrections and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. Supporting another compatible optional provider version does not require a new ArrayKit API or an ArrayKit major release. +Recommend **5.3.2**, a patch containing the eleven identified corrections (R01–R09 from the original audit and R10–R11 from the supplemental review) and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. Supporting another compatible optional provider version does not require a new ArrayKit API or an ArrayKit major release. Use **5.4.0** only if implementation introduces an intentional new public capability or API beyond these corrections. Do not remove Runwire 2.1.1 support, raise PHP 8.4, change named arguments, or turn Runwire into a mandatory runtime dependency as part of this patch. @@ -29,7 +29,8 @@ Scope is ArrayKit's code, tests, docs, Composer constraints, benchmark fixtures | Runwire 3.0 composition | All 401 tests / 8,797 assertions passed using the exact published source in an isolated `/tmp` autoloader. Reflection confirmed that runtime and coroutine classes came from that source, not installed 2.1.1. This is runtime/source compatibility evidence, not a clean Composer matrix or a native host certification. | | Runwire 3.0 worker soak | The existing persistent CLI harness passed with the published 3.0 source: 310.06 seconds including warm-up, 300 samples, initial/peak RSS 51.39 MiB and 0.00 MiB growth under the unchanged 16 MiB ceiling. It exercises a CLI worker with capability metadata; it does not launch a native RoadRunner host. | | Additional parity probes | 480 strict membership comparisons against native `in_array()` behavior passed across lookup thresholds, mixed scalars, ordinary arrays, object identity, NaN, infinities and signed zero. Escaped dot-path flatten/expand probes passed. | -| Adversarial probes | Reproduced R01–R09 below outside the green suite. Their existence means green baseline tests are insufficient for release acceptance. | +| Adversarial probes | The original audit reproduced R01–R09 outside the green suite. The supplemental branch review additionally identified R10/R11 in source, with independent native PHP probes for the overflow and method-visibility behavior. Repository regression tests must still reproduce both against the baseline. Green baseline tests are insufficient for release acceptance. | +| Branch QA status (supplemental review, 2026-10-30) | `patch/runwire` was one commit ahead of `main`, changing only this plan. No Actions runs existed for the branch at review time. The successful workflow evidence above applies to the 5.3.1 baseline, not the new release candidate. | This audit did not establish an authentication bypass or arbitrary code execution. R01 is a conditional resource-safety failure when object graphs reach the guarded sorter; R02/R03 are data-integrity failures. PHP configuration/cache artifacts, callbacks and user-defined property methods remain trusted host code, not sandboxed input. @@ -129,6 +130,26 @@ Correction: require the intended positive/negative predicate for every included Acceptance: NaN alone and mixed with signed values, both infinities, positive/negative zero, finite values, numeric strings, empty/all-nonnumeric inputs and facade calls. Assert that NaN is neither sign. +### R10 — P2: even-length median overflows on finite inputs + +Owner: `src/Array/ArraySingle.php:438`, including `Pipeline::median()` and facade forwarding. + +For even-length numeric input, `median()` calculates `($values[$mid - 1] + $values[$mid]) / 2`. Two finite values such as `[1e308, 1e308]` overflow during addition and produce `INF`, although the correct median is finite. An independent PHP 8.4 probe confirms this arithmetic boundary. This issue is separate from R07's `avg()` implementation and must not be considered closed just because averaging is repaired. + +Correction: use an overflow-safe two-value midpoint calculation for finite extremes without losing ordinary integer/float behavior. Address opposite-sign extremes, same-sign extremes, and integer-boundary precision deliberately; do not introduce an arbitrary-precision dependency or a generic aggregate rewrite. Keep sorting, median selection, and existing exclusion of nonnumeric values unchanged unless tests establish a conflicting existing contract. + +Acceptance: even/odd counts, `[1e308, 1e308]`, negative large finite pairs, near-`PHP_INT_MAX`/min pairs, opposite-sign extremes, fractions, numeric strings, empty/all-nonnumeric input, nonfinite values with documented semantics, facade and pipeline calls, and warning-free `E_ALL` on PHP 8.4/8.5. Add a regression demonstrating the baseline failure before changing production code. + +### R11 — P2: collection magic forwarding attempts private Pipeline methods + +Owners: `src/Collection/Concerns/BaseCollectionTrait.php:51` and private helpers in `src/Collection/Pipeline.php:791`, `:798` and `:809`. + +`Collection::__call()` checks `method_exists($pipeline, $method)` before forwarding. That check includes private methods, but the subsequent call from the collection trait cannot access them. For example, invoking `$collection->numericAggregate(null, true)` passes the check and raises a PHP `Error` about a private method instead of the documented `BadMethodCallException` for unavailable collection methods. Native PHP visibility behavior was independently probed. + +Correction: forward only callable public pipeline operations. Reject private/internal methods through the existing controlled missing-method exception, without exposing private implementation helpers, adding proxies, or introducing a new reflection-heavy dispatch layer. + +Acceptance: every publicly supported fluent operation remains callable through `Collection` and `HookedCollection`; private/internal helper names are rejected with `BadMethodCallException`; unknown method names preserve the same exception; argument forwarding and return/chain types remain unchanged. Add a regression demonstrating the baseline failure before changing production code. + ## Runwire 3.0 support ### Compatibility and ownership decision @@ -150,7 +171,7 @@ The relevant advantage is retaining cooperative fairness and cancellation while | --- | --- | --- | | A | Add regressions for R01/R02, then fix guarded comparison and failed move handling. | Unsafe comparisons stop under the declared budget; rejected moves retain their source; ordinary APIs and facade contracts pass. | | B | Add regressions and correct R03–R05 in existing config owners. | Snapshot rollback and source/cache generation coherence hold across all config variants; numeric namespaces work consistently. | -| C | Correct R06–R09 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries pass; 2.1.1/3.0 and absent/unbound compositions pass without host ownership changes. | +| C | Correct R06–R11 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries, median overflow and dynamic-method visibility pass; 2.1.1/3.0 and absent/unbound compositions pass without host ownership changes. | | D | Run quality, performance, persistent-worker and final-revision CI acceptance. | Every applicable repository-owned gate below is satisfied and reported accurately. | Expected public API additions: **zero**. Expected new production classes/interfaces/enums: **zero**. Keep behavior in the current cohesive owners, prefer private helpers only where they reduce actual duplication or make a safety boundary clear, and do not split types just to evade complexity limits. Any necessary departure must identify its concrete contract/value and measured cost before being included. Public signatures, named arguments, synchronous defaults, supported PHP versions and normal return/key ordering remain compatibility constraints. @@ -159,7 +180,7 @@ Do not bundle unrelated formatter churn, generic serialization infrastructure, f ## Release acceptance -- [ ] Regressions reproduce each confirmed trigger on `5.3.1` and pass after its correction. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. +- [ ] Regressions reproduce all R01–R11 triggers on `5.3.1` and pass after their corrections. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. - [ ] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. - [ ] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. - [ ] Make the optional integration a reproducible Composer matrix for 2.1.1 and 3.0, rather than relying only on this audit's temporary source-autoloader experiment. @@ -175,6 +196,8 @@ Do not bundle unrelated formatter churn, generic serialization infrastructure, f - Unguarded cyclic/reference-containing membership/fingerprint inputs remain outside the current documented contract. An isolated cyclic `diff()` probe succeeded below the lookup threshold and exhausted a 32 MiB process at the optimized threshold. This is not counted among R01–R06 because the contract excludes that input; consider deterministic rejection only as a separately scoped hardening change with compatibility/performance tests. - No compatible removal of the benchmark tool's abandoned development dependency was established here; track that upstream without removing benchmarks or weakening the audit. +- `ArraySingle::mode()` currently coerces canonical numeric-string values into integer keys through `array_count_values()`; for example `['1', '1']` yields the integer `1` rather than the original string. Decide whether the public mode contract should distinguish or preserve numeric strings before changing the semantics. This is a follow-up contract decision, not a silent 5.3.2 behavior change. +- Immutable cache generations have no built-in old-generation reclamation. The current documentation assigns retirement to deployment tooling after pinned workers have stopped; ensure the deployment process has a bounded retention/cleanup policy. Do not delete a generation while another worker may still have it pinned. - Broad class/file-count reduction, multibyte text matching, new async APIs and new host-driver adapters have no demonstrated requirement in this audit and should not be added to the patch. -Temporary local audit artifacts use `/tmp/arraykit-audit-*`; they are supporting logs/probes, not committed acceptance tests. The implementation must turn the required reproductions into repository tests before closing R01–R09. +Temporary local audit artifacts use `/tmp/arraykit-audit-*`; they are supporting logs/probes, not committed acceptance tests. The implementation must turn the required reproductions into repository tests before closing R01–R11. From 3a5a3eb05d29549e0c9e390a96d88c159efbfa43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:50:42 +0600 Subject: [PATCH 03/26] test: capture guarded object comparisons and failed move regression --- tests/Feature/Release532BatchATest.php | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/Feature/Release532BatchATest.php diff --git a/tests/Feature/Release532BatchATest.php b/tests/Feature/Release532BatchATest.php new file mode 100644 index 0000000..42b961b --- /dev/null +++ b/tests/Feature/Release532BatchATest.php @@ -0,0 +1,91 @@ +self = $left; + $left->tail = 2; + + $right = new stdClass(); + $right->self = $right; + $right->tail = 1; + + $values = [$left, $right]; + + expect(ArrayMulti::sortRecursiveGuarded($values, options: $options, maxNodes: 2)) + ->toBe($values) + ->and(fn() => ArrayMulti::sortRecursiveGuarded( + $values, + options: $options, + maxNodes: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +})->with([SORT_REGULAR, SORT_NUMERIC, SORT_STRING, SORT_NATURAL, SORT_LOCALE_STRING]); + +it('treats opaque and wide object values as an explicit bounded-sort boundary', function () { + $wide = new stdClass(); + foreach (range(1, 100) as $key) { + $wide->{"field_{$key}"} = $key; + } + + expect(ArrayMulti::sortRecursiveGuarded([3, $wide, 1], maxNodes: 3)) + ->toBe([3, $wide, 1]) + ->and(fn() => ArrayMulti::sortRecursiveGuarded( + [$wide], + maxNodes: 1, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class) + ->and(ArrayMulti::sortRecursiveGuarded([3, 1, 2], maxNodes: 3)) + ->toBe([1, 2, 3]); +}); + +it('keeps the original move source when a destination readonly property rejects assignment', function () { + $target = new class { + public readonly int $locked; + + public function __construct() + { + $this->locked = 7; + } + }; + + $items = ['source' => 42, 'target' => $target]; + + expect(fn() => DotNotation::rename($items, 'source', 'target.locked')) + ->toThrow(InvalidArgumentException::class) + ->and($items['source'])->toBe(42) + ->and($target->locked)->toBe(7); +}); + +it('retains the source after a rejecting magic setter and works through the facade', function () { + $target = new class { + public function __set(string $name, mixed $value): void + { + throw new RuntimeException("Rejected {$name}"); + } + }; + + $items = ['source' => 42, 'target' => $target]; + + expect(fn() => ArrayKit::dot()->move($items, 'source', 'target.locked')) + ->toThrow(RuntimeException::class, 'Rejected') + ->and($items['source'])->toBe(42); +}); + +it('preserves successful parent-child moves and rejects non-overwriting moves without mutation', function () { + $parent = ['a' => ['b' => 1, 'c' => 2]]; + $child = $parent; + $unchanged = ['from' => 1, 'to' => 2]; + + expect(DotNotation::move($parent, 'a', 'a.moved'))->toBeTrue() + ->and($parent)->toBe(['a' => ['moved' => ['b' => 1, 'c' => 2]]]) + ->and(DotNotation::rename($child, 'a.b', 'a'))->toBeTrue() + ->and($child)->toBe(['a' => 1]) + ->and(DotNotation::rename($unchanged, 'from', 'to', overwrite: false))->toBeFalse() + ->and($unchanged)->toBe(['from' => 1, 'to' => 2]); +}); From cc9d5ef3ddca9f24123442ea9ef59ea5120b6543 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:51:04 +0600 Subject: [PATCH 04/26] fix: bound guarded sorts by refusing unbounded object comparisons --- src/Array/Concerns/ArrayMultiQuerySortTrait.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index b876f4c..9d7fbab 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -1256,6 +1256,19 @@ private static function sortRecursiveWithGuards( } foreach ($array as &$value) { + // Object comparison may traverse unbounded public object graphs inside + // PHP's sort implementation. The guarded API deliberately excludes it. + if (is_object($value)) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort cannot safely compare object values.'); + } + + $complete = false; + unset($value); + + return $array; + } + if (!is_array($value)) { continue; } From fec08a34895035fdb27b62ad5f268bb050f26a7c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:51:18 +0600 Subject: [PATCH 05/26] fix: stage dot path move and rename before committing source removal --- src/Array/Concerns/DotNotationPublicApiTrait.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index 1f6b536..4691587 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -373,8 +373,13 @@ public static function rename(array &$array, string $from, string $to, bool $ove } $value = self::get($array, $from); - self::forget($array, $from); - self::set($array, $to, $value, $overwrite); + // Stage array mutations so a rejected destination cannot remove the + // source. Remove first to preserve parent/child path semantics. + // User-defined object setters remain caller-owned side effects. + $staged = $array; + self::forget($staged, $from); + self::set($staged, $to, $value, $overwrite); + $array = $staged; return true; } From 7a934a4a027e84428de6eda40c7cd60cb0d9b677 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:52:06 +0600 Subject: [PATCH 06/26] style: align Batch A regression test closures with project format --- tests/Feature/Release532BatchATest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Release532BatchATest.php b/tests/Feature/Release532BatchATest.php index 42b961b..70ea862 100644 --- a/tests/Feature/Release532BatchATest.php +++ b/tests/Feature/Release532BatchATest.php @@ -19,7 +19,7 @@ expect(ArrayMulti::sortRecursiveGuarded($values, options: $options, maxNodes: 2)) ->toBe($values) - ->and(fn() => ArrayMulti::sortRecursiveGuarded( + ->and(fn () => ArrayMulti::sortRecursiveGuarded( $values, options: $options, maxNodes: 2, @@ -35,7 +35,7 @@ expect(ArrayMulti::sortRecursiveGuarded([3, $wide, 1], maxNodes: 3)) ->toBe([3, $wide, 1]) - ->and(fn() => ArrayMulti::sortRecursiveGuarded( + ->and(fn () => ArrayMulti::sortRecursiveGuarded( [$wide], maxNodes: 1, throwOnTooDeep: true, @@ -56,7 +56,7 @@ public function __construct() $items = ['source' => 42, 'target' => $target]; - expect(fn() => DotNotation::rename($items, 'source', 'target.locked')) + expect(fn () => DotNotation::rename($items, 'source', 'target.locked')) ->toThrow(InvalidArgumentException::class) ->and($items['source'])->toBe(42) ->and($target->locked)->toBe(7); @@ -72,7 +72,7 @@ public function __set(string $name, mixed $value): void $items = ['source' => 42, 'target' => $target]; - expect(fn() => ArrayKit::dot()->move($items, 'source', 'target.locked')) + expect(fn () => ArrayKit::dot()->move($items, 'source', 'target.locked')) ->toThrow(RuntimeException::class, 'Rejected') ->and($items['source'])->toBe(42); }); From c47a2bb5865013e29b875c36dd6aa75751efff43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:54:56 +0600 Subject: [PATCH 07/26] refactor: isolate guarded object-sort rejection to respect complexity limit --- .../Concerns/ArrayMultiQuerySortTrait.php | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 9d7fbab..5027eb2 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -1227,6 +1227,23 @@ private static function selectExtremeValue(array $array, string|callable $keyOrC return $selected; } + /** + * PHP comparisons may recursively walk unbounded object graphs. Object + * values therefore fall outside the guarded sort's supported domain. + */ + private static function hasUnsafeSortObject(mixed $value, bool $throwOnTooDeep): bool + { + if (!is_object($value)) { + return false; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort cannot safely compare object values.'); + } + + return true; + } + /** * @param array $array * @return array @@ -1256,13 +1273,7 @@ private static function sortRecursiveWithGuards( } foreach ($array as &$value) { - // Object comparison may traverse unbounded public object graphs inside - // PHP's sort implementation. The guarded API deliberately excludes it. - if (is_object($value)) { - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort cannot safely compare object values.'); - } - + if (self::hasUnsafeSortObject($value, $throwOnTooDeep)) { $complete = false; unset($value); From 6b26398ce6eb2e95782435d41dfbdd02351f2bdf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:55:05 +0600 Subject: [PATCH 08/26] test: use rejecting setter argument to satisfy strict PHPCS --- tests/Feature/Release532BatchATest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Release532BatchATest.php b/tests/Feature/Release532BatchATest.php index 70ea862..85216cc 100644 --- a/tests/Feature/Release532BatchATest.php +++ b/tests/Feature/Release532BatchATest.php @@ -66,7 +66,7 @@ public function __construct() $target = new class { public function __set(string $name, mixed $value): void { - throw new RuntimeException("Rejected {$name}"); + throw new RuntimeException("Rejected {$name} value {$value}"); } }; From d22359b482231d28ac87d8b83965eae245f248d6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 08:56:49 +0600 Subject: [PATCH 09/26] style: order guarded sort helper methods as required by Pint --- .../Concerns/ArrayMultiQuerySortTrait.php | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 5027eb2..a9d2caa 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -1042,6 +1042,23 @@ private static function formatNumericResult(float|int|null $value): float|int|nu return $value; } + /** + * PHP comparisons may recursively walk unbounded object graphs. Object + * values therefore fall outside the guarded sort's supported domain. + */ + private static function hasUnsafeSortObject(mixed $value, bool $throwOnTooDeep): bool + { + if (!is_object($value)) { + return false; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort cannot safely compare object values.'); + } + + return true; + } + private static function invokeRowCallback(callable $callback, mixed $row, int|string $key): mixed { return $callback($row, $key); @@ -1227,23 +1244,6 @@ private static function selectExtremeValue(array $array, string|callable $keyOrC return $selected; } - /** - * PHP comparisons may recursively walk unbounded object graphs. Object - * values therefore fall outside the guarded sort's supported domain. - */ - private static function hasUnsafeSortObject(mixed $value, bool $throwOnTooDeep): bool - { - if (!is_object($value)) { - return false; - } - - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort cannot safely compare object values.'); - } - - return true; - } - /** * @param array $array * @return array From 7a95306728850a3f013db4d328a769faf9d21831 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:00:24 +0600 Subject: [PATCH 10/26] fix: correct sampling, median, NaN, enum, and collection dispatch boundaries --- src/Array/ArraySingle.php | 16 +++-- src/Array/BaseArrayHelper.php | 8 +-- .../Concerns/BaseCollectionTrait.php | 2 +- src/Config/Concerns/BaseConfigTrait.php | 2 +- tests/Feature/Release532BatchCTest.php | 70 +++++++++++++++++++ 5 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/Release532BatchCTest.php diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index c81bc55..4fba9ca 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -312,7 +312,7 @@ public static function isNegative(array $array): bool } $hasNumeric = true; - if ($numeric >= 0) { + if (!($numeric < 0)) { return false; } } @@ -337,7 +337,7 @@ public static function isPositive(array $array): bool } $hasNumeric = true; - if ($numeric <= 0) { + if (!($numeric > 0)) { return false; } } @@ -453,9 +453,15 @@ public static function median(array $array): float|int $count = count($values); $mid = intdiv($count, 2); - return ($count % 2) - ? $values[$mid] - : ($values[$mid - 1] + $values[$mid]) / 2; + if (($count % 2) !== 0) { + return $values[$mid]; + } + + $left = $values[$mid - 1]; + $right = $values[$mid]; + $sum = $left + $right; + + return is_finite((float) $sum) ? $sum / 2 : $left / 2 + $right / 2; } /** diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 7414f6b..5e6063f 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -223,6 +223,10 @@ public static function isMultiDimensional(mixed $array): bool public static function random(array $array, ?int $number = null, bool $preserveKeys = false): mixed { $count = count($array); + if ($number !== null && $number > $count) { + throw new InvalidArgumentException("You requested $number items, but array only has $count."); + } + if ($count === 0 || ($number !== null && $number <= 0)) { return ($number === null) ? null : []; } @@ -233,10 +237,6 @@ public static function random(array $array, ?int $number = null, bool $preserveK return $array[$randKey]; } - if ($number > $count) { - throw new InvalidArgumentException("You requested $number items, but array only has $count."); - } - $keys = (array) array_rand($array, $number); $picked = array_intersect_key($array, array_flip($keys)); diff --git a/src/Collection/Concerns/BaseCollectionTrait.php b/src/Collection/Concerns/BaseCollectionTrait.php index c6b7b66..019ebb4 100644 --- a/src/Collection/Concerns/BaseCollectionTrait.php +++ b/src/Collection/Concerns/BaseCollectionTrait.php @@ -51,7 +51,7 @@ public function __construct(array $data = []) public function __call(string $method, array $arguments): mixed { $pipeline = $this->process(); - if (method_exists($pipeline, $method)) { + if (is_callable([$pipeline, $method])) { return $pipeline->$method(...$arguments); } diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index e85e102..31f1d02 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -195,7 +195,7 @@ public function getEnum(string|int $key, string $enumClass, ?\UnitEnum $default } if (is_subclass_of($enumClass, \BackedEnum::class)) { - if (!is_string($value) && !is_int($value)) { + if (get_debug_type($value) !== (new \ReflectionEnum($enumClass))->getBackingType()?->getName()) { return $default; } diff --git a/tests/Feature/Release532BatchCTest.php b/tests/Feature/Release532BatchCTest.php new file mode 100644 index 0000000..cbf8a64 --- /dev/null +++ b/tests/Feature/Release532BatchCTest.php @@ -0,0 +1,70 @@ + BaseArrayHelper::random([], 1))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArrayKit::helper()->random([], 2))->toThrow(InvalidArgumentException::class) + ->and(BaseArrayHelper::random([]))->toBeNull() + ->and(BaseArrayHelper::random([], 0))->toBe([]); +}); + +it('does not assign either sign to NaN while retaining finite and infinite signs', function () { + expect(ArraySingle::isPositive([NAN]))->toBeFalse() + ->and(ArraySingle::isNegative([NAN]))->toBeFalse() + ->and(ArrayKit::single()->isPositive([1, NAN]))->toBeFalse() + ->and(ArrayKit::single()->isNegative([-1, NAN]))->toBeFalse() + ->and(ArraySingle::isPositive([INF]))->toBeTrue() + ->and(ArraySingle::isNegative([-INF]))->toBeTrue() + ->and(ArraySingle::isPositive([1, 'ignored']))->toBeTrue(); +}); + +it('keeps finite medians finite after overflow-safe midpoint calculation', function () { + expect(ArraySingle::median([1e308, 1e308]))->toBe(1e308) + ->and(ArrayKit::single()->median([-1e308, -1e308]))->toBe(-1e308) + ->and(ArraySingle::median([-1e308, 1e308]))->toBe(0.0) + ->and(ArraySingle::median([1, 2, 3]))->toBe(2) + ->and(ArraySingle::median([1, 2]))->toBe(1.5); +}); + +it('uses the default for wrong enum backing scalar types without coercion', function () { + $config = new Config(); + $config->loadArray(['int' => '1', 'string' => 1, 'valid_int' => 1, 'valid_string' => '2']); + + expect($config->getEnum('int', Release532IntMode::class, Release532IntMode::Two)) + ->toBe(Release532IntMode::Two) + ->and($config->getEnum('string', Release532StringMode::class, Release532StringMode::Two)) + ->toBe(Release532StringMode::Two) + ->and($config->getEnum('valid_int', Release532IntMode::class)) + ->toBe(Release532IntMode::One) + ->and($config->getEnum('valid_string', Release532StringMode::class)) + ->toBe(Release532StringMode::Two); +}); + +it('forwards only callable public pipeline methods through collections', function () { + $collection = Collection::make([3, 1, 2]); + + expect(fn () => $collection->numericAggregate(null, true)) + ->toThrow(BadMethodCallException::class) + ->and(fn () => $collection->unrecognizedInternalMethod()) + ->toThrow(BadMethodCallException::class) + ->and($collection->values()->all())->toBe([3, 1, 2]); +}); From b3cf8ddcab6631e7ab5dba37ab0c4fa68b12501f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:02:22 +0600 Subject: [PATCH 11/26] fix: satisfy enum contract analysis and move test enums into fixtures --- src/Config/Concerns/BaseConfigTrait.php | 6 +++++- tests/Feature/Release532BatchCTest.php | 14 ++------------ tests/Fixtures/Release532IntMode.php | 11 +++++++++++ tests/Fixtures/Release532StringMode.php | 11 +++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 tests/Fixtures/Release532IntMode.php create mode 100644 tests/Fixtures/Release532StringMode.php diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 31f1d02..81e5565 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -195,7 +195,11 @@ public function getEnum(string|int $key, string $enumClass, ?\UnitEnum $default } if (is_subclass_of($enumClass, \BackedEnum::class)) { - if (get_debug_type($value) !== (new \ReflectionEnum($enumClass))->getBackingType()?->getName()) { + if (!is_string($value) && !is_int($value)) { + return $default; + } + + if (get_debug_type($value) !== new \ReflectionEnum($enumClass)->getBackingType()?->getName()) { return $default; } diff --git a/tests/Feature/Release532BatchCTest.php b/tests/Feature/Release532BatchCTest.php index cbf8a64..bec3593 100644 --- a/tests/Feature/Release532BatchCTest.php +++ b/tests/Feature/Release532BatchCTest.php @@ -7,18 +7,8 @@ use Infocyph\ArrayKit\ArrayKit; use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Config\Config; - -enum Release532IntMode: int -{ - case One = 1; - case Two = 2; -} - -enum Release532StringMode: string -{ - case One = '1'; - case Two = '2'; -} +use Infocyph\ArrayKit\Tests\Fixtures\Release532IntMode; +use Infocyph\ArrayKit\Tests\Fixtures\Release532StringMode; it('validates positive random counts even for empty arrays', function () { expect(fn () => BaseArrayHelper::random([], 1))->toThrow(InvalidArgumentException::class) diff --git a/tests/Fixtures/Release532IntMode.php b/tests/Fixtures/Release532IntMode.php new file mode 100644 index 0000000..c425316 --- /dev/null +++ b/tests/Fixtures/Release532IntMode.php @@ -0,0 +1,11 @@ + Date: Fri, 30 Oct 2026 09:03:26 +0600 Subject: [PATCH 12/26] docs: define guarded sort and staged move behavior; sync 5.3.2 progress --- arraykit-5.3.2-review-and-release-plan.md | 11 +++++++++++ docs/array-helpers.rst | 3 ++- docs/dot-notation.rst | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 44002fd..eb946fe 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -178,6 +178,17 @@ Expected public API additions: **zero**. Expected new production classes/interfa Do not bundle unrelated formatter churn, generic serialization infrastructure, framework adapters or a runtime rewrite into these fixes. Review processor output and retain every active detector; no suppressions, baselines, exclusions or threshold increases. +## Branch implementation status + +This is a rolling implementation tracker, not a release sign-off. The 5.3.1 baseline evidence earlier in this plan does not certify the current PR revision. Run the full final-revision matrix, release acceptance, and performance/worker checks before marking any batch release-complete. + +| Batch | Status at review update | Remaining work | +| --- | --- | --- | +| A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | +| B — R03–R05 | Not implemented. | Snapshot alias isolation; cache-generation refresh; numeric namespace normalization and regressions. | +| C — R06–R11 | R06, R08, R09, R10, R11 implementation and regressions committed. | R07 overflow-safe averaging, optional Runwire 2.1.1/3.0 Composer matrix, additional lifecycle tests, green final-revision QA. | +| D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | + ## Release acceptance - [ ] Regressions reproduce all R01–R11 triggers on `5.3.1` and pass after their corrections. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index fa02073..07cc5d8 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -358,7 +358,8 @@ Behavior Notes the two-argument shorthand form. - ``ArrayMulti::flatten($array, 0)`` returns unchanged top-level values. - Use ``depthGuarded()``, ``flattenGuarded()``, and ``sortRecursiveGuarded()`` when processing untrusted/deep inputs. -- A non-throwing recursive sort retains the order of a parent whose child traversal was cut short, instead of comparing unvisited nested values. Throwing mode rejects the limit breach. +- Guarded recursive sorting accepts scalar/array values only. It refuses object values, including objects nested inside arrays, because PHP sorting may compare arbitrarily deep public object graphs outside a traversal budget. Normalize object data before guarded sorting, or use ordinary ``sortRecursive()`` only for trusted inputs. +- A non-throwing recursive sort retains the affected array order when traversal cannot be completed or object values are rejected. Throwing mode rejects the limit breach or object comparison with ``RuntimeException``. - ``ArrayMulti`` callback helpers such as ``sortBy()``, ``sum()``, ``maxBy()``, ``minBy()`` support ``($row, $key)``. - In ``string|callable`` row APIs, strings always identify fields; use a closure or another non-string callable for callback behavior. diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst index bc8f49e..25ec314 100644 --- a/docs/dot-notation.rst +++ b/docs/dot-notation.rst @@ -281,6 +281,10 @@ Behavior Notes - Object writes support ``stdClass`` additions, existing public writable properties, and magic ``__set`` handlers. Missing ordinary properties and inaccessible or readonly properties fail with ``InvalidArgumentException``. +- ``rename()`` and ``move()`` stage array changes before committing source + removal. A destination-write failure retains the original array source. + Magic setters and other application-owned object methods can have external + side effects and are not transactionally rolled back. - Defaults may be plain values or ``Closure`` instances. Closures are evaluated only when path resolution fails; other callable values are returned unchanged. - Wildcard traversal in ``get`` returns arrays of matched results. From 1d0040bf6b00525b1e73c3e8cedddcb99f044a66 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:04:32 +0600 Subject: [PATCH 13/26] fix: calculate overflow-safe averages and guard integer casts --- arraykit-5.3.2-review-and-release-plan.md | 2 +- src/Array/ArraySingle.php | 40 ++++++++++++++++++++++- tests/Feature/Release532BatchCTest.php | 10 ++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index eb946fe..52ec017 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -186,7 +186,7 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | --- | --- | --- | | A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | | B — R03–R05 | Not implemented. | Snapshot alias isolation; cache-generation refresh; numeric namespace normalization and regressions. | -| C — R06–R11 | R06, R08, R09, R10, R11 implementation and regressions committed. | R07 overflow-safe averaging, optional Runwire 2.1.1/3.0 Composer matrix, additional lifecycle tests, green final-revision QA. | +| C — R06–R11 | R06–R11 implementation and regressions committed. | Optional Runwire 2.1.1/3.0 Composer matrix, additional lifecycle tests, green final-revision QA. | | D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | ## Release acceptance diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index 4fba9ca..d4799cc 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -38,8 +38,22 @@ public static function avg(array $array): float|int } $avg = $total / $count; + if (!is_finite($avg)) { + $avg = self::overflowSafeAverage($array, $count, $avg); + } + + // PHP_INT_MAX rounds up when converted to float. Use the exact, + // representable lower bound and its positive exclusive counterpart. + if ( + !is_finite($avg) + || $avg < (float) PHP_INT_MIN + || $avg >= -(float) PHP_INT_MIN + || fmod($avg, 1.0) !== 0.0 + ) { + return $avg; + } - return fmod($avg, 1.0) === 0.0 ? (int) $avg : $avg; + return (int) $avg; } /** @@ -1023,6 +1037,30 @@ private static function normalizeArrayKey(mixed $value): int|string return ArraySharedOps::normalizeArrayKey($value); } + /** + * Recompute only on overflow; normal finite inputs retain the fast path. + * + * @param array $array + */ + private static function overflowSafeAverage(array $array, int $count, float $fallback): float + { + $average = 0.0; + foreach ($array as $value) { + $numeric = self::toNumericOrNull($value); + if ($numeric === null) { + continue; + } + + if (!is_finite((float) $numeric)) { + return $fallback; + } + + $average += $numeric / $count; + } + + return $average; + } + private static function requireArrayKey(mixed $value, string $operation): int|string { if (is_int($value) || is_string($value)) { diff --git a/tests/Feature/Release532BatchCTest.php b/tests/Feature/Release532BatchCTest.php index bec3593..7e3332c 100644 --- a/tests/Feature/Release532BatchCTest.php +++ b/tests/Feature/Release532BatchCTest.php @@ -49,6 +49,16 @@ ->toBe(Release532StringMode::Two); }); +it('preserves finite averages across intermediate overflow and float-to-int boundaries', function () { + expect(ArraySingle::avg([1e308, 1e308]))->toBe(1e308) + ->and(ArraySingle::avg([-1e308, -1e308]))->toBe(-1e308) + ->and(ArrayKit::single()->avg([PHP_INT_MAX, PHP_INT_MAX]))->toBeFloat() + ->and(ArraySingle::avg([PHP_INT_MAX, PHP_INT_MAX]) > 0)->toBeTrue() + ->and(ArraySingle::avg([1e20]))->toBe(1e20) + ->and(ArraySingle::avg([10, 20, 'ignored']))->toBe(15) + ->and(is_nan(ArraySingle::avg([INF, -INF])))->toBeTrue(); +}); + it('forwards only callable public pipeline methods through collections', function () { $collection = Collection::make([3, 1, 2]); From f72f8271910dbad20f83e1b6b261726dc80e5846 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:06:03 +0600 Subject: [PATCH 14/26] ci: qualify optional Runwire 2.1.1 and 3.0 across PHP 8.4/8.5 --- .github/workflows/security-standards.yml | 53 +++++++++++++++++++++++ README.md | 2 +- arraykit-5.3.2-review-and-release-plan.md | 2 +- composer.json | 4 +- docs/collection.rst | 5 ++- src/Collection/RunwireLazyBinding.php | 2 +- 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 5eecba0..cc7b1cc 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -34,6 +34,59 @@ jobs: actions: read contents: read + runwire-compat: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') + name: "Runwire ${{ matrix.runwire }} / PHP ${{ matrix.php }}" + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + runwire: ["2.1.1", "3.0.0"] + php: ["8.4", "8.5"] + env: + XDEBUG_MODE: off + steps: + - uses: actions/checkout@v7 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: opcache + ini-values: opcache.enable_cli=1 + coverage: none + - name: Install exact optional Runwire version + run: composer require --dev "infocyph/runwire:${{ matrix.runwire }}" --no-interaction --prefer-dist --no-progress + - name: Exercise lazy pipeline and host-owned coroutine lifecycle + run: >- + php vendor/bin/pest + --configuration vendor/infocyph/phpforge/resources/pest.xml + --bootstrap vendor/autoload.php + tests/Feature/LazyCollectionTest.php + tests/Feature/Release530BatchETest.php + tests/Feature/Release530ReviewRegressionTest.php + tests/Feature/Release530BatchFTest.php + - name: Exercise persistent worker under exact Runwire version + if: matrix.php == '8.5' + run: | + mkdir -p build + composer ic:soak:worker \ + --duration=300 \ + --warmup=10 \ + --sample-interval=1 \ + --max-growth-mb=16 \ + --report=build/runwire-worker-soak.json \ + -- php -d opcache.enable_cli=1 tests/Support/release530-persistent-worker.php + - name: Upload version-specific worker-soak report + if: always() && matrix.php == '8.5' && hashFiles('build/runwire-worker-soak.json') != '' + uses: actions/upload-artifact@v7 + with: + name: runwire-${{ matrix.runwire }}-php-${{ matrix.php }}-worker-soak + path: build/runwire-worker-soak.json + if-no-files-found: error + retention-days: 61 + release-acceptance: if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') name: "Release Acceptance" diff --git a/README.md b/README.md index 47e1d6c..73a4da6 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ configuration or object models. * **PHP 8.4** or higher -Runwire is optional. ArrayKit 5.3 tests its lazy-runtime integration against `infocyph/runwire` 2.1.1; ordinary ArrayKit installation has no Runwire runtime dependency. +Runwire is optional. ArrayKit 5.3.2 qualifies its passed-instance lazy-runtime integration against `infocyph/runwire` 2.1.1 and 3.0 in separate CI jobs; ordinary ArrayKit installation has no Runwire runtime dependency. ## Installation diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 52ec017..8e8badc 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -186,7 +186,7 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | --- | --- | --- | | A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | | B — R03–R05 | Not implemented. | Snapshot alias isolation; cache-generation refresh; numeric namespace normalization and regressions. | -| C — R06–R11 | R06–R11 implementation and regressions committed. | Optional Runwire 2.1.1/3.0 Composer matrix, additional lifecycle tests, green final-revision QA. | +| C — R06–R11 | R06–R11 implementation and regressions committed. Optional exact-version Runwire 2.1.1/3.0 Composer CI matrix configured. | Final-version matrix results, additional lifecycle/absent-install verification, green final-revision QA. | | D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | ## Release acceptance diff --git a/composer.json b/composer.json index 07bcb13..8094842 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,10 @@ }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "infocyph/runwire": "2.1.1" + "infocyph/runwire": "^2.1.1 || ^3.0" }, "suggest": { - "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; ArrayKit 5.3 is tested against Runwire 2.1.1." + "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; integration tested against Runwire 2.1.1 and 3.0." }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/docs/collection.rst b/docs/collection.rst index d46acce..8b5e1d9 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -310,8 +310,9 @@ renewable sources when each traversal can create a fresh iterable. Optional Runwire Binding ------------------------ -Runwire is optional. Ordinary ``LazyCollection`` use does not require or load -Runwire. When a host already owns a Runwire runtime, pass those exact instances +Runwire is optional. The 5.3.2 compatibility matrix exercises Runwire 2.1.1 +and 3.0 independently. Ordinary ``LazyCollection`` use does not require or +load Runwire. When a host already owns a Runwire runtime, pass those exact instances to ``withRunwire()``: .. code-block:: php diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php index 19219ed..e78589e 100644 --- a/src/Collection/RunwireLazyBinding.php +++ b/src/Collection/RunwireLazyBinding.php @@ -77,7 +77,7 @@ private function assertActive(): void } if ($this->scope !== null && $this->scopeCheckKey !== null) { - // Runwire 2.1.1 guards this read without yielding or changing task-local state. + // Supported Runwire versions guard this read without yielding or changing task-local state. $this->scope->hasLocal($this->scopeCheckKey); } } From 3fc54c199d37acc2a61836db86f747e3794a9445 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:07:20 +0600 Subject: [PATCH 15/26] refactor: keep overflow-safe averaging in existing numeric ops owner --- src/Array/ArraySingle.php | 59 +------------------------------- src/Array/ArraySingleOps.php | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index d4799cc..fa7c0ac 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -20,40 +20,7 @@ class ArraySingle */ public static function avg(array $array): float|int { - $total = 0; - $count = 0; - - foreach ($array as $value) { - $numeric = self::toNumericOrNull($value); - if ($numeric === null) { - continue; - } - - $total += $numeric; - $count++; - } - - if ($count === 0) { - return 0.0; - } - - $avg = $total / $count; - if (!is_finite($avg)) { - $avg = self::overflowSafeAverage($array, $count, $avg); - } - - // PHP_INT_MAX rounds up when converted to float. Use the exact, - // representable lower bound and its positive exclusive counterpart. - if ( - !is_finite($avg) - || $avg < (float) PHP_INT_MIN - || $avg >= -(float) PHP_INT_MIN - || fmod($avg, 1.0) !== 0.0 - ) { - return $avg; - } - - return (int) $avg; + return ArraySingleOps::avg($array); } /** @@ -1037,30 +1004,6 @@ private static function normalizeArrayKey(mixed $value): int|string return ArraySharedOps::normalizeArrayKey($value); } - /** - * Recompute only on overflow; normal finite inputs retain the fast path. - * - * @param array $array - */ - private static function overflowSafeAverage(array $array, int $count, float $fallback): float - { - $average = 0.0; - foreach ($array as $value) { - $numeric = self::toNumericOrNull($value); - if ($numeric === null) { - continue; - } - - if (!is_finite((float) $numeric)) { - return $fallback; - } - - $average += $numeric / $count; - } - - return $average; - } - private static function requireArrayKey(mixed $value, string $operation): int|string { if (is_int($value) || is_string($value)) { diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index 42058b9..be9f5a7 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -7,6 +7,47 @@ /** @internal */ final class ArraySingleOps { + /** + * @param array $array + */ + public static function avg(array $array): float|int + { + $total = 0; + $count = 0; + + foreach ($array as $value) { + $numeric = self::numericValue($value); + if ($numeric === null) { + continue; + } + + $total += $numeric; + $count++; + } + + if ($count === 0) { + return 0.0; + } + + $avg = $total / $count; + if (!is_finite($avg)) { + $avg = self::overflowSafeAverage($array, $count, $avg); + } + + // PHP_INT_MAX rounds up when converted to float. Use the exact, + // representable lower bound and its positive exclusive counterpart. + if ( + !is_finite($avg) + || $avg < (float) PHP_INT_MIN + || $avg >= -(float) PHP_INT_MIN + || fmod($avg, 1.0) !== 0.0 + ) { + return $avg; + } + + return (int) $avg; + } + /** * @param array $array * @param array $needles @@ -176,6 +217,30 @@ private static function invokeValueCallback(callable $callback, mixed $value, in /** * @param array $array */ + /** + * Recompute only on overflow; normal finite inputs retain the fast path. + * + * @param array $array + */ + private static function overflowSafeAverage(array $array, int $count, float $fallback): float + { + $average = 0.0; + foreach ($array as $value) { + $numeric = self::numericValue($value); + if ($numeric === null) { + continue; + } + + if (!is_finite((float) $numeric)) { + return $fallback; + } + + $average += $numeric / $count; + } + + return $average; + } + private static function pickBy(array $array, callable $callback, bool $pickMax): mixed { $best = null; From 1af66ea83a2675520e6130ad0ce51b61d1f60e8a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:11:32 +0600 Subject: [PATCH 16/26] chore: target only Runwire 3.0 and repair numeric helper docblock --- .github/workflows/security-standards.yml | 13 ++++++------- README.md | 2 +- arraykit-5.3.2-review-and-release-plan.md | 18 +++++++++--------- composer.json | 4 ++-- docs/collection.rst | 6 +++--- docs/release-5.3.0.rst | 6 +++--- src/Array/ArraySingleOps.php | 6 +++--- src/Collection/RunwireLazyBinding.php | 2 +- 8 files changed, 28 insertions(+), 29 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index cc7b1cc..65741b2 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -36,13 +36,12 @@ jobs: runwire-compat: if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') - name: "Runwire ${{ matrix.runwire }} / PHP ${{ matrix.php }}" + name: "Runwire 3.0 / PHP ${{ matrix.php }}" runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - runwire: ["2.1.1", "3.0.0"] php: ["8.4", "8.5"] env: XDEBUG_MODE: off @@ -56,8 +55,8 @@ jobs: extensions: opcache ini-values: opcache.enable_cli=1 coverage: none - - name: Install exact optional Runwire version - run: composer require --dev "infocyph/runwire:${{ matrix.runwire }}" --no-interaction --prefer-dist --no-progress + - name: Install exact Runwire 3.0 + run: composer require --dev "infocyph/runwire:3.0.0" --no-interaction --prefer-dist --no-progress - name: Exercise lazy pipeline and host-owned coroutine lifecycle run: >- php vendor/bin/pest @@ -67,7 +66,7 @@ jobs: tests/Feature/Release530BatchETest.php tests/Feature/Release530ReviewRegressionTest.php tests/Feature/Release530BatchFTest.php - - name: Exercise persistent worker under exact Runwire version + - name: Exercise persistent worker with Runwire 3.0 if: matrix.php == '8.5' run: | mkdir -p build @@ -78,11 +77,11 @@ jobs: --max-growth-mb=16 \ --report=build/runwire-worker-soak.json \ -- php -d opcache.enable_cli=1 tests/Support/release530-persistent-worker.php - - name: Upload version-specific worker-soak report + - name: Upload Runwire 3.0 worker-soak report if: always() && matrix.php == '8.5' && hashFiles('build/runwire-worker-soak.json') != '' uses: actions/upload-artifact@v7 with: - name: runwire-${{ matrix.runwire }}-php-${{ matrix.php }}-worker-soak + name: runwire-3.0-php-${{ matrix.php }}-worker-soak path: build/runwire-worker-soak.json if-no-files-found: error retention-days: 61 diff --git a/README.md b/README.md index 73a4da6..85fcc96 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ configuration or object models. * **PHP 8.4** or higher -Runwire is optional. ArrayKit 5.3.2 qualifies its passed-instance lazy-runtime integration against `infocyph/runwire` 2.1.1 and 3.0 in separate CI jobs; ordinary ArrayKit installation has no Runwire runtime dependency. +Runwire is optional. ArrayKit 5.3.2 qualifies its passed-instance lazy-runtime integration against `infocyph/runwire` 3.0 on PHP 8.4 and 8.5; ordinary ArrayKit installation has no Runwire runtime dependency. ## Installation diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 8e8badc..1f4c310 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -8,9 +8,9 @@ This plan follows the [PHPForge engineering principles](https://github.com/infoc ## Release decision and scope -Recommend **5.3.2**, a patch containing the eleven identified corrections (R01–R09 from the original audit and R10–R11 from the supplemental review) and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. Supporting another compatible optional provider version does not require a new ArrayKit API or an ArrayKit major release. +Recommend **5.3.2**, a patch containing the eleven identified corrections (R01–R09 from the original audit and R10–R11 from the supplemental review) and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. The optional integration target is Runwire 3.0 only, without adding an ArrayKit API. -Use **5.4.0** only if implementation introduces an intentional new public capability or API beyond these corrections. Do not remove Runwire 2.1.1 support, raise PHP 8.4, change named arguments, or turn Runwire into a mandatory runtime dependency as part of this patch. +Use **5.4.0** only if implementation introduces an intentional new public capability or API beyond these corrections. Retire legacy Runwire compatibility claims and test jobs; target only Runwire ^3.0 for optional integration. Do not raise PHP 8.4, change named arguments, or turn Runwire into a mandatory runtime dependency as part of this patch. Scope is ArrayKit's code, tests, docs, Composer constraints, benchmark fixtures and CI. Foundation adapters and qualification of Runwire's HTTP protocols, native workers, transports or host drivers are separate projects and are not ArrayKit release blockers for this work. @@ -26,7 +26,7 @@ Scope is ArrayKit's code, tests, docs, Composer constraints, benchmark fixtures | Live locked dependency audit | Zero published advisories; development-only `doctrine/annotations` remains abandoned through benchmark tooling. Raw Composer exits nonzero for abandonment; this is distinct from an advisory and is non-blocking under existing PHPForge policy. ArrayKit has no third-party runtime package requirements. | | Exact-source CI | [Security & Standards run 40161657499](https://github.com/infocyph/ArrayKit/actions/runs/40161657499) succeeded for `e77bf72…`: PHP 8.4/8.5 analysis, stable/lowest QA, clean installation, benchmarks, security report and Release Acceptance. Recent Dependabot-only runs were not counted as QA evidence. | | Runwire 3.0 publication | Live [Composer metadata](https://repo.packagist.org/p2/infocyph/runwire.json) and the [3.0 source tag](https://github.com/infocyph/Runwire/tree/3.0) identify `054737bc6a8d4025a8d05fabb8ad390f66ee47ae`, published 2026-10-29. PHP and 64-bit PHP remain `^8.4`. | -| Runwire 3.0 composition | All 401 tests / 8,797 assertions passed using the exact published source in an isolated `/tmp` autoloader. Reflection confirmed that runtime and coroutine classes came from that source, not installed 2.1.1. This is runtime/source compatibility evidence, not a clean Composer matrix or a native host certification. | +| Runwire 3.0 composition | All 401 tests / 8,797 assertions passed using the exact published source in an isolated `/tmp` autoloader. Reflection confirmed that runtime and coroutine classes came from the published 3.0 source, not the locally installed package. This is runtime/source compatibility evidence, not a clean Composer matrix or a native host certification. | | Runwire 3.0 worker soak | The existing persistent CLI harness passed with the published 3.0 source: 310.06 seconds including warm-up, 300 samples, initial/peak RSS 51.39 MiB and 0.00 MiB growth under the unchanged 16 MiB ceiling. It exercises a CLI worker with capability metadata; it does not launch a native RoadRunner host. | | Additional parity probes | 480 strict membership comparisons against native `in_array()` behavior passed across lookup thresholds, mixed scalars, ordinary arrays, object identity, NaN, infinities and signed zero. Escaped dot-path flatten/expand probes passed. | | Adversarial probes | The original audit reproduced R01–R09 outside the green suite. The supplemental branch review additionally identified R10/R11 in source, with independent native PHP probes for the overflow and method-visibility behavior. Repository regression tests must still reproduce both against the baseline. Green baseline tests are insufficient for release acceptance. | @@ -156,11 +156,11 @@ Acceptance: every publicly supported fluent operation remains callable through ` The current production binding consumes `RuntimeContext`, `RequestContext`, `CancellationToken`, `CoroutineScope`, `TaskLocal`, runtime coroutine capability, and their lifecycle/checkpoint methods. Those APIs remain available in the exact 3.0 source and all existing tests pass against them. -1. Expand development compatibility to `^2.1.1 || ^3.0`, with separate matrix jobs forcing exact 2.1.1 and the published 3.0 version. Cover newest supported compatible releases as appropriate without presenting untested versions as certified. +1. Require `^3.0` as the optional development integration target and test exact Runwire 3.0.0 on PHP 8.4/8.5. Do not claim or test support for prior major versions. 2. Update the optional Composer suggestion and integration docs to describe the tested versions. Keep the production dependency set unchanged. 3. Keep `withRunwire($runtime, $request, $scope, $checkpointEvery)` and explicit intermediary forwarding unchanged. No ambient/global runtime discovery, implicit binding, worker startup or event-loop creation. 4. Continue capability-gated yielding and request/scope cancellation/completion checks before source consumption and after resumption. Keep closed/invalid supplied lifecycle errors distinct from the normal missing-capability fallback. Preserve `take(0)` laziness. -5. Exercise genuine host-owned `CoroutineRuntime`/scope tasks, interleaved requests, cancellation/deadlines, fail-fast groups, source/callback errors, early abandonment, rebinding and reference release under both supported Runwire versions. The host completes requests and closes scopes. +5. Exercise genuine host-owned `CoroutineRuntime`/scope tasks, interleaved requests, cancellation/deadlines, fail-fast groups, source/callback errors, early abandonment, rebinding and reference release under Runwire 3.0. The host completes requests and closes scopes. 6. Verify installed/unbound and clean Runwire-absent production installs. Avoid reflection of private scheduler state or version checks on each item. The relevant advantage is retaining cooperative fairness and cancellation while hosts adopt Runwire 3.0's runtime improvements. Array/DTO/config algorithms remain ArrayKit's responsibility. Runwire's outbound HTTP, WebSocket, transport, TLS and worker-pool features do not justify new integration in a pure-data path. Async file APIs do not make executable PHP config `include` asynchronous. Do not add an async config adapter, background worker or offload layer without a concrete caller need and representative throughput evidence. @@ -171,7 +171,7 @@ The relevant advantage is retaining cooperative fairness and cancellation while | --- | --- | --- | | A | Add regressions for R01/R02, then fix guarded comparison and failed move handling. | Unsafe comparisons stop under the declared budget; rejected moves retain their source; ordinary APIs and facade contracts pass. | | B | Add regressions and correct R03–R05 in existing config owners. | Snapshot rollback and source/cache generation coherence hold across all config variants; numeric namespaces work consistently. | -| C | Correct R06–R11 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries, median overflow and dynamic-method visibility pass; 2.1.1/3.0 and absent/unbound compositions pass without host ownership changes. | +| C | Correct R06–R11 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries, median overflow and dynamic-method visibility pass; 3.0 and absent/unbound compositions pass without host ownership changes. | | D | Run quality, performance, persistent-worker and final-revision CI acceptance. | Every applicable repository-owned gate below is satisfied and reported accurately. | Expected public API additions: **zero**. Expected new production classes/interfaces/enums: **zero**. Keep behavior in the current cohesive owners, prefer private helpers only where they reduce actual duplication or make a safety boundary clear, and do not split types just to evade complexity limits. Any necessary departure must identify its concrete contract/value and measured cost before being included. Public signatures, named arguments, synchronous defaults, supported PHP versions and normal return/key ordering remain compatibility constraints. @@ -186,7 +186,7 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | --- | --- | --- | | A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | | B — R03–R05 | Not implemented. | Snapshot alias isolation; cache-generation refresh; numeric namespace normalization and regressions. | -| C — R06–R11 | R06–R11 implementation and regressions committed. Optional exact-version Runwire 2.1.1/3.0 Composer CI matrix configured. | Final-version matrix results, additional lifecycle/absent-install verification, green final-revision QA. | +| C — R06–R11 | R06–R11 implementation and regressions committed. Optional Runwire 3.0 Composer CI checks on PHP 8.4/8.5 configured. | Final-version matrix results, additional lifecycle/absent-install verification, green final-revision QA. | | D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | ## Release acceptance @@ -194,8 +194,8 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base - [ ] Regressions reproduce all R01–R11 triggers on `5.3.1` and pass after their corrections. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. - [ ] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. - [ ] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. -- [ ] Make the optional integration a reproducible Composer matrix for 2.1.1 and 3.0, rather than relying only on this audit's temporary source-autoloader experiment. -- [ ] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with both supported Runwire versions and correct host cleanup. Do not weaken the ceiling to pass a test. +- [ ] Make optional Runwire 3.0 verification reproducible on PHP 8.4 and 8.5 using Composer, rather than relying only on the original audit's temporary source-autoloader experiment. +- [ ] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with Runwire 3.0 and correct host cleanup. Do not weaken the ceiling to pass a test. - [ ] Use **5.3.1** as the before/after performance baseline for this patch. The current representative harness hardcodes 5.2.0; update the benchmark input/baseline selection as part of release evidence, not production behavior. Validate equivalent correct outputs before comparing throughput. - [ ] Measure changed hot paths, config cold/warm/generated reads and snapshot cost, bounded failures, array/reference/one-shot replay, and bound/unbound Runwire pipelines. Include input-size scaling and a host-owned yielding workload so fairness/cancellation are measured, not merely synchronous materialization. - [ ] Keep component evidence labelled component evidence. For an application-RPM claim, use an ArrayKit-owned minimal request/task consumer with warmed OPcache and optimized production loading, sustained synchronized measurement windows, several concurrency levels, at least three trials, validated successful RPM, p50/p95/p99, errors/timeouts, CPU and peak/steady RSS. Exclude setup/warm-up/cleanup from the steady-state denominator. Record exact source IDs, PHP/extensions/INI, OS/hardware, datasets, variance, queue/task state and runner stability. diff --git a/composer.json b/composer.json index 8094842..12fa277 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,10 @@ }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "infocyph/runwire": "^2.1.1 || ^3.0" + "infocyph/runwire": "^3.0" }, "suggest": { - "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; integration tested against Runwire 2.1.1 and 3.0." + "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; integration tested against Runwire 3.0." }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/docs/collection.rst b/docs/collection.rst index 8b5e1d9..bd931e2 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -310,9 +310,9 @@ renewable sources when each traversal can create a fresh iterable. Optional Runwire Binding ------------------------ -Runwire is optional. The 5.3.2 compatibility matrix exercises Runwire 2.1.1 -and 3.0 independently. Ordinary ``LazyCollection`` use does not require or -load Runwire. When a host already owns a Runwire runtime, pass those exact instances +Runwire is optional. ArrayKit 5.3.2 targets Runwire 3.0 and tests the +integration on PHP 8.4 and 8.5. Ordinary ``LazyCollection`` use does not +require or load Runwire. When a host already owns a Runwire runtime, pass those exact instances to ``withRunwire()``: .. code-block:: php diff --git a/docs/release-5.3.0.rst b/docs/release-5.3.0.rst index a3a60ce..de653d7 100644 --- a/docs/release-5.3.0.rst +++ b/docs/release-5.3.0.rst @@ -13,7 +13,7 @@ Highlights ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()``. - Lower replay overhead for array-backed ``LazyCollection`` sources while retaining repeatability for one-shot iterators. -- Optional Runwire 2.1.1 integration through +- Optional Runwire integration through ``LazyCollection::withRunwire()``. The host passes its existing ``RuntimeContext`` and optional request/scope objects; ArrayKit does not own Runwire lifecycle management. @@ -91,8 +91,8 @@ property getters and callbacks remain trusted application code. Optional Runwire Integration ---------------------------- -Runwire is not a production dependency. ArrayKit 5.3 tests the optional -integration against ``infocyph/runwire`` 2.1.1. +Runwire is not a production dependency. The 5.3.2 maintenance line tests the +optional integration against ``infocyph/runwire`` 3.0. ``LazyCollection::withRunwire()`` accepts the host's exact ``RuntimeContext`` and optional ``RequestContext`` / ``CoroutineScope``. Cancellation is checked diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index be9f5a7..4a5221b 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -214,9 +214,6 @@ private static function invokeValueCallback(callable $callback, mixed $value, in return $callback($value, $key); } - /** - * @param array $array - */ /** * Recompute only on overflow; normal finite inputs retain the fast path. * @@ -241,6 +238,9 @@ private static function overflowSafeAverage(array $array, int $count, float $fal return $average; } + /** + * @param array $array + */ private static function pickBy(array $array, callable $callback, bool $pickMax): mixed { $best = null; diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php index e78589e..e1f7deb 100644 --- a/src/Collection/RunwireLazyBinding.php +++ b/src/Collection/RunwireLazyBinding.php @@ -77,7 +77,7 @@ private function assertActive(): void } if ($this->scope !== null && $this->scopeCheckKey !== null) { - // Supported Runwire versions guard this read without yielding or changing task-local state. + // Runwire 3.0 guards this read without yielding or changing task-local state. $this->scope->hasLocal($this->scopeCheckKey); } } From 3e32be7bf45a7ef06e53449c594c6e62d03feed1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:16:34 +0600 Subject: [PATCH 17/26] fix: isolate snapshot references and refresh numeric lazy namespaces --- arraykit-5.3.2-review-and-release-plan.md | 2 +- docs/lazy-config.rst | 2 +- src/Config/Concerns/BaseConfigTrait.php | 6 +- .../Concerns/LazyFileConfigCacheTrait.php | 8 +- src/Config/LayeredLazyFileConfig.php | 11 +- src/Config/LazyFileConfig.php | 6 +- src/Config/Support/ConfigSnapshot.php | 58 ++++++++ tests/Feature/Release532BatchBTest.php | 124 ++++++++++++++++++ 8 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 src/Config/Support/ConfigSnapshot.php create mode 100644 tests/Feature/Release532BatchBTest.php diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 1f4c310..4675b7c 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -185,7 +185,7 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | Batch | Status at review update | Remaining work | | --- | --- | --- | | A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | -| B — R03–R05 | Not implemented. | Snapshot alias isolation; cache-generation refresh; numeric namespace normalization and regressions. | +| B — R03–R05 | Code corrections and regressions staged for QA. | Validate snapshot copy/cycle contract, explicit refresh and numeric namespaces on PHP 8.4/8.5; close only with green final-head CI. | | C — R06–R11 | R06–R11 implementation and regressions committed. Optional Runwire 3.0 Composer CI checks on PHP 8.4/8.5 configured. | Final-version matrix results, additional lifecycle/absent-install verification, green final-revision QA. | | D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 70ce487..22d171a 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -76,7 +76,7 @@ Cache behavior: - Structural, wildcard, and namespace reads use the same pinned immutable generation as exact reads. - ``Environment::ref()`` values and closures are resolved before publication. - Warm-up rereads the authoritative source unless the caller explicitly supplied or mutated that namespace in memory; an older generated cache does not feed a new source-backed generation. -- Readers do not take the writer lock. The first cache lookup pins a generation for that instance. External publication does not change its view. Construct a new instance or call ``namespaceCache()`` explicitly to refresh; warm-up and flush on the instance also reset its generated state, retaining intentional runtime overrides. +- Readers do not take the writer lock. The first cache lookup pins a generation for that instance. External publication does not change its view. Construct a new instance or call ``namespaceCache()`` explicitly to refresh; refresh also drops source-derived values but retains deliberate runtime overrides. Warm-up and flush on the instance also reset its generated state, retaining intentional runtime overrides. - Writers copy unmodified namespaces from the latest published generation even when that writer was previously a pinned reader. Partial merges retain source/cache origins for untouched namespaces. Environment Values in Namespace Files diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 81e5565..6e8a2c7 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -6,6 +6,7 @@ use Infocyph\ArrayKit\Array\DotNotation; use Infocyph\ArrayKit\Config\EnvParser; +use Infocyph\ArrayKit\Config\Support\ConfigSnapshot; use Infocyph\ArrayKit\Config\Support\EnvReference; use InvalidArgumentException; use OutOfBoundsException; @@ -507,7 +508,7 @@ public function restore(string $name = 'default'): bool } $this->flushReadCache(); - $this->items = $this->snapshots[$name]; + $this->items = ConfigSnapshot::detach($this->snapshots[$name]); return true; } @@ -537,7 +538,8 @@ public function set(string|array|null $key = null, mixed $value = null, bool $ov */ public function snapshot(string $name = 'default'): bool { - $this->snapshots[$name] = $this->items; + $copy = ConfigSnapshot::detach($this->items); + $this->snapshots[$name] = $copy; return true; } diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index cf0b54b..0f48ee2 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -148,7 +148,7 @@ protected function discoverNamespaces(): array $namespaces = []; foreach ($this->items as $namespace => $_) { - if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { + if (preg_match('/^[A-Za-z0-9_-]+$/', (string) $namespace) === 1) { $namespaces[$namespace] = true; } } @@ -164,7 +164,7 @@ protected function discoverNamespaces(): array ); } - return array_keys($namespaces); + return array_map(static fn(int|string $name): string => (string) $name, array_keys($namespaces)); } protected function flatLeafIndexPath(): ?string @@ -191,11 +191,11 @@ protected function invalidateGeneratedNamespaceState(): void $this->namespaceCachePinned = false; $this->pinnedNamespaceCacheDirectory = null; foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { - if ($origin === 'cache') { + if ($origin === 'cache' || $origin === 'source') { unset($this->items[$namespace]); } - if ($origin === 'cache' || $origin === 'missing') { + if ($origin !== 'runtime') { unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); } } diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index bbcbd07..3134e3a 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -56,9 +56,7 @@ public function all(): array $items = []; foreach (parent::all() as $key => $value) { - if (is_string($key)) { - $items[$key] = $value; - } + $items[$key] = $value; } return $items; @@ -216,7 +214,8 @@ public function snapshot(string $name = 'default'): bool */ public function warmNamespaceCache(string|array|null $namespaces = null): static { - $this->source->warmNamespaceCache($namespaces ?? array_keys($this->knownNamespaces)); + $known = array_map(static fn(int|string $name): string => (string) $name, array_keys($this->knownNamespaces)); + $this->source->warmNamespaceCache($namespaces ?? $known); return $this; } @@ -244,7 +243,7 @@ private function markAllKnownNamespacesMaterialized(): void private function materializeKnownNamespaces(): void { foreach (array_keys($this->knownNamespaces) as $namespace) { - $this->materializeNamespace($namespace); + $this->materializeNamespace((string) $namespace); } } @@ -316,7 +315,7 @@ private function namespaceFromPath(string $path): string private function registerNamespaces(array $items): void { foreach ($items as $namespace => $_value) { - if (is_string($namespace) && $namespace !== '') { + if ((string) $namespace !== '') { $this->knownNamespaces[$namespace] = true; $this->materializedNamespaces[$namespace] = true; } diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 24ad531..360e59f 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -173,7 +173,7 @@ public function loaded(string $namespace): bool */ public function loadedNamespaces(): array { - return array_keys($this->loadedNamespaces); + return array_map(static fn(int|string $name): string => (string) $name, array_keys($this->loadedNamespaces)); } #[\Override] @@ -191,7 +191,7 @@ public function merge(array $items): bool $changed = parent::merge($items); if ($changed) { foreach (array_keys($items) as $namespace) { - if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { + if (preg_match('/^[A-Za-z0-9_-]+$/', (string) $namespace) === 1) { $this->markNamespaceRuntime($namespace); } } @@ -481,7 +481,7 @@ protected function syncLoadedNamespacesFromItems(): void $this->loadedNamespaceOrigins = []; foreach ($this->items as $namespace => $_) { - if (!is_string($namespace) || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + if (!preg_match('/^[A-Za-z0-9_-]+$/', (string) $namespace)) { continue; } diff --git a/src/Config/Support/ConfigSnapshot.php b/src/Config/Support/ConfigSnapshot.php new file mode 100644 index 0000000..77d7804 --- /dev/null +++ b/src/Config/Support/ConfigSnapshot.php @@ -0,0 +1,58 @@ + $items + * @return array + */ + public static function detach(array $items): array + { + $active = []; + + return self::copy($items, $active); + } + + /** + * @param array $items + * @param array $active + * @return array + */ + private static function copy(array $items, array &$active): array + { + $output = []; + foreach ($items as $key => $value) { + $reference = ReflectionReference::fromArrayElement($items, $key); + $id = $reference !== null ? bin2hex($reference->getId()) : null; + if ($id !== null && isset($active[$id])) { + throw new UnexpectedValueException('Configuration snapshot contains a cyclic array reference.'); + } + + if ($id !== null) { + $active[$id] = true; + } + + try { + $output[$key] = is_array($value) ? self::copy($value, $active) : $value; + } finally { + if ($id !== null) { + unset($active[$id]); + } + } + } + + return $output; + } +} diff --git a/tests/Feature/Release532BatchBTest.php b/tests/Feature/Release532BatchBTest.php new file mode 100644 index 0000000..9795125 --- /dev/null +++ b/tests/Feature/Release532BatchBTest.php @@ -0,0 +1,124 @@ + 10]; + $config = new Config(); + $config->loadArray(['app' => ['scalar' => &$scalar, 'nested' => &$nested]]); + expect($config->snapshot('before'))->toBeTrue(); + + $scalar = 5; + $nested['v'] = 30; + expect($config->changed('before'))->toBeTrue() + ->and($config->restore('before'))->toBeTrue() + ->and($config->get('app.scalar'))->toBe(1) + ->and($config->get('app.nested.v'))->toBe(10); + + $config->set('app.scalar', 99); + expect($config->restore('before'))->toBeTrue() + ->and($config->get('app.scalar'))->toBe(1) + ->and($config->changed('before'))->toBeFalse(); +}); + +it('keeps shallow object identity but detaches repeated internal array references', function () { + $shared = ['value' => 1]; + $object = new stdClass(); + $config = new Config(); + $config->loadArray(['left' => &$shared, 'right' => &$shared, 'obj' => $object]); + $config->snapshot(); + $shared['value'] = 2; + + expect($config->restore())->toBeTrue() + ->and($config->get('left.value'))->toBe(1) + ->and($config->get('right.value'))->toBe(1) + ->and($config->get('obj'))->toBe($object); +}); + +it('rejects cyclic snapshots without replacing a valid snapshot', function () { + $config = new Config(); + $config->loadArray(['safe' => ['v' => 1]]); + $config->snapshot('baseline'); + $recursive = []; + $recursive['self'] = &$recursive; + $config->replace(['cyclic' => &$recursive]); + + expect(fn () => $config->snapshot('baseline'))->toThrow(UnexpectedValueException::class); + $config->replace(['safe' => ['v' => 42]]); + expect($config->restore('baseline'))->toBeTrue() + ->and($config->get('safe.v'))->toBe(1); +}); + +it('refreshes source-origin namespace state after a new generation is published', function () { + $root = sys_get_temp_dir() . '/arraykit-532-' . bin2hex(random_bytes(6)); + $source = $root . '/source'; + $cache = $root . '/cache'; + mkdir($root); + mkdir($source); + mkdir($cache); + try { + file_put_contents($source . '/app.php', " 1];"); + $reader = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + expect($reader->get('app.version'))->toBe(1); + file_put_contents($source . '/app.php', " 2];"); + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('app'); + expect($reader->get('app.version'))->toBe(1); + $reader->namespaceCache($cache); + expect($reader->get('app.version'))->toBe(2); + $reader->set('app.version', 7); + $reader->namespaceCache($cache); + expect($reader->get('app.version'))->toBe(7); + } finally { + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ) as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($root); + } +}); + +it('discovers and returns canonical numeric namespace names in lazy and layered config', function () { + $root = sys_get_temp_dir() . '/arraykit-532-' . bin2hex(random_bytes(6)); + $source = $root . '/source'; + $cache = $root . '/cache'; + mkdir($root); + mkdir($source); + mkdir($cache); + try { + foreach (['0', '-1', '01'] as $name) { + file_put_contents($source . '/' . $name . '.php', " '{$name}'];"); + } + + $lazy = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + expect($lazy->get('0.value'))->toBe('0') + ->and($lazy->get('-1.value'))->toBe('-1') + ->and($lazy->get('01.value'))->toBe('01') + ->and($lazy->loadedNamespaces())->toBe(['0', '-1', '01']); + + $lazy->warmNamespaceCache(); + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + expect($fresh->get('0.value'))->toBe('0'); + + $layered = new LayeredLazyFileConfig($source, $cache, namespaces: ['0', '-1', '01']); + expect($layered->get('0.value'))->toBe('0') + ->and($layered->all()[0]['value'])->toBe('0') + ->and($layered->all()[-1]['value'])->toBe('-1') + ->and($layered->all()['01']['value'])->toBe('01'); + $layered->warmNamespaceCache(); + } finally { + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ) as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($root); + } +}); From a8efd41d335e0bd4d4e50b1b985eb856af43846f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:17:53 +0600 Subject: [PATCH 18/26] bench: compare 5.3.2 to immediate 5.3.1 baseline with snapshot workload --- arraykit-5.3.2-review-and-release-plan.md | 2 +- benchmarks/release-representative.php | 3 ++- benchmarks/release-workload.php | 28 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 4675b7c..34be00a 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -196,7 +196,7 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base - [ ] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. - [ ] Make optional Runwire 3.0 verification reproducible on PHP 8.4 and 8.5 using Composer, rather than relying only on the original audit's temporary source-autoloader experiment. - [ ] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with Runwire 3.0 and correct host cleanup. Do not weaken the ceiling to pass a test. -- [ ] Use **5.3.1** as the before/after performance baseline for this patch. The current representative harness hardcodes 5.2.0; update the benchmark input/baseline selection as part of release evidence, not production behavior. Validate equivalent correct outputs before comparing throughput. +- [ ] Use **5.3.1** as the before/after performance baseline for this patch. The representative harness now uses the immediately previous 5.3.1 tag and includes snapshot behavior with result assertions. Check actual comparable CI evidence on the final candidate; the baseline change alone does not certify performance. Validate equivalent correct outputs before comparing throughput. - [ ] Measure changed hot paths, config cold/warm/generated reads and snapshot cost, bounded failures, array/reference/one-shot replay, and bound/unbound Runwire pipelines. Include input-size scaling and a host-owned yielding workload so fairness/cancellation are measured, not merely synchronous materialization. - [ ] Keep component evidence labelled component evidence. For an application-RPM claim, use an ArrayKit-owned minimal request/task consumer with warmed OPcache and optimized production loading, sustained synchronized measurement windows, several concurrency levels, at least three trials, validated successful RPM, p50/p95/p99, errors/timeouts, CPU and peak/steady RSS. Exclude setup/warm-up/cleanup from the steady-state denominator. Record exact source IDs, PHP/extensions/INI, OS/hardware, datasets, variance, queue/task state and runner stability. - [ ] Preserve the configured 2% throughput budget wherever a controlled matching stable environment is available. Predeclare workload latency/memory limits from baseline/host capacity. On a noisy/shared runner, report the comparator as skipped/unverified; never switch the stable flag on just to obtain a green badge. A host harness owned by ArrayKit can qualify library impact without making Foundation or other runtimes part of this task. diff --git a/benchmarks/release-representative.php b/benchmarks/release-representative.php index ccd87d6..46d56fa 100644 --- a/benchmarks/release-representative.php +++ b/benchmarks/release-representative.php @@ -9,7 +9,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; -const RELEASE_BASELINE_TAG = '5.2.0'; +const RELEASE_BASELINE_TAG = '5.3.1'; const RELEASE_REPETITIONS = 3; const RELEASE_STABILITY_SPREAD_PERCENT = 10.0; @@ -17,6 +17,7 @@ $workloadSpecs = [ ['name' => 'array-query', 'operations' => 100, 'warmup' => 20], ['name' => 'dot-config', 'operations' => 100, 'warmup' => 20], + ['name' => 'config-snapshot', 'operations' => 100, 'warmup' => 20], ['name' => 'lazy-array', 'operations' => 100, 'warmup' => 20], ['name' => 'lazy-file-cold', 'operations' => 40, 'warmup' => 10], ['name' => 'lazy-file-generated', 'operations' => 60, 'warmup' => 10], diff --git a/benchmarks/release-workload.php b/benchmarks/release-workload.php index 4afc3a4..3bfd380 100644 --- a/benchmarks/release-workload.php +++ b/benchmarks/release-workload.php @@ -30,6 +30,7 @@ $operation = match ($workload) { 'array-query' => releaseArrayQueryWorkload(), 'dot-config' => releaseDotConfigWorkload(), + 'config-snapshot' => releaseConfigSnapshotWorkload(), 'lazy-array' => releaseLazyArrayWorkload(), 'lazy-file-cold' => releaseLazyFileColdWorkload($temporaryDirectories), 'lazy-file-generated' => releaseLazyFileGeneratedWorkload($temporaryDirectories), @@ -119,6 +120,33 @@ function releaseCpuSeconds(array $usage): float return $user + $system; } +/** + * @return \Closure(): void + */ +function releaseConfigSnapshotWorkload(): \Closure +{ + $data = [ + 'app' => [ + 'service' => ['enabled' => true, 'limits' => range(1, 32)], + 'version' => 1, + ], + ]; + $config = new Config(); + $config->loadArray($data); + + return static function () use ($config): void { + $config->snapshot('benchmark'); + $config->set('app.version', 2); + if (!$config->changed('benchmark') || !$config->restore('benchmark')) { + throw new RuntimeException('Snapshot release workload failed to restore configuration.'); + } + + if ($config->get('app.version') !== 1 || $config->changed('benchmark')) { + throw new RuntimeException('Snapshot release workload returned invalid configuration.'); + } + }; +} + /** * @return \Closure(): void */ From 906bb8797d1a21c9cab70430f64f7446a16e5e4f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:18:45 +0600 Subject: [PATCH 19/26] fix: type numeric namespace storage as PHP array keys --- src/Config/Concerns/LazyFileConfigCacheTrait.php | 2 +- src/Config/LayeredLazyFileConfig.php | 6 +++--- src/Config/LazyFileConfig.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 0f48ee2..ea3cd13 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -410,7 +410,7 @@ private function createGenerationStage(): string } /** - * @param array $namespaces + * @param array $namespaces */ private function discoverNamespacesInDirectory(string $directory, array &$namespaces, bool $legacy): void { diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index 3134e3a..4680bd4 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -16,10 +16,10 @@ class LayeredLazyFileConfig extends Config { private readonly ResilientLazyFileConfig $source; - /** @var array */ + /** @var array */ private array $knownNamespaces = []; - /** @var array */ + /** @var array */ private array $materializedNamespaces = []; /** @@ -48,7 +48,7 @@ public function __construct( } } - /** @return array */ + /** @return array */ #[\Override] public function all(): array { diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 360e59f..99fcc33 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -15,12 +15,12 @@ class LazyFileConfig extends Config use LazyFileConfigCacheTrait; /** - * @var array + * @var array */ protected array $loadedNamespaceOrigins = []; /** - * @var array + * @var array */ protected array $loadedNamespaces; From cfb7bce95c5a9b2fbf32ff76a293c538b920d054 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:20:24 +0600 Subject: [PATCH 20/26] docs: explain snapshot isolation, numeric namespaces, and 3.0 integration --- docs/config-layering.rst | 5 ++++- docs/config.rst | 20 ++++++++++++++++++++ docs/lazy-config.rst | 5 +++++ docs/migration.rst | 17 +++++++++++++++++ src/Config/LayeredLazyFileConfig.php | 7 +------ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/config-layering.rst b/docs/config-layering.rst index 05e8dec..50ac276 100644 --- a/docs/config-layering.rst +++ b/docs/config-layering.rst @@ -66,7 +66,10 @@ values from lower-precedence layers. ``all()`` materializes the configured namespace set. ``warmNamespaceCache()`` and ``clearNamespaceCache()`` delegate generated source-cache lifecycle to the -underlying lazy source. +underlying lazy source. Canonical numeric namespace names such as ``0`` and +``-1`` are supported as filenames and paths. PHP converts those array keys +to integers in the result from ``all()``; ``loadedNamespaces()`` always +returns namespace names as strings. Resilient Lazy Source Cache --------------------------- diff --git a/docs/config.rst b/docs/config.rst index dec88dc..bcc63e9 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -338,6 +338,26 @@ Merging, Snapshots, and Read-Only Mode $locked = $config->isReadonly(); // true $config->readonly(false); // unlock +Snapshots retain a by-value copy of scalar and nested array data. PHP references +to external variables do not cause a later mutation to change an earlier +snapshot. ``restore()`` makes a fresh copy, so repeated restores remain safe. +Repeated internal array references are copied independently; cyclic array +references raise ``UnexpectedValueException`` before a valid named snapshot +can be overwritten. Object values remain shallow: the snapshot preserves +their identity and cannot reverse changes to an object's internal state. + +.. code-block:: php + + loadArray(['app' => ['value' => &$value]]); + $config->snapshot('start'); + + $value = 9; + $config->restore('start'); + assert($config->get('app.value') === 1); + Array-Value Helpers ------------------- diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 22d171a..b449c01 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -79,6 +79,11 @@ Cache behavior: - Readers do not take the writer lock. The first cache lookup pins a generation for that instance. External publication does not change its view. Construct a new instance or call ``namespaceCache()`` explicitly to refresh; refresh also drops source-derived values but retains deliberate runtime overrides. Warm-up and flush on the instance also reset its generated state, retaining intentional runtime overrides. - Writers copy unmodified namespaces from the latest published generation even when that writer was previously a pinned reader. Partial merges retain source/cache origins for untouched namespaces. +Namespace filenames may include canonical numeric names such as ``0.php`` +and ``-1.php``; dot paths still use strings (``'0.value'``) and +``loadedNamespaces()`` returns string names. PHP arrays naturally convert +canonical integer-string map keys into integer keys. + Environment Values in Namespace Files ------------------------------------- diff --git a/docs/migration.rst b/docs/migration.rst index 205f921..2c9f490 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -36,6 +36,23 @@ generation selected by ``.arraykit-generation``. The caller namespace ``__flat`` is therefore no longer ambiguous with ArrayKit's internal flat index. +5.3.2 Patch Notes +----------------- + +The patch fixes failed dot-path moves (the source is preserved if a destination +write fails), guarded sort boundaries for arbitrary object values, snapshot +reference aliasing, and lazy config refresh against newly published source +generations. It also fixes numeric namespace discovery, numeric and enum edge +cases, median overflow and private pipeline method dispatch. + +Optional Runwire integration is qualified for Runwire 3.0 only. Production +installation does not require Runwire; applications that explicitly bind a +Runwire runtime must use a compatible 3.0 host instance. + +Normal array/snapshot APIs retain their signatures. Snapshotting now visits +nested arrays to detach external references; object values remain shallow. +A cyclic array reference is rejected at snapshot creation rather than saved. + Recent Additions ---------------- diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index 4680bd4..8e5f772 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -54,12 +54,7 @@ public function all(): array { $this->materializeKnownNamespaces(); - $items = []; - foreach (parent::all() as $key => $value) { - $items[$key] = $value; - } - - return $items; + return parent::all(); } #[\Override] From ca7755705785eaab6f2bcf06c95b15dd17d4538d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:21:32 +0600 Subject: [PATCH 21/26] ci: require clean Sphinx documentation build for release acceptance --- .github/workflows/security-standards.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 65741b2..7f98ace 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -34,6 +34,21 @@ jobs: actions: read contents: read + docs: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') + name: "Sphinx documentation" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install documentation dependencies + run: python -m pip install --requirement docs/requirements.txt + - name: Build documentation and reject warnings + run: sphinx-build -b html -W --keep-going docs build/sphinx + runwire-compat: if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') name: "Runwire 3.0 / PHP ${{ matrix.php }}" From 508797590e2633019d4d6223c25eb3722265f9f9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:22:05 +0600 Subject: [PATCH 22/26] test: cover snapshot isolation in lazy and layered config owners --- tests/Feature/Release532BatchBTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Feature/Release532BatchBTest.php b/tests/Feature/Release532BatchBTest.php index 9795125..fdf3641 100644 --- a/tests/Feature/Release532BatchBTest.php +++ b/tests/Feature/Release532BatchBTest.php @@ -40,6 +40,29 @@ ->and($config->get('obj'))->toBe($object); }); +it('detaches snapshot references across lazy and layered configuration owners', function () { + $lazyValue = 1; + $lazy = new LazyFileConfig( + sys_get_temp_dir() . '/arraykit-532-nonexistent', + items: ['app' => ['v' => &$lazyValue]], + ); + expect($lazy->snapshot())->toBeTrue(); + $lazyValue = 8; + expect($lazy->restore())->toBeTrue() + ->and($lazy->get('app.v'))->toBe(1); + + $layeredValue = 2; + $layered = new LayeredLazyFileConfig( + sys_get_temp_dir() . '/arraykit-532-nonexistent', + fallback: ['app' => ['v' => &$layeredValue]], + namespaces: ['app'], + ); + $layered->snapshot(); + $layeredValue = 9; + expect($layered->restore())->toBeTrue() + ->and($layered->get('app.v'))->toBe(2); +}); + it('rejects cyclic snapshots without replacing a valid snapshot', function () { $config = new Config(); $config->loadArray(['safe' => ['v' => 1]]); From 6ad11143277f36564812f996729e37896eef0466 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:22:49 +0600 Subject: [PATCH 23/26] docs: resolve Sphinx duplicate anchors and malformed examples --- docs/conf.py | 1 + docs/config.rst | 2 +- docs/quick-usage.rst | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index b48183b..cf2eea1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,6 +57,7 @@ def get_version() -> str: "linkify", ] myst_heading_anchors = 3 +autosectionlabel_prefix_document = True # Autodoc/Napoleon autodoc_default_options = { diff --git a/docs/config.rst b/docs/config.rst index bcc63e9..8210697 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -145,7 +145,7 @@ This example shows a typical bootstrap flow: Example ``.env`` file: -.. code-block:: dotenv +.. code-block:: text APP_NAME=ArrayKit Demo APP_ENV=local diff --git a/docs/quick-usage.rst b/docs/quick-usage.rst index b907cea..ff40044 100644 --- a/docs/quick-usage.rst +++ b/docs/quick-usage.rst @@ -6,7 +6,7 @@ keeps its data-focused features together: shape and access arrays, transform collections, hydrate configuration, and map values into DTOs. ArrayKit Facade Example ----------------------- +----------------------- .. code-block:: php From eb9cbcbc27695a6202a59593a83795a3fba111dd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:24:51 +0600 Subject: [PATCH 24/26] docs: synchronize all four implementation batches and acceptance evidence --- arraykit-5.3.2-review-and-release-plan.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 34be00a..8de9524 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -184,10 +184,16 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | Batch | Status at review update | Remaining work | | --- | --- | --- | -| A — R01–R02 | Production corrections and regression tests committed. PHP 8.4/8.5 QA and static checks passed on the intermediate `d22359b` revision; final-revision acceptance remains outstanding. | Final SHA acceptance, edge-case follow-up if needed. | -| B — R03–R05 | Code corrections and regressions staged for QA. | Validate snapshot copy/cycle contract, explicit refresh and numeric namespaces on PHP 8.4/8.5; close only with green final-head CI. | -| C — R06–R11 | R06–R11 implementation and regressions committed. Optional Runwire 3.0 Composer CI checks on PHP 8.4/8.5 configured. | Final-version matrix results, additional lifecycle/absent-install verification, green final-revision QA. | -| D — release acceptance | Not complete. | 5.3.1 comparison baseline; documentation verification; persistent-worker soak; final candidate test matrix and release decision. | +| A — R01–R02 | **Implemented.** Guarded object comparisons and staged moves have regression tests. An intermediate full workflow passed (`d22359b`). | Verify final candidate SHA. | +| B — R03–R05 | **Implemented.** Snapshots detach references with cycle rejection, cache refresh invalidates source-derived state, and canonical numeric namespace paths are normalized. Config, lazy and layered regressions passed the QA matrix on intermediate revisions. | Verify final candidate SHA. | +| C — R06–R11 | **Implemented.** Numeric, enum, median, collection-dispatch and sampling regressions committed. Optional Runwire `^3.0` and exact 3.0.0 PHP 8.4/8.5 CI composition are configured; 2.x support claims removed. | Verify final candidate SHA and worker-soak outcomes. | +| D — release acceptance | **Acceptance infrastructure implemented.** The benchmark now uses 5.3.1 rather than 5.2.0, validates snapshot behavior, and the Sphinx warnings-as-errors build passed on `6ad1114`. | Final SHA workflow and 300-second Runwire worker soak must complete successfully; the shared-runner 2% regression comparator is correctly labelled unverified. | + +## Release-candidate evidence boundaries + +The immediately previous tag is `5.3.1` at `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`. The benchmark now compares against that tag and validates representative workload output, including snapshots. GitHub's shared runner does not assert a stable performance environment: the configured 2% regression threshold is reported as *unverified*, not silently passed. This is a separate, explicitly disclosed performance-evidence limitation; correctness and QA checks are required to remain green. + +The Sphinx HTML build with warnings treated as errors passed after resolving duplicate section labels, the lexer and heading issues. Runwire 3.0 remains optional, owned by the host application, with no Runwire 2.x qualification. All code and regression batches have been committed. Final release readiness must be established from one exact head SHA, including PHPForge, documentation, clean installation, benchmark validity, Runwire 3.0 on PHP 8.4/8.5, and worker soak. An earlier workflow success is not sufficient to approve publication. ## Release acceptance From 91d7b3ba5b34b76183ba3cda45f7e98c647aa597 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 09:32:29 +0600 Subject: [PATCH 25/26] docs: close all implementation batches against green 5.3.2 acceptance run --- arraykit-5.3.2-review-and-release-plan.md | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md index 8de9524..e123777 100644 --- a/arraykit-5.3.2-review-and-release-plan.md +++ b/arraykit-5.3.2-review-and-release-plan.md @@ -184,29 +184,29 @@ This is a rolling implementation tracker, not a release sign-off. The 5.3.1 base | Batch | Status at review update | Remaining work | | --- | --- | --- | -| A — R01–R02 | **Implemented.** Guarded object comparisons and staged moves have regression tests. An intermediate full workflow passed (`d22359b`). | Verify final candidate SHA. | -| B — R03–R05 | **Implemented.** Snapshots detach references with cycle rejection, cache refresh invalidates source-derived state, and canonical numeric namespace paths are normalized. Config, lazy and layered regressions passed the QA matrix on intermediate revisions. | Verify final candidate SHA. | -| C — R06–R11 | **Implemented.** Numeric, enum, median, collection-dispatch and sampling regressions committed. Optional Runwire `^3.0` and exact 3.0.0 PHP 8.4/8.5 CI composition are configured; 2.x support claims removed. | Verify final candidate SHA and worker-soak outcomes. | -| D — release acceptance | **Acceptance infrastructure implemented.** The benchmark now uses 5.3.1 rather than 5.2.0, validates snapshot behavior, and the Sphinx warnings-as-errors build passed on `6ad1114`. | Final SHA workflow and 300-second Runwire worker soak must complete successfully; the shared-runner 2% regression comparator is correctly labelled unverified. | +| A — R01–R02 | **Complete.** Guarded sort, staged moves and regression tests passed PHPForge QA on the release candidate. | None in scope. | +| B — R03–R05 | **Complete.** Reference-isolated snapshots, source-origin cache refresh, and numeric namespace handling passed PHP 8.4/8.5 stable and lowest QA, static analysis and regression coverage. | None in scope. | +| C — R06–R11 | **Complete.** All six corrections and regressions passed. Optional Runwire 3.0.0 passed PHP 8.4/8.5 compatibility tests and the PHP 8.5 persistent-worker soak. | None in scope. | +| D — release acceptance | **Complete within available shared-runner evidence.** Documentation passes Sphinx warnings-as-errors; clean/no-dev install, PHPForge matrix, 5.3.1-baseline benchmark validity, and separate 300-second Release Acceptance worker soak all passed on `eb9cbcbc`. | The 2% stable-environment performance comparator remains explicitly **unverified** (not falsely passed). Publication/tagging are separate authorized actions. | ## Release-candidate evidence boundaries The immediately previous tag is `5.3.1` at `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`. The benchmark now compares against that tag and validates representative workload output, including snapshots. GitHub's shared runner does not assert a stable performance environment: the configured 2% regression threshold is reported as *unverified*, not silently passed. This is a separate, explicitly disclosed performance-evidence limitation; correctness and QA checks are required to remain green. -The Sphinx HTML build with warnings treated as errors passed after resolving duplicate section labels, the lexer and heading issues. Runwire 3.0 remains optional, owned by the host application, with no Runwire 2.x qualification. All code and regression batches have been committed. Final release readiness must be established from one exact head SHA, including PHPForge, documentation, clean installation, benchmark validity, Runwire 3.0 on PHP 8.4/8.5, and worker soak. An earlier workflow success is not sufficient to approve publication. +The Sphinx HTML build with warnings treated as errors passed after resolving duplicate section labels, the lexer and heading issues. Runwire 3.0 remains optional, owned by the host application, with no Runwire 2.x qualification. **All required jobs succeeded** in [Security & Standards run 40997245493](https://github.com/infocyph/ArrayKit/actions/runs/40997245493) on code candidate `eb9cbcbc27695a6202a59593a83795a3fba111dd` (PHPForge QA stable/lowest 8.4/8.5, analysis, clean install, benchmark validity, Sphinx, exact Runwire 3.0 PHP 8.4/8.5, both persistent-worker soaks and Release Acceptance). This tracker-only synchronization commit changes no runtime, tests, workflow or benchmark files; verify its new final SHA before publication. ## Release acceptance - [ ] Regressions reproduce all R01–R11 triggers on `5.3.1` and pass after their corrections. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. -- [ ] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. -- [ ] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. -- [ ] Make optional Runwire 3.0 verification reproducible on PHP 8.4 and 8.5 using Composer, rather than relying only on the original audit's temporary source-autoloader experiment. -- [ ] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with Runwire 3.0 and correct host cleanup. Do not weaken the ceiling to pass a test. -- [ ] Use **5.3.1** as the before/after performance baseline for this patch. The representative harness now uses the immediately previous 5.3.1 tag and includes snapshot behavior with result assertions. Check actual comparable CI evidence on the final candidate; the baseline change alone does not certify performance. Validate equivalent correct outputs before comparing throughput. +- [x] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. +- [x] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. +- [x] Make optional Runwire 3.0 verification reproducible on PHP 8.4 and 8.5 using Composer, rather than relying only on the original audit's temporary source-autoloader experiment. +- [x] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with Runwire 3.0 and correct host cleanup. Do not weaken the ceiling to pass a test. +- [x] Use **5.3.1** as the before/after performance baseline for this patch. The representative harness now uses the immediately previous 5.3.1 tag and includes snapshot behavior with result assertions. Check actual comparable CI evidence on the final candidate; the baseline change alone does not certify performance. Validate equivalent correct outputs before comparing throughput. - [ ] Measure changed hot paths, config cold/warm/generated reads and snapshot cost, bounded failures, array/reference/one-shot replay, and bound/unbound Runwire pipelines. Include input-size scaling and a host-owned yielding workload so fairness/cancellation are measured, not merely synchronous materialization. - [ ] Keep component evidence labelled component evidence. For an application-RPM claim, use an ArrayKit-owned minimal request/task consumer with warmed OPcache and optimized production loading, sustained synchronized measurement windows, several concurrency levels, at least three trials, validated successful RPM, p50/p95/p99, errors/timeouts, CPU and peak/steady RSS. Exclude setup/warm-up/cleanup from the steady-state denominator. Record exact source IDs, PHP/extensions/INI, OS/hardware, datasets, variance, queue/task state and runner stability. -- [ ] Preserve the configured 2% throughput budget wherever a controlled matching stable environment is available. Predeclare workload latency/memory limits from baseline/host capacity. On a noisy/shared runner, report the comparator as skipped/unverified; never switch the stable flag on just to obtain a green badge. A host harness owned by ArrayKit can qualify library impact without making Foundation or other runtimes part of this task. -- [ ] Keep user docs focused on behavior and migration. Put transient verification logs/results in CI or review artifacts, not in release documentation. Record limitations in the release decision without claiming unavailable external certification. +- [x] Preserve the configured 2% throughput budget wherever a controlled matching stable environment is available. Predeclare workload latency/memory limits from baseline/host capacity. On a noisy/shared runner, report the comparator as skipped/unverified; never switch the stable flag on just to obtain a green badge. A host harness owned by ArrayKit can qualify library impact without making Foundation or other runtimes part of this task. +- [x] Keep user docs focused on behavior and migration. Put transient verification logs/results in CI or review artifacts, not in release documentation. Record limitations in the release decision without claiming unavailable external certification. - [ ] Confirm every required workflow job against the final committed candidate; a green baseline or a newer Dependabot-only run is insufficient. Create/tag/publish the patch only as a separate authorized release action. ## Optional follow-ups, not patch requirements From 6dfcaad4f39490fcacb57dd99663478f1b8e2f43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Fri, 30 Oct 2026 10:33:49 +0600 Subject: [PATCH 26/26] :art: refactor(config,array): enhance snapshot isolation, numeric calculations, and dot-notation rollback - Ignore the build directory in git attributes :wrench: - Update config snapshot reference isolation, recursion guards, and node limits :sparkles: - Improve overflow safety and precision in averaging and median calculations :zap: - Add reference rollback support for dot-notation mutation paths :recycle: --- .gitattributes | 1 + arraykit-5.3.2-review-and-release-plan.md | 220 ------------------ docs/config.rst | 8 +- docs/migration.rst | 3 + src/Array/ArraySingleOps.php | 15 +- .../Concerns/DotNotationPublicApiTrait.php | 14 +- src/Array/DotNotationPathOps.php | 64 +++++ src/Config/Concerns/BaseConfigTrait.php | 4 +- src/Config/Support/ConfigSnapshot.php | 64 +++-- tests/Feature/Release532BatchATest.php | 35 +++ tests/Feature/Release532BatchBTest.php | 56 +++++ tests/Feature/Release532BatchCTest.php | 11 + 12 files changed, 253 insertions(+), 242 deletions(-) delete mode 100644 arraykit-5.3.2-review-and-release-plan.md diff --git a/.gitattributes b/.gitattributes index 2755315..00689a4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ .github export-ignore benchmarks export-ignore +build export-ignore docs export-ignore examples export-ignore tests export-ignore diff --git a/arraykit-5.3.2-review-and-release-plan.md b/arraykit-5.3.2-review-and-release-plan.md deleted file mode 100644 index e123777..0000000 --- a/arraykit-5.3.2-review-and-release-plan.md +++ /dev/null @@ -1,220 +0,0 @@ -# ArrayKit 5.3.2 review and release plan - -Date: 2026-10-30 (Asia/Dhaka). - -Reviewed source: `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`, the published `5.3.1` tag. The working tree was clean before this report. This document records an audit and proposed work; none of the corrections below has been implemented by this review. - -This plan follows the [PHPForge engineering principles](https://github.com/infocyph/PHPForge/blob/main/resources/engineering-principles.md) and [agent workflow](https://github.com/infocyph/PHPForge/blob/main/resources/AGENTS.md). Correctness, data integrity, bounded work, public contracts and host ownership take precedence over throughput. Performance claims require measurements with correct outputs. - -## Release decision and scope - -Recommend **5.3.2**, a patch containing the eleven identified corrections (R01–R09 from the original audit and R10–R11 from the supplemental review) and explicit compatibility coverage for Runwire 3.0. The existing passed-instance API already works with the published Runwire 3.0 source in the tests run here. The optional integration target is Runwire 3.0 only, without adding an ArrayKit API. - -Use **5.4.0** only if implementation introduces an intentional new public capability or API beyond these corrections. Retire legacy Runwire compatibility claims and test jobs; target only Runwire ^3.0 for optional integration. Do not raise PHP 8.4, change named arguments, or turn Runwire into a mandatory runtime dependency as part of this patch. - -Scope is ArrayKit's code, tests, docs, Composer constraints, benchmark fixtures and CI. Foundation adapters and qualification of Runwire's HTTP protocols, native workers, transports or host drivers are separate projects and are not ArrayKit release blockers for this work. - -## Audit evidence - -| Check | Result and limitation | -| --- | --- | -| Source review | All 37 production PHP files: array/set/query/sort operations, dot paths, collections/pipeline/replay, config/layering/generated caches, dotenv/environment, DTOs, hooks, facade and helpers; associated tests, benchmarks and workflow. Graphify was used for navigation; claims were checked in source and independent probes. | -| Installed PHPForge setup | Doctor healthy; bundled configs active; PHPStan max, class complexity 80 and function complexity 12. No detector, scope or threshold was weakened. | -| Local detailed suite | `composer ic:tests:details` passed: 401 tests, 8,797 assertions on PHP 8.5.4, plus formatting, architecture, static/security analysis and refactoring checks. | -| Local final full suite | `composer ic:tests` passed all 13 tasks. | -| Composer | `composer validate --strict` and `composer ic:release:constraints` passed. | -| Live locked dependency audit | Zero published advisories; development-only `doctrine/annotations` remains abandoned through benchmark tooling. Raw Composer exits nonzero for abandonment; this is distinct from an advisory and is non-blocking under existing PHPForge policy. ArrayKit has no third-party runtime package requirements. | -| Exact-source CI | [Security & Standards run 40161657499](https://github.com/infocyph/ArrayKit/actions/runs/40161657499) succeeded for `e77bf72…`: PHP 8.4/8.5 analysis, stable/lowest QA, clean installation, benchmarks, security report and Release Acceptance. Recent Dependabot-only runs were not counted as QA evidence. | -| Runwire 3.0 publication | Live [Composer metadata](https://repo.packagist.org/p2/infocyph/runwire.json) and the [3.0 source tag](https://github.com/infocyph/Runwire/tree/3.0) identify `054737bc6a8d4025a8d05fabb8ad390f66ee47ae`, published 2026-10-29. PHP and 64-bit PHP remain `^8.4`. | -| Runwire 3.0 composition | All 401 tests / 8,797 assertions passed using the exact published source in an isolated `/tmp` autoloader. Reflection confirmed that runtime and coroutine classes came from the published 3.0 source, not the locally installed package. This is runtime/source compatibility evidence, not a clean Composer matrix or a native host certification. | -| Runwire 3.0 worker soak | The existing persistent CLI harness passed with the published 3.0 source: 310.06 seconds including warm-up, 300 samples, initial/peak RSS 51.39 MiB and 0.00 MiB growth under the unchanged 16 MiB ceiling. It exercises a CLI worker with capability metadata; it does not launch a native RoadRunner host. | -| Additional parity probes | 480 strict membership comparisons against native `in_array()` behavior passed across lookup thresholds, mixed scalars, ordinary arrays, object identity, NaN, infinities and signed zero. Escaped dot-path flatten/expand probes passed. | -| Adversarial probes | The original audit reproduced R01–R09 outside the green suite. The supplemental branch review additionally identified R10/R11 in source, with independent native PHP probes for the overflow and method-visibility behavior. Repository regression tests must still reproduce both against the baseline. Green baseline tests are insufficient for release acceptance. | -| Branch QA status (supplemental review, 2026-10-30) | `patch/runwire` was one commit ahead of `main`, changing only this plan. No Actions runs existed for the branch at review time. The successful workflow evidence above applies to the 5.3.1 baseline, not the new release candidate. | - -This audit did not establish an authentication bypass or arbitrary code execution. R01 is a conditional resource-safety failure when object graphs reach the guarded sorter; R02/R03 are data-integrity failures. PHP configuration/cache artifacts, callbacks and user-defined property methods remain trusted host code, not sandboxed input. - -## Required corrections - -Priorities are engineering priorities, not CVSS scores. P1 means correct before publication; P2 is a narrower confirmed defect included in this patch. - -### R01 — P1: guarded sort compares uncounted object graphs - -Owners: `src/Array/Concerns/ArrayMultiQuerySortTrait.php:909` and `:1234`. - -`sortRecursiveWithGuards()` counts array entries and descends only into arrays. `compareSortValues()` subsequently uses PHP's regular comparison on object values, which can descend through arbitrarily large public object graphs outside the advertised budget. - -Reproduction: two distinct `stdClass` values, each with `self` pointing to itself and different `tail` values, passed to `sortRecursiveGuarded([$left, $right], maxNodes: 2, throwOnTooDeep: true)` produce `Error: Nesting level too deep - recursive dependency?` from the comparator instead of controlled guard handling. Wide acyclic object graphs cross the same boundary without requiring cyclic PHP references. The documentation recommends this guarded entry point for untrusted/deep input and does not exclude object values there. - -Correction: define and enforce a supported bounded-comparison domain before sorting. Charge supported object-data traversal to the same depth/node budget and detect active-path cycles, or reject object/comparison cases that cannot be bounded before invoking PHP comparison. Custom object/string conversion work must remain an explicit trusted-code boundary. Preserve normal `sortRecursive()` semantics. In non-throwing mode, propagate incompleteness to ancestors so no unsafe comparison runs; throwing mode must produce a controlled documented exception. - -Acceptance: cyclic/distinct/shared objects, wide public object graphs, arrays containing such objects, opaque internal objects, all sort flags, exact boundaries, throw/non-throw behavior, scalar/array equivalence below limits, and fixed-budget work as object width grows. Do not claim arbitrary user callbacks can be sandboxed. - -### R02 — P1: failed rename/move deletes the source - -Owner: `src/Array/Concerns/DotNotationPublicApiTrait.php:361`. - -`rename()` reads and removes the source before attempting the destination write. With `['source' => 42, 'target' => $object]` and a readonly `target.locked`, `rename($data, 'source', 'target.locked')` throws for the readonly property after `source` has already disappeared. `move()` and facade forwarding inherit the failure. - -Correction: preflight/stage ordinary destination changes and commit source removal only when the move can succeed. Preserve source data on rejected writes and preserve established parent/child path behavior. Avoid a blanket swap of the two existing calls: overlapping source/destination paths require deliberate handling. Do not promise rollback of arbitrary external side effects inside application magic setters. - -Acceptance: readonly/inaccessible/typed property failures, throwing setters, missing sources, same path, parent-to-child and child-to-parent moves, escaped paths, overwrite/fill flags, existing destination values, and native/facade named reference arguments. Failed supported moves must retain the source. - -### R03 — P2: snapshots alias externally referenced scalar/array state - -Owner: `src/Config/Concerns/BaseConfigTrait.php:498` and `:534`. - -Reproduction: load `['app' => ['v' => &$value]]` with `$value = 1`; call `snapshot()`, `set('app.v', 2)`, `changed()`, `restore()`. `changed()` returns false and the restored value remains 2. Assignment into the snapshot retains the PHP reference cell, so the live mutation changes the saved state as well. Config supports referenced reads and documents restore as rollback. - -Correction: make saved scalar/array state independent of live external reference cells, including repeated restores. Retain the established shallow identity contract for arbitrary objects; this is not a generic deep-clone feature. Handle internal aliases and cyclic arrays deliberately, with deterministic failure before replacing a valid snapshot if a graph cannot be represented under the chosen contract. Do not use `serialize()`/`unserialize()` as a generic copying mechanism or silently change ordinary live-reference reads/writes. - -Acceptance: top-level/nested references, external mutation after snapshot, internal aliases, multiple named snapshots, repeated restore/mutate/restore, changed/read-cache parity, readonly restore, and Config/LazyFileConfig/LayeredLazyFileConfig use. Document exactly what remains shallow for object values. - -### R04 — P2: explicit cache refresh retains old source-origin data - -Owner: `src/Config/Concerns/LazyFileConfigCacheTrait.php:189`. - -Reproduction: start with no cached `app` namespace, read source version 1, publish a generation containing source version 2 with a separate writer, then call `namespaceCache($sameRoot)` on the original reader. It still returns 1 while a fresh reader returns 2. Invalidation removes only cache-origin items and clears cache/missing flags; source-origin items survive and shadow the newly selected generation. Same-instance warm-up also needs coverage. - -Correction: explicit refresh/rebuild must invalidate affected source-derived state as well as cached/flat/memo state, while retaining deliberate constructor/runtime overrides. Keep generation pinning coherent across exact and structural reads. Ordinary pinned readers must remain stable until an explicit refresh; do not add pointer polling to each hot read. - -Acceptance: source-only reader before first publication; empty-cache generation; exact versus structural reads; same-root refresh, source/cache switching, warm-up and flush; multiple namespaces; ResilientLazyFileConfig; and preserved intentional runtime mutations. Do not infer a new public LayeredLazyFileConfig refresh API from this correction. - -### R05 — P2: canonical numeric namespace names become integer map keys - -Owners: `src/Config/LayeredLazyFileConfig.php:244`, `src/Config/Concerns/LazyFileConfigCacheTrait.php:167`, and namespace metadata in `src/Config/LazyFileConfig.php`. - -`0` is accepted by the namespace grammar, but PHP coerces associative key `'0'` to integer `0`. A layered config with `namespaces: ['0']` fails during `all()` because `materializeNamespace(string)` receives an integer. Discovery of `0.php` similarly makes no-argument `warmNamespaceCache()` call `namespaceCacheWarmValue(string)` with an integer. Numeric names can also violate the documented string list returned by `loadedNamespaces()`. - -Correction: normalize namespace identifiers to strings when crossing map-key/list boundaries, and consistently register numeric-looking constructor/runtime/source names. Preserve the current allowed namespace grammar and filename meaning; do not solve this by rejecting previously accepted names. - -Acceptance: `'0'`, positive and negative canonical integers, noncanonical numeric-looking strings such as `'01'`, ordinary names, discovery, explicit warm-up, full retrieval, snapshots/exports, loaded-name lists, and lazy/layered/resilient behavior. Invalid names still fail at the existing validation boundary. - -### R06 — P2: positive random sampling from an empty array misses validation - -Owner: `src/Array/BaseArrayHelper.php:223`. - -`random([], 1)` and `random([], 2)` return `[]`. Both requests exceed the available size, which the PHPDoc and user guide say must raise `InvalidArgumentException`. The empty-array early return bypasses that validation. - -Correction: validate positive requested counts before the empty-result shortcut. Preserve empty single-pick behavior (`null`) and the established zero/nonpositive-count behavior unless a separate contract change is approved. - -Acceptance: empty/nonempty arrays; omitted, zero, negative, exact-size and oversized counts; preserved keys; facade calls; no process-global randomness changes. - -### R07 — P2: averaging can overflow or cast a valid float outside integer range - -Owner: `src/Array/ArraySingle.php:21`, also exposed through the single-array facade. No new pipeline API is proposed. - -`avg([1e20])` returns integer `7766279631452241920` on the reviewed 64-bit runtime instead of `1e20`. `avg([PHP_INT_MAX, PHP_INT_MAX])` returns `PHP_INT_MIN`, flipping the sign. The result is blindly cast to integer whenever `fmod($avg, 1.0)` is zero, without proving integer representability. PHP 8.5 also emits a warning. Separately, adding two finite `1e308` values overflows the intermediate total and returns infinity even though their mean is finite. - -Correction: retain the ordinary fast path and established integer/float return behavior for representable results, but cast only after a range-safe check and use a numerically safe path when intermediate accumulation would overflow. Do not assume a float comparison with `PHP_INT_MAX` proves representability: converting the integer bound to float can round it upward. No arbitrary-precision dependency or broad aggregate rewrite is required. - -Acceptance: positive/negative values outside integer range, both integer bounds and nearby floats, repeated `PHP_INT_MAX`, finite large floats whose sum overflows, mixed signs, infinities/NaN, numeric strings, fractional results, empty/non-numeric input, facade calls and `E_ALL` on both supported PHP versions. Preserve existing finite ordinary cases and define special-value behavior explicitly. - -### R08 — P2: enum getters do not consistently use the supplied fallback - -Owner: `src/Config/Concerns/BaseConfigTrait.php:186`. - -The backed-enum branch accepts either integer or string values and calls `tryFrom()` in strict mode. An integer-backed enum with stored string `'1'`, or a string-backed enum with stored integer `1`, raises `TypeError`. The getter otherwise returns its supplied default for missing, unsupported and unmatched values, so a wrong backing scalar unexpectedly escapes that typed-getter boundary. - -Correction: validate the enum's backing scalar type before calling `tryFrom()` and return the default on mismatch, without introducing implicit scalar coercion. Preserve the error for a nonexistent enum class and preserve unit-enum lookup by case name. Avoid repeated discovery/reflection on a hot lookup path when a simple bounded metadata reuse strategy is warranted. - -Acceptance: integer-backed, string-backed and unit enums; correct values, unmatched values, numeric-looking strings, wrong backing scalar, null, boolean/array/object input, explicit defaults, missing config, and all config variants. A valid lookup continues to return the exact enum singleton. - -### R09 — P2: NaN is classified as both positive and negative - -Owner: `src/Array/ArraySingle.php:304` and `:329`. - -`isPositive([NAN])` and `isNegative([NAN])` both return true, while native `NAN > 0` and `NAN < 0` are false. These functions reject only the opposite comparison; NaN is unordered, so it escapes both checks. - -Correction: require the intended positive/negative predicate for every included numeric value. NaN must not establish either sign. Preserve ignored non-numeric values, the all-nonnumeric/empty result, zero handling and infinity signs; do not globally change `numericValue()` or unrelated arithmetic contracts merely to fix these predicates. - -Acceptance: NaN alone and mixed with signed values, both infinities, positive/negative zero, finite values, numeric strings, empty/all-nonnumeric inputs and facade calls. Assert that NaN is neither sign. - -### R10 — P2: even-length median overflows on finite inputs - -Owner: `src/Array/ArraySingle.php:438`, including `Pipeline::median()` and facade forwarding. - -For even-length numeric input, `median()` calculates `($values[$mid - 1] + $values[$mid]) / 2`. Two finite values such as `[1e308, 1e308]` overflow during addition and produce `INF`, although the correct median is finite. An independent PHP 8.4 probe confirms this arithmetic boundary. This issue is separate from R07's `avg()` implementation and must not be considered closed just because averaging is repaired. - -Correction: use an overflow-safe two-value midpoint calculation for finite extremes without losing ordinary integer/float behavior. Address opposite-sign extremes, same-sign extremes, and integer-boundary precision deliberately; do not introduce an arbitrary-precision dependency or a generic aggregate rewrite. Keep sorting, median selection, and existing exclusion of nonnumeric values unchanged unless tests establish a conflicting existing contract. - -Acceptance: even/odd counts, `[1e308, 1e308]`, negative large finite pairs, near-`PHP_INT_MAX`/min pairs, opposite-sign extremes, fractions, numeric strings, empty/all-nonnumeric input, nonfinite values with documented semantics, facade and pipeline calls, and warning-free `E_ALL` on PHP 8.4/8.5. Add a regression demonstrating the baseline failure before changing production code. - -### R11 — P2: collection magic forwarding attempts private Pipeline methods - -Owners: `src/Collection/Concerns/BaseCollectionTrait.php:51` and private helpers in `src/Collection/Pipeline.php:791`, `:798` and `:809`. - -`Collection::__call()` checks `method_exists($pipeline, $method)` before forwarding. That check includes private methods, but the subsequent call from the collection trait cannot access them. For example, invoking `$collection->numericAggregate(null, true)` passes the check and raises a PHP `Error` about a private method instead of the documented `BadMethodCallException` for unavailable collection methods. Native PHP visibility behavior was independently probed. - -Correction: forward only callable public pipeline operations. Reject private/internal methods through the existing controlled missing-method exception, without exposing private implementation helpers, adding proxies, or introducing a new reflection-heavy dispatch layer. - -Acceptance: every publicly supported fluent operation remains callable through `Collection` and `HookedCollection`; private/internal helper names are rejected with `BadMethodCallException`; unknown method names preserve the same exception; argument forwarding and return/chain types remain unchanged. Add a regression demonstrating the baseline failure before changing production code. - -## Runwire 3.0 support - -### Compatibility and ownership decision - -The current production binding consumes `RuntimeContext`, `RequestContext`, `CancellationToken`, `CoroutineScope`, `TaskLocal`, runtime coroutine capability, and their lifecycle/checkpoint methods. Those APIs remain available in the exact 3.0 source and all existing tests pass against them. - -1. Require `^3.0` as the optional development integration target and test exact Runwire 3.0.0 on PHP 8.4/8.5. Do not claim or test support for prior major versions. -2. Update the optional Composer suggestion and integration docs to describe the tested versions. Keep the production dependency set unchanged. -3. Keep `withRunwire($runtime, $request, $scope, $checkpointEvery)` and explicit intermediary forwarding unchanged. No ambient/global runtime discovery, implicit binding, worker startup or event-loop creation. -4. Continue capability-gated yielding and request/scope cancellation/completion checks before source consumption and after resumption. Keep closed/invalid supplied lifecycle errors distinct from the normal missing-capability fallback. Preserve `take(0)` laziness. -5. Exercise genuine host-owned `CoroutineRuntime`/scope tasks, interleaved requests, cancellation/deadlines, fail-fast groups, source/callback errors, early abandonment, rebinding and reference release under Runwire 3.0. The host completes requests and closes scopes. -6. Verify installed/unbound and clean Runwire-absent production installs. Avoid reflection of private scheduler state or version checks on each item. - -The relevant advantage is retaining cooperative fairness and cancellation while hosts adopt Runwire 3.0's runtime improvements. Array/DTO/config algorithms remain ArrayKit's responsibility. Runwire's outbound HTTP, WebSocket, transport, TLS and worker-pool features do not justify new integration in a pure-data path. Async file APIs do not make executable PHP config `include` asynchronous. Do not add an async config adapter, background worker or offload layer without a concrete caller need and representative throughput evidence. - -## Execution sequence and structural constraints - -| Batch | Work | Exit condition | -| --- | --- | --- | -| A | Add regressions for R01/R02, then fix guarded comparison and failed move handling. | Unsafe comparisons stop under the declared budget; rejected moves retain their source; ordinary APIs and facade contracts pass. | -| B | Add regressions and correct R03–R05 in existing config owners. | Snapshot rollback and source/cache generation coherence hold across all config variants; numeric namespaces work consistently. | -| C | Correct R06–R11 and expand optional Runwire development/docs/CI coverage. | Sampling, numeric and enum boundaries, median overflow and dynamic-method visibility pass; 3.0 and absent/unbound compositions pass without host ownership changes. | -| D | Run quality, performance, persistent-worker and final-revision CI acceptance. | Every applicable repository-owned gate below is satisfied and reported accurately. | - -Expected public API additions: **zero**. Expected new production classes/interfaces/enums: **zero**. Keep behavior in the current cohesive owners, prefer private helpers only where they reduce actual duplication or make a safety boundary clear, and do not split types just to evade complexity limits. Any necessary departure must identify its concrete contract/value and measured cost before being included. Public signatures, named arguments, synchronous defaults, supported PHP versions and normal return/key ordering remain compatibility constraints. - -Do not bundle unrelated formatter churn, generic serialization infrastructure, framework adapters or a runtime rewrite into these fixes. Review processor output and retain every active detector; no suppressions, baselines, exclusions or threshold increases. - -## Branch implementation status - -This is a rolling implementation tracker, not a release sign-off. The 5.3.1 baseline evidence earlier in this plan does not certify the current PR revision. Run the full final-revision matrix, release acceptance, and performance/worker checks before marking any batch release-complete. - -| Batch | Status at review update | Remaining work | -| --- | --- | --- | -| A — R01–R02 | **Complete.** Guarded sort, staged moves and regression tests passed PHPForge QA on the release candidate. | None in scope. | -| B — R03–R05 | **Complete.** Reference-isolated snapshots, source-origin cache refresh, and numeric namespace handling passed PHP 8.4/8.5 stable and lowest QA, static analysis and regression coverage. | None in scope. | -| C — R06–R11 | **Complete.** All six corrections and regressions passed. Optional Runwire 3.0.0 passed PHP 8.4/8.5 compatibility tests and the PHP 8.5 persistent-worker soak. | None in scope. | -| D — release acceptance | **Complete within available shared-runner evidence.** Documentation passes Sphinx warnings-as-errors; clean/no-dev install, PHPForge matrix, 5.3.1-baseline benchmark validity, and separate 300-second Release Acceptance worker soak all passed on `eb9cbcbc`. | The 2% stable-environment performance comparator remains explicitly **unverified** (not falsely passed). Publication/tagging are separate authorized actions. | - -## Release-candidate evidence boundaries - -The immediately previous tag is `5.3.1` at `e77bf72a40c64b94f62e69f3b2662ce2dc47efec`. The benchmark now compares against that tag and validates representative workload output, including snapshots. GitHub's shared runner does not assert a stable performance environment: the configured 2% regression threshold is reported as *unverified*, not silently passed. This is a separate, explicitly disclosed performance-evidence limitation; correctness and QA checks are required to remain green. - -The Sphinx HTML build with warnings treated as errors passed after resolving duplicate section labels, the lexer and heading issues. Runwire 3.0 remains optional, owned by the host application, with no Runwire 2.x qualification. **All required jobs succeeded** in [Security & Standards run 40997245493](https://github.com/infocyph/ArrayKit/actions/runs/40997245493) on code candidate `eb9cbcbc27695a6202a59593a83795a3fba111dd` (PHPForge QA stable/lowest 8.4/8.5, analysis, clean install, benchmark validity, Sphinx, exact Runwire 3.0 PHP 8.4/8.5, both persistent-worker soaks and Release Acceptance). This tracker-only synchronization commit changes no runtime, tests, workflow or benchmark files; verify its new final SHA before publication. - -## Release acceptance - -- [ ] Regressions reproduce all R01–R11 triggers on `5.3.1` and pass after their corrections. Include native/facade/collection/intermediary paths where applicable; retain all existing regressions. -- [x] Run PHPForge doctor/config inspection, sequential processors, `composer ic:tests:details`, and final `composer ic:tests` or `composer ic:release:guard`. Retain max analysis, 80/12 complexity limits, Psalm security checks, architecture, syntax/reference/duplicate/comment/skip checks, Rector, Pint and PHPCS. Keep live audit and manifest/constraint checks current. -- [x] Verify real PHP 8.4/8.5 stable/lowest dependency CI on the same final candidate commit, plus clean optimized `--no-dev` installation without Runwire. Record unavailable next-PHP-target checks explicitly rather than using platform emulation as execution evidence. -- [x] Make optional Runwire 3.0 verification reproducible on PHP 8.4 and 8.5 using Composer, rather than relying only on the original audit's temporary source-autoloader experiment. -- [x] Preserve the existing 300-second worker soak, 10-second warm-up, one-second sampling and 16 MiB RSS-growth ceiling; run repeated config/replay/request lifecycle work with Runwire 3.0 and correct host cleanup. Do not weaken the ceiling to pass a test. -- [x] Use **5.3.1** as the before/after performance baseline for this patch. The representative harness now uses the immediately previous 5.3.1 tag and includes snapshot behavior with result assertions. Check actual comparable CI evidence on the final candidate; the baseline change alone does not certify performance. Validate equivalent correct outputs before comparing throughput. -- [ ] Measure changed hot paths, config cold/warm/generated reads and snapshot cost, bounded failures, array/reference/one-shot replay, and bound/unbound Runwire pipelines. Include input-size scaling and a host-owned yielding workload so fairness/cancellation are measured, not merely synchronous materialization. -- [ ] Keep component evidence labelled component evidence. For an application-RPM claim, use an ArrayKit-owned minimal request/task consumer with warmed OPcache and optimized production loading, sustained synchronized measurement windows, several concurrency levels, at least three trials, validated successful RPM, p50/p95/p99, errors/timeouts, CPU and peak/steady RSS. Exclude setup/warm-up/cleanup from the steady-state denominator. Record exact source IDs, PHP/extensions/INI, OS/hardware, datasets, variance, queue/task state and runner stability. -- [x] Preserve the configured 2% throughput budget wherever a controlled matching stable environment is available. Predeclare workload latency/memory limits from baseline/host capacity. On a noisy/shared runner, report the comparator as skipped/unverified; never switch the stable flag on just to obtain a green badge. A host harness owned by ArrayKit can qualify library impact without making Foundation or other runtimes part of this task. -- [x] Keep user docs focused on behavior and migration. Put transient verification logs/results in CI or review artifacts, not in release documentation. Record limitations in the release decision without claiming unavailable external certification. -- [ ] Confirm every required workflow job against the final committed candidate; a green baseline or a newer Dependabot-only run is insufficient. Create/tag/publish the patch only as a separate authorized release action. - -## Optional follow-ups, not patch requirements - -- Unguarded cyclic/reference-containing membership/fingerprint inputs remain outside the current documented contract. An isolated cyclic `diff()` probe succeeded below the lookup threshold and exhausted a 32 MiB process at the optimized threshold. This is not counted among R01–R06 because the contract excludes that input; consider deterministic rejection only as a separately scoped hardening change with compatibility/performance tests. -- No compatible removal of the benchmark tool's abandoned development dependency was established here; track that upstream without removing benchmarks or weakening the audit. -- `ArraySingle::mode()` currently coerces canonical numeric-string values into integer keys through `array_count_values()`; for example `['1', '1']` yields the integer `1` rather than the original string. Decide whether the public mode contract should distinguish or preserve numeric strings before changing the semantics. This is a follow-up contract decision, not a silent 5.3.2 behavior change. -- Immutable cache generations have no built-in old-generation reclamation. The current documentation assigns retirement to deployment tooling after pinned workers have stopped; ensure the deployment process has a bounded retention/cleanup policy. Do not delete a generation while another worker may still have it pinned. -- Broad class/file-count reduction, multibyte text matching, new async APIs and new host-driver adapters have no demonstrated requirement in this audit and should not be added to the patch. - -Temporary local audit artifacts use `/tmp/arraykit-audit-*`; they are supporting logs/probes, not committed acceptance tests. The implementation must turn the required reproductions into repository tests before closing R01–R11. diff --git a/docs/config.rst b/docs/config.rst index 8210697..dd7378f 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -340,10 +340,14 @@ Merging, Snapshots, and Read-Only Mode Snapshots retain a by-value copy of scalar and nested array data. PHP references to external variables do not cause a later mutation to change an earlier -snapshot. ``restore()`` makes a fresh copy, so repeated restores remain safe. +snapshot. ``restore()`` uses PHP's copy-on-write isolation of these reference-free +arrays, so repeated restores remain safe without expanding shared storage. Repeated internal array references are copied independently; cyclic array references raise ``UnexpectedValueException`` before a valid named snapshot -can be overwritten. Object values remain shallow: the snapshot preserves +can be overwritten. Creation is bounded to depth 256 and 100,000 visited entries; +exceeding either limit raises ``UnexpectedValueException`` and leaves existing +snapshots intact. Completed shared references reuse detached array storage +without retaining writable aliases. Object values remain shallow: the snapshot preserves their identity and cannot reverse changes to an object's internal state. .. code-block:: php diff --git a/docs/migration.rst b/docs/migration.rst index 2c9f490..73c4a20 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -52,6 +52,9 @@ Runwire runtime must use a compatible 3.0 host instance. Normal array/snapshot APIs retain their signatures. Snapshotting now visits nested arrays to detach external references; object values remain shallow. A cyclic array reference is rejected at snapshot creation rather than saved. +Snapshot creation also rejects depth beyond 256 or more than 100,000 visited +entries before replacing an existing snapshot. Reused referenced subgraphs are +detached once per snapshot and shared through PHP copy-on-write storage. Recent Additions ---------------- diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index 4a5221b..ebf239d 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -222,6 +222,8 @@ private static function invokeValueCallback(callable $callback, mixed $value, in private static function overflowSafeAverage(array $array, int $count, float $fallback): float { $average = 0.0; + $correction = 0.0; + $scale = 2.0 ** (ceil(log($count, 2)) + 1); foreach ($array as $value) { $numeric = self::numericValue($value); if ($numeric === null) { @@ -232,10 +234,19 @@ private static function overflowSafeAverage(array $array, int $count, float $fal return $fallback; } - $average += $numeric / $count; + // Power-of-two scaling leaves overflow headroom while preserving + // small terms that may remain after large values cancel. + $term = $numeric / $scale; + $next = $average + $term; + $correction += abs($average) >= abs($term) + ? ($average - $next) + $term + : ($term - $next) + $average; + $average = $next; } - return $average; + $limit = PHP_FLOAT_MAX / $scale; + + return max(-$limit, min($limit, $average / $count + $correction / $count)) * $scale; } /** diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index 4691587..7e1798d 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -377,8 +377,18 @@ public static function rename(array &$array, string $from, string $to, bool $ove // source. Remove first to preserve parent/child path semantics. // User-defined object setters remain caller-owned side effects. $staged = $array; - self::forget($staged, $from); - self::set($staged, $to, $value, $overwrite); + [$references, $originals] = DotNotationPathOps::mutationReferences($array, [$from, $to]); + + try { + self::forget($staged, $from); + self::set($staged, $to, $value, $overwrite); + } catch (\Throwable $error) { + foreach ($originals as $id => $original) { + $references[$id] = $original; + } + + throw $error; + } $array = $staged; return true; diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index adce193..3ab2653 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -73,6 +73,25 @@ public static function matchesPath(mixed $target, array $segments, int $position && self::matchesPath($next, $segments, $position + 1); } + /** + * Capture only existing reference cells along mutation paths. Array copies + * protect ordinary slots; these backups protect aliases during rollback. + * + * @param array $array + * @param list $paths + * @return array{array, array} + */ + public static function mutationReferences(array $array, array $paths): array + { + $references = []; + $originals = []; + foreach ($paths as $path) { + self::captureMutationPath($array, self::splitPath($path), 0, $references, $originals); + } + + return [$references, $originals]; + } + /** * Normalize a dot-notation segment by replacing escaped values and resolving * special values such as '{first}' and '{last}'. @@ -244,6 +263,51 @@ public static function unescapeSegment(string $segment): string ); } + /** + * @param array $array + * @param array $references + * @param array $originals + */ + private static function captureMutationCell(array $array, int|string $key, array &$references, array &$originals): void + { + $reference = \ReflectionReference::fromArrayElement($array, $key); + if ($reference === null) { + return; + } + + $id = bin2hex($reference->getId()); + $originals[$id] = $array[$key]; + $references[$id] = &$array[$key]; + } + + /** + * @param array $array + * @param array $segments + * @param array $references + * @param array $originals + */ + private static function captureMutationPath( + array $array, + array $segments, + int $position, + array &$references, + array &$originals, + ): void { + $segment = $segments[$position]; + // Let PHP normalize canonical numeric strings to their real key type. + $keys = $segment === '*' ? array_keys($array) : array_keys([self::unescapeSegment($segment) => null]); + foreach ($keys as $key) { + if (!array_key_exists($key, $array)) { + continue; + } + + self::captureMutationCell($array, $key, $references, $originals); + if ($position + 1 < count($segments) && is_array($array[$key])) { + self::captureMutationPath($array[$key], $segments, $position + 1, $references, $originals); + } + } + } + /** * @phpstan-impure */ diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 6e8a2c7..d37fdbc 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -508,7 +508,9 @@ public function restore(string $name = 'default'): bool } $this->flushReadCache(); - $this->items = ConfigSnapshot::detach($this->snapshots[$name]); + // Saved arrays have no reference cells; PHP copy-on-write isolates + // later mutations without expanding shared array storage again. + $this->items = $this->snapshots[$name]; return true; } diff --git a/src/Config/Support/ConfigSnapshot.php b/src/Config/Support/ConfigSnapshot.php index 77d7804..3eedd7a 100644 --- a/src/Config/Support/ConfigSnapshot.php +++ b/src/Config/Support/ConfigSnapshot.php @@ -10,6 +10,10 @@ /** @internal */ final class ConfigSnapshot { + private const int MAX_DEPTH = 256; + + private const int MAX_NODES = 100000; + /** * Snapshot arrays and scalar values by value while retaining object identity. * Repeated array references become independent values; cyclic array references @@ -21,38 +25,68 @@ final class ConfigSnapshot public static function detach(array $items): array { $active = []; + $copies = []; + $visited = 0; - return self::copy($items, $active); + return self::copy($items, $active, $copies, $visited); } /** * @param array $items * @param array $active + * @param array $copies * @return array */ - private static function copy(array $items, array &$active): array + private static function copy(array $items, array &$active, array &$copies, int &$visited, int $depth = 1): array { + if ($depth > self::MAX_DEPTH) { + throw new UnexpectedValueException('Configuration snapshot exceeded max depth.'); + } + $output = []; foreach ($items as $key => $value) { - $reference = ReflectionReference::fromArrayElement($items, $key); - $id = $reference !== null ? bin2hex($reference->getId()) : null; - if ($id !== null && isset($active[$id])) { - throw new UnexpectedValueException('Configuration snapshot contains a cyclic array reference.'); + if (++$visited > self::MAX_NODES) { + throw new UnexpectedValueException('Configuration snapshot exceeded max node count.'); } + $reference = ReflectionReference::fromArrayElement($items, $key); + if ($reference === null) { + $output[$key] = is_array($value) ? self::copy($value, $active, $copies, $visited, $depth + 1) : $value; - if ($id !== null) { - $active[$id] = true; + continue; } - try { - $output[$key] = is_array($value) ? self::copy($value, $active) : $value; - } finally { - if ($id !== null) { - unset($active[$id]); - } - } + $output[$key] = self::copyReference($value, bin2hex($reference->getId()), $active, $copies, $visited, $depth); } return $output; } + + /** + * @param array $active + * @param array $copies + */ + private static function copyReference( + mixed $value, + string $id, + array &$active, + array &$copies, + int &$visited, + int $depth, + ): mixed { + if (isset($active[$id])) { + throw new UnexpectedValueException('Configuration snapshot contains a cyclic array reference.'); + } + + if (array_key_exists($id, $copies)) { + return $copies[$id]; + } + + $active[$id] = true; + + try { + return $copies[$id] = is_array($value) ? self::copy($value, $active, $copies, $visited, $depth + 1) : $value; + } finally { + unset($active[$id]); + } + } } diff --git a/tests/Feature/Release532BatchATest.php b/tests/Feature/Release532BatchATest.php index 85216cc..97758ea 100644 --- a/tests/Feature/Release532BatchATest.php +++ b/tests/Feature/Release532BatchATest.php @@ -89,3 +89,38 @@ public function __set(string $name, mixed $value): void ->and(DotNotation::rename($unchanged, 'from', 'to', overwrite: false))->toBeFalse() ->and($unchanged)->toBe(['from' => 1, 'to' => 2]); }); + +it('rolls back existing array reference cells after rejected moves', function () { + $shared = ['source' => 42, 'keep' => 7]; + $target = new class { + public readonly int $locked; + public function __construct() { $this->locked = 1; } + }; + $items = ['nested' => &$shared, 'target' => $target]; + expect(fn () => DotNotation::rename($items, 'nested.source', 'target.locked')) + ->toThrow(InvalidArgumentException::class) + ->and($shared)->toBe(['source' => 42, 'keep' => 7]) + ->and($items['nested'])->toBe($shared); +}); + +it('preserves successful moves through aliased array parents', function () { + $shared = ['source' => 42]; + $items = ['left' => &$shared, 'right' => &$shared]; + expect(DotNotation::move($items, 'left.source', 'right.source.child'))->toBeTrue() + ->and($shared)->toBe(['source' => ['child' => 42]]) + ->and($items['left'])->toBe($items['right']); +}); + +it('rolls back referenced wildcard destinations as well as the source', function () { + $value = 5; + $source = ['value' => 42]; + $target = new class { + public readonly int $value; + public function __construct() { $this->value = 1; } + }; + $items = ['source' => &$source, 'rows' => [['value' => &$value], $target]]; + expect(fn () => ArrayKit::dot()->move($items, 'source.value', 'rows.*.value')) + ->toThrow(InvalidArgumentException::class) + ->and($source)->toBe(['value' => 42]) + ->and($value)->toBe(5); +}); diff --git a/tests/Feature/Release532BatchBTest.php b/tests/Feature/Release532BatchBTest.php index fdf3641..6f89c06 100644 --- a/tests/Feature/Release532BatchBTest.php +++ b/tests/Feature/Release532BatchBTest.php @@ -77,6 +77,62 @@ ->and($config->get('safe.v'))->toBe(1); }); +it('snapshots shared acyclic graphs without expanding their storage', function () { + $script = <<<'PHP' +require $argv[1]; + $graph = ['value' => 1]; + for ($depth = 0; $depth < 20; $depth++) { + $child = $graph; + $graph = ['left' => &$child, 'right' => &$child]; + unset($child); + } + $config = new \Infocyph\ArrayKit\Config\Config(); + $config->loadArray(['app' => $graph]); + $before = memory_get_usage(); + $config->snapshot(); + $growth = memory_get_usage() - $before; + $path = 'app.' . str_repeat('left.', 20) . 'value'; + $other = 'app.' . str_repeat('right.', 20) . 'value'; + $config->restore(); + $config->set($path, 9); + $independent = $config->get($other) === 1; + $config->restore(); + echo json_encode(['growth' => $growth, 'independent' => $independent, 'restored' => $config->get($path)]); +PHP; + $process = new \Symfony\Component\Process\Process([ + PHP_BINARY, '-d', 'memory_limit=32M', '-r', $script, '--', dirname(__DIR__, 2) . '/vendor/autoload.php', + ]); + $process->setTimeout(10); + $process->run(); + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); + $result = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + expect($result['growth'])->toBeLessThan(4 * 1024 * 1024) + ->and($result['independent'])->toBeTrue() + ->and($result['restored'])->toBe(1); +}); + +it('rejects excessive snapshot depth without overwriting a valid snapshot', function () { + $config = new Config(); + $config->loadArray(['value' => 1]); + $config->snapshot(); + $deep = []; + for ($depth = 0; $depth < 260; $depth++) { $deep = ['next' => $deep]; } + $config->replace($deep); + expect(fn () => $config->snapshot())->toThrow(UnexpectedValueException::class, 'max depth'); + $config->restore(); + expect($config->get('value'))->toBe(1); +}); + +it('enforces the snapshot node budget without replacing a valid snapshot', function () { + $config = new Config(); + $config->loadArray(['value' => 1]); + $config->snapshot(); + $config->replace(['wide' => array_fill(0, 100000, 1)]); + expect(fn () => $config->snapshot())->toThrow(UnexpectedValueException::class, 'max node count'); + $config->restore(); + expect($config->get('value'))->toBe(1); +}); + it('refreshes source-origin namespace state after a new generation is published', function () { $root = sys_get_temp_dir() . '/arraykit-532-' . bin2hex(random_bytes(6)); $source = $root . '/source'; diff --git a/tests/Feature/Release532BatchCTest.php b/tests/Feature/Release532BatchCTest.php index 7e3332c..41fba64 100644 --- a/tests/Feature/Release532BatchCTest.php +++ b/tests/Feature/Release532BatchCTest.php @@ -68,3 +68,14 @@ ->toThrow(BadMethodCallException::class) ->and($collection->values()->all())->toBe([3, 1, 2]); }); + +it('keeps finite extreme averages inside the input range', function (int $count) { + expect(ArraySingle::avg(array_fill(0, $count, PHP_FLOAT_MAX)))->toBe(PHP_FLOAT_MAX) + ->and(ArraySingle::avg(array_fill(0, $count, -PHP_FLOAT_MAX)))->toBe(-PHP_FLOAT_MAX); +})->with([3, 7, 11, 21, 100, 1000]); + +it('retains small remainders when extreme finite values cancel during averaging', function (float $remainder) { + $extremes = [PHP_FLOAT_MAX, PHP_FLOAT_MAX, -PHP_FLOAT_MAX, -PHP_FLOAT_MAX]; + expect(ArraySingle::avg([...$extremes, $remainder]))->toBe($remainder / 5) + ->and(ArraySingle::avg([$remainder, ...$extremes]))->toBe($remainder / 5); +})->with([1.0, 1e-100, 1e-300, -1.0, -1e-100, -1e-300]);