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/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 5eecba0..7f98ace 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -34,6 +34,73 @@ 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 }}" + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + 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 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 + --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 with Runwire 3.0 + 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 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-3.0-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..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 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` 3.0 on PHP 8.4 and 8.5; ordinary ArrayKit installation has no Runwire runtime dependency. ## Installation 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 */ diff --git a/composer.json b/composer.json index 07bcb13..12fa277 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": "^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 3.0." }, "minimum-stability": "stable", "prefer-stable": true, 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/collection.rst b/docs/collection.rst index d46acce..bd931e2 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. 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/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-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..dd7378f 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 @@ -338,6 +338,30 @@ 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()`` 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. 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 + + 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/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. diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 70ce487..b449c01 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -76,9 +76,14 @@ 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. +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..73c4a20 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -36,6 +36,26 @@ 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. +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/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 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/ArraySingle.php b/src/Array/ArraySingle.php index c81bc55..fa7c0ac 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -20,26 +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; - - return fmod($avg, 1.0) === 0.0 ? (int) $avg : $avg; + return ArraySingleOps::avg($array); } /** @@ -312,7 +293,7 @@ public static function isNegative(array $array): bool } $hasNumeric = true; - if ($numeric >= 0) { + if (!($numeric < 0)) { return false; } } @@ -337,7 +318,7 @@ public static function isPositive(array $array): bool } $hasNumeric = true; - if ($numeric <= 0) { + if (!($numeric > 0)) { return false; } } @@ -453,9 +434,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/ArraySingleOps.php b/src/Array/ArraySingleOps.php index 42058b9..ebf239d 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 @@ -173,6 +214,41 @@ private static function invokeValueCallback(callable $callback, mixed $value, in return $callback($value, $key); } + /** + * 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; + $correction = 0.0; + $scale = 2.0 ** (ceil(log($count, 2)) + 1); + foreach ($array as $value) { + $numeric = self::numericValue($value); + if ($numeric === null) { + continue; + } + + if (!is_finite((float) $numeric)) { + return $fallback; + } + + // 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; + } + + $limit = PHP_FLOAT_MAX / $scale; + + return max(-$limit, min($limit, $average / $count + $correction / $count)) * $scale; + } + /** * @param array $array */ 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/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index b876f4c..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); @@ -1256,6 +1273,13 @@ private static function sortRecursiveWithGuards( } foreach ($array as &$value) { + if (self::hasUnsafeSortObject($value, $throwOnTooDeep)) { + $complete = false; + unset($value); + + return $array; + } + if (!is_array($value)) { continue; } diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index 1f6b536..7e1798d 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -373,8 +373,23 @@ 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; + [$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/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/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php index 19219ed..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) { - // Runwire 2.1.1 guards 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); } } diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index e85e102..d37fdbc 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; @@ -199,6 +200,10 @@ public function getEnum(string|int $key, string $enumClass, ?\UnitEnum $default return $default; } + if (get_debug_type($value) !== new \ReflectionEnum($enumClass)->getBackingType()?->getName()) { + return $default; + } + return $enumClass::tryFrom($value) ?? $default; } @@ -503,6 +508,8 @@ public function restore(string $name = 'default'): bool } $this->flushReadCache(); + // 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; @@ -533,7 +540,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..ea3cd13 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]); } } @@ -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 bbcbd07..8e5f772 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,20 +48,13 @@ public function __construct( } } - /** @return array */ + /** @return array */ #[\Override] public function all(): array { $this->materializeKnownNamespaces(); - $items = []; - foreach (parent::all() as $key => $value) { - if (is_string($key)) { - $items[$key] = $value; - } - } - - return $items; + return parent::all(); } #[\Override] @@ -216,7 +209,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 +238,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 +310,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..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; @@ -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..3eedd7a --- /dev/null +++ b/src/Config/Support/ConfigSnapshot.php @@ -0,0 +1,92 @@ + $items + * @return array + */ + public static function detach(array $items): array + { + $active = []; + $copies = []; + $visited = 0; + + 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 &$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) { + 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; + + continue; + } + + $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 new file mode 100644 index 0000000..97758ea --- /dev/null +++ b/tests/Feature/Release532BatchATest.php @@ -0,0 +1,126 @@ +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} value {$value}"); + } + }; + + $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]); +}); + +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 new file mode 100644 index 0000000..6f89c06 --- /dev/null +++ b/tests/Feature/Release532BatchBTest.php @@ -0,0 +1,203 @@ + 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('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]]); + $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('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'; + $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); + } +}); diff --git a/tests/Feature/Release532BatchCTest.php b/tests/Feature/Release532BatchCTest.php new file mode 100644 index 0000000..41fba64 --- /dev/null +++ b/tests/Feature/Release532BatchCTest.php @@ -0,0 +1,81 @@ + 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('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]); + + expect(fn () => $collection->numericAggregate(null, true)) + ->toThrow(BadMethodCallException::class) + ->and(fn () => $collection->unrecognizedInternalMethod()) + ->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]); 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 @@ +