From 21f9d4012de89bad43a17673e1bc2bd41a890afe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:17:55 +0600 Subject: [PATCH 001/129] :memo: docs(arraykit): add ArrayKit 5.3.0 implementation and release plan - Introduce comprehensive review and release plan document for ArrayKit 5.3.0 :memo: - Outline 12 confirmed fixes, security boundaries, and cache lifecycle requirements :art: - Detail optional Runwire 2.1.1 instance integration and acceptance gates :white_check_mark: --- arraykit-review-and-release-plan.md | 314 ++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 arraykit-review-and-release-plan.md diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md new file mode 100644 index 0000000..073c603 --- /dev/null +++ b/arraykit-review-and-release-plan.md @@ -0,0 +1,314 @@ +# ArrayKit 5.3.0 implementation and release plan + +Date: 2026-10-04 (Asia/Dhaka) + +Status: review complete; scope consolidated for a single 5.3.0 release; implementation and release acceptance remain open. + +Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. +Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. +The reviewed source, tests, benchmarks and Composer manifest are unchanged from that tag; the subsequent change removed the root CaptainHook configuration. + +This plan follows the installed [PHPForge engineering principles](vendor/infocyph/phpforge/resources/engineering-principles.md) and [agent workflow](vendor/infocyph/phpforge/resources/AGENTS.md). It distinguishes defect corrections from additive enhancements, preserves public contracts, assigns changes to existing owners, and requires correctness, compatibility and representative performance evidence before tagging. + +## Decision + +Target **5.3.0 directly**, as requested. Deliver the **12 confirmed fixes**, including five P1 findings, the scoped improvements below, and additive Runwire 2.1.1 instance integration through one implementation sequence and one release candidate. There is no intermediate patch release or separate later Runwire release track. + +Runwire integration is part of the 5.3.0 delivery scope. Its use remains optional for consumers: passed instances enable relevant capabilities, and unbound consumers retain the normal path. Performance and lifecycle evidence are release gates for the integration, not a reason to silently drop it from the plan. + +Keep PHP 8.4 support. Do not change existing loose/strict comparison defaults, list-versus-map merge semantics, helper opt-in behavior, or established mutation signatures as incidental cleanup. If a proposed fix actually removes a supported contract, revise the version and migration decision before implementing it. + +The highest-priority security-related finding is ineffective resource limiting in APIs explicitly advertised for untrusted/deep data. Exploitability depends on a host passing attacker-controlled data or paths into those APIs. This review did not establish a standalone remote-code-execution exploit or authentication bypass. PHP config files and generated PHP caches are executable, trusted deployment inputs; corruption fallback is not a sandbox. + +## Consolidated 5.3.0 scope + +| Workstream | Release commitment | +| --- | --- | +| Correctness and security boundaries | Resolve R01-R12 and cover native APIs, facades and collection/pipeline wrappers. | +| Configuration lifecycle | Coherent lazy/layered mutations, snapshots, memo invalidation, authoritative rebuilds and validated generated artifacts, with cache migration guidance. | +| Lazy collections | Preserve terminal source failures, measure and reduce avoidable replay allocations, and add explicit passed-instance Runwire binding with bounded cancellation/yield checkpoints. | +| DTO graph handling | Add compatible bounded graph entry points with cycle/depth/node handling; retain ordinary DTO contracts. | +| Ownership and structural improvements | Consolidate the reported repeated logic in its existing owners and document request/worker/cache/stream ownership. | +| Dependencies and tooling | Keep Runwire optional at runtime, verify its exact supported version in integration tests, assess development dependency hygiene, and retain all PHPForge detectors. | +| Release evidence | One final 5.3.0 SHA, complete compatibility/consumer/runtime checks, measured performance and soak evidence, CI, release notes and acceptance decision. | + +Speculative APIs, unrelated rewrites and unsupported asynchronous filesystem claims remain outside this scope. The development-only abandoned dependency remains a recorded upstream maintenance item when no compatible replacement is available; it does not become a new blocker contrary to the established PHPForge audit policy. + +## Verified evidence + +| Check | Result and boundary | +| --- | --- | +| Review coverage | All 35 production PHP files; array/set/query operations, dot paths, collections/pipeline/lazy iteration, config/layering/caches, dotenv/environment, DTOs, hooks, facade/helpers; relevant tests, benchmarks, Composer and CI configuration. Graphify used for navigation; findings checked in source and runtime probes. | +| PHPForge doctor/config resolution | Healthy; bundled tool configurations active. Cognitive budgets: class 80, function 12, dependency tree 120; PHPStan level max. | +| Local detailed quality suite | `composer ic:tests:details` passed on PHP 8.5.4: 298 tests, 6,710 assertions; syntax, references, duplicate/comment checks, formatting, architecture, PHPStan, Psalm security analysis and Rector dry run passed. | +| Final local full suite | `composer ic:tests` passed on the same source revision. | +| Manifest and constraints | `composer validate --strict`, `composer ic:release:constraints`, and `composer ic:skipper` passed. | +| Dependency audit | Live `composer audit --locked --format=json`: zero advisories; one abandoned development dependency, `doctrine/annotations`, required by PHPBench 1.7.0. Raw Composer exits 1 for abandonment. `composer ic:release:audit` passes and reports abandonment as non-blocking. There are no third-party runtime package dependencies to audit under `--no-dev`. | +| Local benchmark smoke | `composer ic:bench:quick` passed: 99 subjects, zero failures/errors, zero performance assertions. PHP 8.5.4, Xdebug absent, CLI OPcache disabled. This is execution evidence, not production throughput or a regression comparison. | +| Exact revision CI | [Run 37180322867](https://github.com/infocyph/ArrayKit/actions/runs/37180322867) succeeded for the reviewed SHA: PHP 8.4/8.5 analysis, prefer-stable/prefer-lowest QA, benchmarks, clean install and security report. Benchmark-result validation and the regression comparison were skipped because representative result/baseline inputs were empty. | +| Release metadata | Live [ArrayKit package metadata](https://repo.packagist.org/p2/infocyph/arraykit.json) confirms 5.2.0. [Runwire metadata](https://repo.packagist.org/p2/infocyph/runwire.json) confirms 2.1.1 at `745b1c2bd7caa56aa5abf6d742c1aa8318fec494`; the matching local tag was inspected. | +| Independent probes | Reproductions below found failures outside the baseline suite. An additional 228 scalar/array membership cases across optimization thresholds matched native `in_array()`; native loose object-to-number comparisons produced expected PHP notices. | + +Local PHP 8.4 and the next PHP upgrade target were not available for new probes. Existing CI covers baseline 8.4/8.5 behavior, not the new repro cases or future fixes. No representative host RPM comparison, persistent-worker soak, or downstream consumer acceptance was performed. No source fixes, dependency changes, release or tag were made during this review. + +## Required findings + +Priority P1 means correct before the next release; P2 means a confirmed defect with a narrower trigger, also included in the 5.3.0 scope. These are engineering priorities, not CVSS ratings. + +### R01 — P1: traversal limits do not bound the advertised work + +Owners: `src/Array/ArrayMulti.php`, `src/Array/Concerns/ArrayMultiQuerySortTrait.php`, `src/Array/DotNotationPathOps.php`, `src/Array/Concerns/DotNotationPublicApiTrait.php`. + +- `flattenGuarded(range(1, 10000), maxNodes: 2, throwOnTooDeep: true)` returns all 10,000 values. `depthGuarded()` and `sortRecursiveGuarded()` similarly count recursive array calls rather than every inspected element; flat arrays escape the budget. +- `getSafe(['rows' => range(1, 10000)], 'rows.*', maxNodes: 2, throwOnTooDeep: true)` returns 10,000 values. Terminal wildcard fan-out consumes no additional node budget. +- Non-throwing `rows.*.id` traversal with a budget of 3 still returns 10,000 entries and invokes the default closure 9,999 times after exhaustion. +- A 12-level singleton array traversed through wildcard-only segments does not throw with `maxDepth: 2`; wildcard recursion does not advance/check depth consistently. + +Fix the actual traversal owners. Define one documented node/depth accounting policy, count examined children and wildcard fan-out, and stop work globally when the budget is exhausted. Bound sort/comparison work and default-callback execution as well as recursion. Apply the budget consistently to multiple requested paths; document whether it is shared per call. Retain established non-throwing depth behavior where possible, with explicit bounded results on exhaustion. Do not silently disable guards for a fast path. + +Acceptance: flat/wide/deep and cyclic reference arrays; terminal/repeated wildcards; multiple keys; exact budget boundaries; zero/negative limit contract; throw/non-throw modes; default-call counts; guarded-versus-normal equivalence below the limit. Demonstrate that processed elements and output allocations stop growing with input width after the budget is exhausted. + +### R02 — P1: layered config inherits incompatible state operations + +Owners: `src/Config/LayeredLazyFileConfig.php` and `src/Config/Concerns/BaseConfigTrait.php`. + +- `set('app.name', 'caller')` before the first read is overwritten by source materialization; the read returns `source`. +- A cold `get()` returns `[]`, while `all()` returns the configured namespaces. +- Snapshot before first read, read, restore, then read returns the default: namespace-materialization flags no longer match restored items. +- Cold `exportCache()` returns success and writes an empty configuration even when known namespaces exist. + +Give `LayeredLazyFileConfig` explicit ownership of materialization plus mutations and snapshots. Materialize affected namespaces before partial writes, preserve runtime writes at their intended precedence, and define complete replacement/reload semantics. Keep materialization flags, known namespaces and snapshot state consistent. Full retrieval/export must materialize the known namespace set, or explicitly reject unsupported operations before changing state. Preserve fallback < source < constructor overrides and atomic list replacement. Review inherited fill/forget/merge/overlay/loadArray/loadFile/reload/replace/set(null)/append/prepend/hooks/readonly behavior, not only `set()`. + +Acceptance: every inherited operation before and after first read; fallback/source/override overlaps; nulls and lists; unknown namespaces; snapshot/restore/replacement; cold/warm exports; readonly mode; direct and intermediary consumer use. + +### R03 — P1: facade calls lose reference mutations + +Owners: `src/Facade/ModuleProxy.php`, `src/ArrayKit.php`, `docs/facade.rst`. + +`ArrayKit::dot()->set($data, 'app.name', 'new')` returns success while `$data` remains unchanged. `ArrayKit::helper()->forget($data, 'remove')` also leaves the caller's array unchanged. Magic `__call()` receives a value argument array and cannot recover the original caller references. The facade documentation promises preservation of the native module API. + +Add explicit reference-preserving mutator entry points at the existing facade boundary, with target-appropriate signatures and dispatch. Cover dot set/fill/forget/rename/move/offsetSet/offsetUnset and helper forget. Preserve existing proxy return types and cached-proxy identity. Avoid a new generic reflection layer on each call or a success response for an unsupported mutation. Native static methods remain the correctness reference. + +Acceptance: caller array actually changes; return values match native calls; named arguments, overwrite/fill flags, literal/escaped paths and nested paths; invalid/missing/private methods still fail normally. + +### R04 — P1: memoization can return stale mutable or cache-derived values + +Owners: `src/Config/Concerns/BaseConfigTrait.php`, `src/Config/Concerns/LazyFileConfigCacheTrait.php`, `src/Config/LazyFileConfig.php`. + +Two confirmed triggers: + +1. Read `app.name` through an object stored in `Config`, mutate that object externally, then read again: the enabled-by-default memo returns the old scalar. +2. Resolve a leaf through `__flat.php`, then call `namespaceCache(null)`: the next read still returns the old cache leaf and the source namespace remains unloaded. Changing the cache source resets the flat index but not the resolved-value memo. + +Memoize only values whose immutability can be established under the actual input contract. Avoid caching paths through externally mutable objects/references; do not prohibit supported mixed values merely to simplify the cache. Clear relevant memo entries when changing cache sources, invalidating/rebuilding flat artifacts, or materializing a previously flat-only namespace. Define which loaded runtime values remain authoritative so cache invalidation cannot discard intentional caller writes. + +Acceptance: object mutation and PHP array references; read-cache enabled/disabled equivalence; cache directory A -> B -> null; selective/full flush; namespace structural reads after scalar reads; cached null and misses; loaded caller mutations remain intact. + +### R05 — P2: the valid `__flat` namespace collides with an internal artifact + +Owners: `src/Config/LazyFileConfig.php`, `src/Config/Concerns/LazyFileConfigCacheTrait.php`. + +`__flat` passes namespace validation. Warming it writes its namespace data to `__flat.php`, then overwrites that file with the generated flat index. A fresh `get('__flat.name')` returns the default rather than the source value. + +Move the internal flat artifact to a filename/layout outside the accepted namespace-file space. Keep valid caller namespaces usable. Treat generated cache layout as versioned/disposable metadata; document rebuilding old artifacts during upgrade and test legacy-reader behavior deliberately. Do not simply narrow the accepted namespace regex and call it compatibility-preserving. + +Acceptance: `__flat` source namespace; all accepted filename forms; alternate extensions; selective/full cache flush; unrelated directory entries; cold/warm structural and exact reads. + +### R06 — P2: cache warm-up can perpetuate stale generated configuration + +Owner: `src/Config/Concerns/LazyFileConfigCacheTrait.php`; documentation: `docs/lazy-config.rst`. + +Warm a source containing `Environment::ref()`, change the process value, create a new `LazyFileConfig`, and warm again: the new warmer reads the previous generated namespace and republishes the old value. Documentation says rerunning warm-up can update changed environment values. + +Define an authoritative rebuild path that reads the source, resolves current environment references and generates consistent artifacts without first importing stale generated values. Preserve the documented ability to intentionally cache caller-provided in-memory overrides. Make that precedence explicit. Clear all affected memos. Use atomic publication and a deployment-owned immutable directory/generation for multi-file rebuilds; writer locking alone does not make unlocked readers see a coherent generation. + +Acceptance: changed environment/source on a fresh warmer; same-instance behavior; in-memory overrides; cache-only deployments; failed writes leave the last valid generation usable; concurrent publishers/readers; OPcache/restart policy. Update docs to recommend build/deployment warm-up rather than normal request-time regeneration. + +### R07 — P1: replayable lazy sources swallow failure on subsequent traversals + +Owner: `src/Collection/LazyCollection.php`. + +A one-shot generator yields one item then throws `RuntimeException`. First `all()` throws; second `all()` silently succeeds with only the cached first item. The source failure is lost after the generator closes, allowing incomplete data to be presented as a successful result. This matters for failed database cursors and host cancellation as well as ordinary generator errors. + +Persist terminal source failure at its stream position and rethrow it on every traversal that reaches that boundary. Replaying a successfully consumed prefix remains valid. Handle failure during initialization, `valid()`, `current()` and `next()` consistently; release the exhausted source when practical. Preserve lazy consumption and interleaved-cursor behavior. + +Acceptance: failure before first yield and after several yields; repeated full and prefix reads; interleaved cursors; consumer callback errors versus source errors; cancellation exceptions; factory-backed renewable sources retain their independent semantics. + +### R08 — P2: compiled config export can report success for unusable PHP + +Owner: `src/Config/Concerns/BaseConfigTrait.php`. + +Exporting config with an anonymous object returns true but the generated cache raises `ParseError` when included. Named objects without a usable `__set_state()` can likewise produce non-loadable values; resources are not safely round-tripped by generic `var_export()`. + +Define and enforce the cacheable value contract before publication: scalar/null/arrays, recursively resolved closures/EnvReference, and explicitly supported exportable values such as enums where verified. Reject unsupported objects/resources and cycles with a clear exception, or deliberately support a safe reconstruction contract. Keep an existing valid cache intact on rejection. Check full file-write completion and generated syntax without executing arbitrary source as a validation shortcut. Do not add request-time lint processes; generation is a build/admin operation. + +Acceptance: valid scalar/null/enum and nested data; anonymous/named objects; resources; cyclic arrays/closures; failure preserves old artifact; warm whole-config and namespace exports round-trip identically. + +### R09 — P2: row membership optimization changes strict resource equality + +Owner: `src/Array/Concerns/ArrayMultiQuerySortTrait.php`; reusable engine: `src/Array/ArrayValueSetOps.php`. + +For two distinct closed resources, native `in_array(..., true)` returns false. With a 256-entry value set containing the first resource, `whereIn()` incorrectly accepts the second, `whereNotIn()` removes it, and `firstWhereIn()` reports a match. The row lookup path handles NaN but does not share the set engine's closed-resource fallback; both resources receive the same non-identity fingerprint. + +Reuse the existing equality owner's eligibility/fallback logic rather than extending an independent partial checklist. Keep exact PHP comparison semantics on both sides of the optimization threshold. + +Acceptance: threshold-minus-one/threshold/threshold-plus-one; distinct and identical live/closed resources; resources nested in arrays; NaN; object identity; null/false/zero; direct row helpers and Pipeline/Collection composition. + +### R10 — P2: wildcard presence treats an existing empty array as missing + +Owners: `src/Array/Concerns/DotNotationPublicApiTrait.php`, `src/Array/DotNotationPathOps.php`. + +`DotNotation::matches(['rows' => [['value' => []]]], 'rows.*.value')` returns false despite the value existing. The result checker recursively inspects value arrays as though they were only wildcard result containers, losing the distinction between an existing empty-array leaf and no match. + +Resolve wildcard presence through path traversal with an explicit missing marker, distinguishing result structure from leaf values. Preserve any-match semantics and null presence. Do not replace presence checks with truthiness. + +Acceptance: empty array/null/false/zero/empty-string leaves, missing leaves, empty parent lists, multiple wildcards, escaped selectors and literal wildcard keys. + +### R11 — P2: SQL-like exact patterns accept a trailing newline + +Owner: `src/Array/Concerns/ArrayMultiQuerySortTrait.php::whereLike()`. + +`whereLike([['name' => "admin\n"]], 'name', 'admin')` returns the row. PCRE `$` accepts the position before a final newline, violating whole-value literal matching. The `%` and `_` conversions also need an explicit newline/byte/Unicode contract. + +Use true whole-string anchoring, document the intended wildcard character semantics, and test them. Keep `preg_quote()` protection. Exercise adversarial wildcard patterns and handle PCRE failure distinctly from an ordinary no-match; choose a bounded matcher if measurements show pathological backtracking. Do not silently broaden matching or alter case defaults. + +Acceptance: exact patterns with terminal/internal newlines; `%`/`_`; empty strings; regex metacharacters; case modes; configured PCRE limit exhaustion; wrapper equivalence. + +### R12 — P2: valid large pagination inputs overflow before slicing + +Owner: `src/Array/ArraySingle.php::paginate()`; wrapper: `src/Collection/Pipeline.php`. + +`paginate([1, 2], PHP_INT_MAX, 2)` raises `TypeError` because `(page - 1) * perPage` becomes a float. Both inputs satisfy the published positive-integer preconditions. + +Check whether the page is beyond the array's possible range before multiplying, using overflow-safe integer arithmetic. Return an empty page for a valid out-of-range request. Keep existing invalid-argument exceptions and key preservation. + +Acceptance: empty arrays, first/last/out-of-range pages, `PHP_INT_MAX` boundaries, large per-page values, key preservation and Pipeline delegation. + +## 5.3.0 improvements and engineering debt + +- **I01 — Replay memory:** `LazyCollection::from()` memoizes all consumed entries, including array-backed sources; retained one-shot streams can grow without bound. `fromFactory()` already provides a renewable path without that replay memo. Measure array-backed versus generator/factory traversal and specialize array input when this removes allocations without changing replay, keys, laziness or failure semantics. Document bounded consumption and lifetime expectations. A new non-replayable API requires demonstrated need beyond the existing factory API; do not silently discard one-shot replay semantics. +- **I02 — DTO graph boundaries:** deep export and nested hydration have no cycle/depth/node budget. Cyclic DTO graphs can exhaust resources. Add bounded export/hydration entry points for arbitrary graphs, with explicit limits, cycle handling and clear failure behavior; keep ordinary DTO APIs compatible. Test self-cycles, mutual cycles, shared acyclic objects, deep/wide arrays, inherited properties and readonly behavior. Avoid reflection caches holding instances or request state. +- **I03 — Duplicate groups:** PHPProbe reports three passing clone groups (139 lines; 1.19%): contains-all/contains-any setup in `ArrayValueSetOps`, strict unique/derived-row loops, and config append/prepend setup. Inspect each group's shared responsibility and centralize repeated logic in its existing owner. Preserve distinct thresholds, short-circuit behavior and array-versus-row semantics; do not merge unrelated code merely to lower a metric. Verify every affected caller and throughput. +- **I04 — Tool dependency hygiene:** assess a compatible PHPForge/PHPBench update that removes abandoned `doctrine/annotations` when an upstream replacement is available. It is development-only, has no replacement declared, and is not a published advisory. Record the outcome in the 5.3.0 evidence; an unavailable upstream replacement remains a maintenance item under the existing non-blocking policy. Do not remove benchmark coverage, edit vendor or weaken audit policy to silence it. +- **I05 — Trust and lifecycle docs:** explicitly document trusted PHP source/cache directories, deployment-owned writes, secret-bearing artifacts, request-scoped mutable config/hooks, shallow collection copies, retained replay caches, Runwire binding lifetimes, and cleanup/replacement in persistent workers. Cache fallback cannot make an untrusted PHP file safe to include. Update examples and migration guidance alongside the corresponding implementation. + +## Runwire 2.1.1 integration for 5.3.0 + +Exact upstream sources: [RuntimeContext](https://github.com/infocyph/Runwire/blob/2.1.1/src/RuntimeContext.php), [RequestContext](https://github.com/infocyph/Runwire/blob/2.1.1/src/RequestContext.php), [CoroutineScope](https://github.com/infocyph/Runwire/blob/2.1.1/src/Coroutine/CoroutineScope.php), [CoroutineRuntime](https://github.com/infocyph/Runwire/blob/2.1.1/src/Coroutine/CoroutineRuntime.php). + +`RuntimeContext` is metadata/capabilities, not an event loop or asynchronous filesystem service. `RequestContext` supplies cancellation/deadlines and single-use lifecycle state. `CoroutineScope` supplies the active scheduler scope, task cancellation and `yieldNow()`. + +| ArrayKit work | Potential benefit | Recommendation | +| --- | --- | --- | +| Long factory-backed lazy traversal | Request/task cancellation and bounded cooperative yielding improve fairness and stop obsolete work. | Implement consumer-opt-in instance integration and verify under representative concurrent host load. | +| Short config reads and array helpers | Capability inspection/checkpoint overhead can exceed the work. | Preserve the direct common path. | +| Dotenv parsing, config `include`, filesystem cache writes | Context flags do not make these native operations non-blocking. | Perform during bootstrap/build/admin stages; no asynchronous-I/O claim. | +| Cache warm-up | Cancellation between namespaces may help administrative tasks. | Optional; do not yield while holding the exclusive filesystem lock or expose partial generations. | +| Parallel array callbacks | Would change order, callback side effects, ownership and failure behavior. | No automatic worker/task spawning. | + +The audit prototype used the exact 2.1.1 source with existing `LazyCollection::fromFactory()`. The host created and drove `CoroutineRuntime::runRequest()`; an intermediary forwarded the same context/request/scope instances into the factory. It produced `[2,4,6,8,10]`, let a peer task progress at checkpoints, propagated cancellation, rejected completed requests, and produced ordinary results without binding/coroutine capability. This proves API feasibility only; no speedup, full runtime-driver compatibility or production safety certification is claimed. + +### Required integration contract + +1. Accept the host's passed `RuntimeContext`, optional active `RequestContext`, and optional active `CoroutineScope` on the relevant operation/instance. A scope may be used for background tasks without a request context. The framework or an intermediate library forwards the same concrete instances; ArrayKit does not reconstruct them or discover a global runtime. +2. Favor an immutable operation/stream wrapper or explicit per-operation parameters. Do not attach a request token or scope to a shared cached facade proxy or worker-global config instance. A new operation must not inherit an earlier request's binding accidentally. +3. Honor both request and task cancellation/deadlines; reject a completed request or incompatible runtime binding. Missing capability is a normal fallback; cancellation, expired deadlines and invalid lifecycle state are terminal conditions, not reasons to restart through the normal path. +4. Enable cooperative yielding only when a passed active scope exists and the runtime advertises the relevant coroutine capability. Flags alone cannot authorize scheduler calls. Make checkpoint cadence bounded and configurable once at the boundary, after measuring it. +5. Check upstream consumption, including rows rejected by filters, and check again after resumption before invoking the next callback. Preserve ordering, keys, exceptions, backpressure and `take(0)` laziness. R07 must prevent a cancelled replay stream from later appearing successfully truncated. +6. The host owns loop driving, workers, listener/process creation, scope lifetime, cancellation sources and request completion. ArrayKit must not call `Runtime::run()`, `CoroutineRuntime::run()`/`runRequest()`/`attachRequest()`, `RequestContext::complete()`, or close the passed scope. It must not create a runtime simply because Runwire is installed. +7. Use the existing synchronous path when Runwire is absent or no relevant binding/capability is passed. No extra runtime work at Composer include time. Ship direct binding with a stable optional `suggest` relationship and isolated development integration tests against **2.1.1**, with a clean production install that has no Runwire package. Runwire requires PHP 8.4+ on 64-bit PHP; keep the ordinary ArrayKit path's existing platform support. +8. Publish direct framework -> ArrayKit and framework -> intermediary -> ArrayKit examples plus cleanup/fallback semantics. Do not introduce an adapter hierarchy or a new execution-context DTO when the upstream objects already express the boundary. + +Acceptance matrix: Runwire absent; installed/unbound; metadata-only; bound request without scope; task scope without request; valid concurrent scope; missing coroutine capability; host-native loop ownership; pre-cancelled/expired/cancel-during-traversal; mismatched runtime; completed request; closed scope; failing source/callback; two interleaved requests; nested intermediary forwarding; early iterator abandonment; worker replacement. Verify no leaked callbacks, request tokens, scope references or replay state. Actual host-driver tests remain necessary; the native probe does not certify Swoole/OpenSwoole/RoadRunner/FrankenPHP integration. + +Implement the binding at `LazyCollection`'s existing iteration owner, with an additive immutable instance method that accepts the upstream context/request/scope objects and returns a bound collection. Forward the binding through derived lazy operations so checkpoints cover upstream consumption, including filtered-out items. Finalize method signatures and checkpoint defaults against the existing generics and measured workloads before freezing the 5.3.0 API. Keep static array helpers and cached facade proxies free of request bindings. + +Compare the direct binding against the existing factory-composition prototype for correctness, consumer complexity, fairness, cancellation and overhead. If a candidate design fails a gate, revise it within the 5.3.0 scope; do not split the release or claim acceptance from prototype feasibility alone. + +## Implementation sequence + +| Batch | Scope | Completion evidence | +| --- | --- | --- | +| A | Add failing regression cases for R01/R02/R03/R07, then fix those existing owners. | Guard budgets actually stop work; config state is coherent; reference calls mutate caller data; stream errors remain errors. | +| B | R04/R05/R06/R08 cache/memo/export corrections; update config and deployment docs together. | Cache transitions/refresh/round-trip correctness, valid artifact publication and concurrency/failure tests. | +| C | R09/R10/R11/R12 semantic boundary corrections and I03 duplicate consolidation. | Native comparison equivalence, presence semantics, whole-string matching and overflow-safe pagination; all wrappers covered. | +| D | I01 replay-memory measurement/improvement, I02 bounded DTO graph entry points, I04 dependency assessment, and I05 ownership/migration docs. | Compatible APIs, bounded graph behavior, measured allocation evidence, all relevant call sites and explicit maintenance outcomes. | +| E | Additive Runwire instance binding and propagation through lazy operations; direct/intermediary consumers and fallback coverage. | The full instance-forwarding/lifecycle matrix passes; bound/unbound performance and host ownership are verified. | +| F | Integrated quality/compatibility/consumer/performance/soak acceptance, then the single 5.3.0 release candidate. | Every applicable release gate below passes on the same final committed SHA. | + +Do not run source-mutating tooling during this review-only stage. During implementation use PHPForge's routine flow: doctor/config checks; focused failing tests; smallest owner changes; sequential `composer ic:process`; detailed suite; final `composer ic:tests` or `composer ic:release:guard`. Review automatic changes and keep vendor untouched. Never raise thresholds, suppress findings, expand baselines, remove assertions, or skip required detectors to obtain a pass. + +## Release acceptance gates + +- [ ] R01-R12 regressions fail on 5.2.0 and pass on the candidate; original valid-input contracts and existing tests remain intact. +- [ ] I01-I05 have implementation/measurement/documentation evidence or the explicitly allowed upstream maintenance outcome; bounded DTO APIs and replay behavior are verified without weakening existing contracts. +- [ ] Full PHPForge flow passes with current rules and configured scopes; audit warning is recorded with its development-only origin. +- [ ] PHP 8.4 and 8.5 stable/lowest dependency CI and clean `--no-dev` installation pass on the final committed revision. Run compatibility/deprecation checks against the next intended PHP target and identify unavailable target evidence explicitly. +- [ ] Generated config fixtures are validated before activation; old-cache rebuild instructions and OPcache/worker restart behavior are verified. No secret values appear in failures, logs or benchmark results. +- [ ] Direct consumer smoke plus a representative intermediary consumer (for example Foundation's layered-config usage) pass for cold reads, runtime writes, snapshot/restore, cache refresh and persistent execution. Do not claim a consumer test from source inspection alone. +- [ ] Capture a baseline and candidate on the same stable production-equivalent runner: PHP/extensions, no-dev optimized Composer mode, enabled production OPcache, OS/hardware, datasets, traffic mix, concurrency and source SHAs recorded. +- [ ] Use at least three warmed steady-state trials at multiple concurrency levels; measure cold startup separately. Cover repeated config reads, first namespace materialization, generated exact/structural reads, array/set/query operations at threshold boundaries, bounded adversarial traversal, and lazy streaming within a representative host request/task. +- [ ] Compare median **validated successful RPM** with a maximum 2% regression budget; reject invalid/partial/error responses from the numerator. Record p50/p95/p99, error/timeout rates, peak/steady memory, CPU, queue growth and relevant cache/lifecycle metadata. Predeclare workload-specific latency/memory limits from baseline and host capacity. Changes serving different correctness contracts require valid-output baselines, not timing of the existing broken behavior. +- [ ] Persistent-worker soak has bounded memory, state reset and no cross-request/tenant data leakage; include cache-miss/failure and worker replacement. Keep mutable config/hooks and replay streams within their intended lifetime. +- [ ] Runwire instance binding and derived-operation propagation pass the complete acceptance matrix, including absence, unavailable capabilities and direct/intermediary forwarding; the host retains ownership of workers, event loops and scope completion. +- [ ] Separately report ordinary unbound regression and bound fairness/cancellation results, including a consumer that forwards instances through another library. Do not extrapolate microbenchmarks into host RPM. +- [ ] Configure PHPForge's existing representative benchmark result/baseline inputs and validate/compare their machine-readable contracts; do not invent a parallel workflow or treat the current skipped comparisons as passed. +- [ ] Record the final **5.3.0** candidate SHA and successful CI URL; review consolidated release notes and migration guidance covering fixes, additive APIs, Runwire optional usage and generated-cache rebuilds. Tagging and publishing remain separate actions after acceptance. + +## Reproduction index + +Temporary PHP probes and an extracted copy of the exact Runwire 2.1.1 source were used, then removed after review. The triggers above and examples below preserve the reproductions. Raw local check logs remain at `/tmp/arraykit-quality-review.log`, `/tmp/arraykit-final-quality-review.log`, and `/tmp/arraykit-benchmark-review.log`; these are temporary evidence, not release artifacts. + +Example: traversal, facade, lazy failure and presence. + +```php + range(1, 10000)], 'rows.*', maxNodes: 2, throwOnTooDeep: true))); + +// R03: 5.2.0 leaves the original value unchanged. +$data = ['app' => ['name' => 'old']]; +ArrayKit::dot()->set($data, 'app.name', 'new'); +var_dump($data); + +// R07: 5.2.0 throws on the first traversal, returns [1] on the second. +$stream = LazyCollection::from((function () { + yield 1; + throw new RuntimeException('source failure'); +})()); +foreach ([1, 2] as $attempt) { + try { + var_dump($stream->all()); + } catch (RuntimeException $error) { + echo $error->getMessage(), PHP_EOL; + } +} + +// R10: 5.2.0 returns false for a present empty array. +var_dump(DotNotation::matches(['rows' => [['value' => []]]], 'rows.*.value')); +``` + +Example: layered mutation and snapshot state. Supply a temporary source directory containing `app.php` that returns `['name' => 'source']`. + +```php +set('app.name', 'caller'); +var_dump($config->get('app.name')); // 5.2.0: 'source' + +$config = new LayeredLazyFileConfig($directory, namespaces: ['app']); +$config->snapshot(); +$config->get('app.name'); +$config->restore(); +var_dump($config->get('app.name', 'missing')); // 5.2.0: 'missing' +``` From 5095378022a634ae54dde2947afc5e6488cbd9c8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:32:33 +0600 Subject: [PATCH 002/129] test(arraykit): add batch A release regressions --- tests/Feature/Release530BatchATest.php | 187 +++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/Feature/Release530BatchATest.php diff --git a/tests/Feature/Release530BatchATest.php b/tests/Feature/Release530BatchATest.php new file mode 100644 index 0000000..0018454 --- /dev/null +++ b/tests/Feature/Release530BatchATest.php @@ -0,0 +1,187 @@ + ArrayMulti::flattenGuarded( + range(1, 10000), + maxNodes: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); + + expect(fn () => DotNotation::getSafe( + ['rows' => range(1, 10000)], + 'rows.*', + maxNodes: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('shares wildcard node budgets and stops default resolution after exhaustion', function () { + $defaults = 0; + + $result = DotNotation::getSafe( + ['rows' => array_fill(0, 10000, [])], + 'rows.*.id', + function () use (&$defaults): string { + $defaults++; + + return 'missing'; + }, + maxNodes: 3, + ); + + expect(count($result))->toBeLessThanOrEqual(3) + ->and($defaults)->toBeLessThanOrEqual(3); +}); + +it('advances guarded depth across wildcard traversal', function () { + $value = 'leaf'; + for ($i = 0; $i < 12; $i++) { + $value = [$value]; + } + + expect(fn () => DotNotation::getSafe( + ['rows' => [$value]], + 'rows.*.*.*.*.*.*.*.*.*.*.*.*', + maxDepth: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('preserves layered runtime writes made before first materialization', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source', 'debug' => false];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + $config->set('app.name', 'caller'); + + expect($config->get('app.name'))->toBe('caller') + ->and($config->get())->toBe($config->all()); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('restores layered materialization state with snapshots', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source'];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + $config->snapshot(); + expect($config->get('app.name'))->toBe('source'); + + $config->restore(); + + expect($config->get('app.name'))->toBe('source'); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('materializes known layered namespaces before exporting compiled config', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-a-cache-' . bin2hex(random_bytes(5)) . '.php'; + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source'];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->exportCache($cache))->toBeTrue() + ->and(include $cache)->toBe(['app' => ['name' => 'source']]); + } finally { + batchARemoveDirectory($directory); + if (is_file($cache)) { + unlink($cache); + } + } +}); + +it('preserves by-reference mutations through facade proxies', function () { + $data = ['app' => ['name' => 'old'], 'remove' => true]; + + expect(ArrayKit::dot()->set($data, 'app.name', 'new'))->toBeTrue() + ->and($data['app']['name'])->toBe('new'); + + ArrayKit::helper()->forget($data, 'remove'); + + expect($data)->not->toHaveKey('remove'); + + ArrayKit::dot()->fill($data, 'app.debug', true); + expect($data['app']['debug'])->toBeTrue(); + + expect(ArrayKit::dot()->rename($data, 'app.debug', 'app.enabled'))->toBeTrue() + ->and($data['app']['enabled'])->toBeTrue(); +}); + +it('replays terminal lazy source failures instead of presenting truncated success', function () { + $lazy = LazyCollection::from((function () { + yield 1; + throw new RuntimeException('source failure'); + })()); + + foreach ([1, 2] as $attempt) { + try { + $lazy->all(); + test()->fail("Traversal {$attempt} unexpectedly succeeded."); + } catch (RuntimeException $error) { + expect($error->getMessage())->toBe('source failure'); + } + } +}); + +it('replays failures before the first lazy yield', function () { + $lazy = LazyCollection::from((function () { + if (true) { + throw new RuntimeException('initial failure'); + } + + yield 1; + })()); + + expect(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure') + ->and(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure'); +}); From 06ebc53be2a8eba108baf0aa9358161dcf1bf915 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:33:25 +0600 Subject: [PATCH 003/129] fix(facade): preserve reference mutations through module proxies --- src/Facade/ModuleProxy.php | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/Facade/ModuleProxy.php b/src/Facade/ModuleProxy.php index f128824..5865571 100644 --- a/src/Facade/ModuleProxy.php +++ b/src/Facade/ModuleProxy.php @@ -19,6 +19,73 @@ public function __construct( * @param array $arguments */ public function __call(string $method, array $arguments): mixed + { + return $this->invoke($method, $arguments); + } + + /** + * @param array $array + * @param array|string|null $keys + */ + public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + { + return $this->invoke('set', [&$array, $keys, $value, $overwrite]); + } + + /** + * @param array $array + * @param array|string $keys + */ + public function fill(array &$array, array|string $keys, mixed $value = null): void + { + $this->invoke('fill', [&$array, $keys, $value]); + } + + /** + * @param array $array + * @param array|int|string|null $keys + */ + public function forget(array &$array, array|string|int|null $keys): void + { + $this->invoke('forget', [&$array, $keys]); + } + + /** + * @param array $array + */ + public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + { + return $this->invoke('rename', [&$array, $from, $to, $overwrite]); + } + + /** + * @param array $array + */ + public function move(array &$array, string $from, string $to, bool $overwrite = true): bool + { + return $this->invoke('move', [&$array, $from, $to, $overwrite]); + } + + /** + * @param array $array + */ + public function offsetSet(array &$array, string $key, mixed $value): void + { + $this->invoke('offsetSet', [&$array, $key, $value]); + } + + /** + * @param array $array + */ + public function offsetUnset(array &$array, string $key): void + { + $this->invoke('offsetUnset', [&$array, $key]); + } + + /** + * @param array $arguments + */ + private function invoke(string $method, array $arguments): mixed { if (!is_callable([$this->targetClass, $method])) { throw new BadMethodCallException("Method {$this->targetClass}::{$method} does not exist."); From a12ba069480eba113a02f7d7dadf46d6e10d873b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:33:35 +0600 Subject: [PATCH 004/129] fix(collection): replay terminal lazy source failures --- src/Collection/LazyCollection.php | 42 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 54cbf8e..7f2af07 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -227,8 +227,16 @@ private static function replayableFactory(iterable $source): \Closure $sourceCursor = null; $sourceAdvancePending = false; $exhausted = false; - - return static function () use ($source, &$cache, &$sourceCursor, &$sourceAdvancePending, &$exhausted): Generator { + $sourceFailure = null; + + return static function () use ( + $source, + &$cache, + &$sourceCursor, + &$sourceAdvancePending, + &$exhausted, + &$sourceFailure, + ): Generator { $position = 0; while (true) { @@ -240,6 +248,10 @@ private static function replayableFactory(iterable $source): \Closure continue; } + if ($sourceFailure instanceof \Throwable) { + throw $sourceFailure; + } + if ($exhausted) { return; } @@ -248,18 +260,28 @@ private static function replayableFactory(iterable $source): \Closure yield from $source; })(); - if ($sourceAdvancePending) { - $sourceCursor->next(); - $sourceAdvancePending = false; - } + try { + if ($sourceAdvancePending) { + $sourceCursor->next(); + $sourceAdvancePending = false; + } - if (!$sourceCursor->valid()) { - $exhausted = true; + if (!$sourceCursor->valid()) { + $exhausted = true; + $sourceCursor = null; - return; + return; + } + + $entry = [$sourceCursor->key(), $sourceCursor->current()]; + } catch (\Throwable $error) { + $sourceFailure = $error; + $sourceCursor = null; + $sourceAdvancePending = false; + + throw $error; } - $entry = [$sourceCursor->key(), $sourceCursor->current()]; $cache[] = $entry; $sourceAdvancePending = true; From 72c6445628eed7e6152434719d09c9c5cadbfff6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:33:54 +0600 Subject: [PATCH 005/129] fix(config): own layered materialization and state mutations --- src/Config/LayeredLazyFileConfig.php | 184 ++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 5 deletions(-) diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index 6cc294a..9845bf4 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -6,7 +6,7 @@ /** * Layered lazy configuration with explicit precedence: - * fallback < lazy source < overrides. + * fallback < lazy source < overrides < runtime mutations. * * Namespace materialization is used deliberately so exact-path reads always * match reads against the fully merged configuration, including list/scalar @@ -52,9 +52,7 @@ public function __construct( #[\Override] public function all(): array { - foreach (array_keys($this->knownNamespaces) as $namespace) { - $this->materializeNamespace($namespace); - } + $this->materializeKnownNamespaces(); $items = []; foreach (parent::all() as $key => $value) { @@ -66,6 +64,136 @@ public function all(): array return $items; } + #[\Override] + public function get(string|int|array|null $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->all(); + } + + return parent::get($key, $default); + } + + #[\Override] + public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool + { + if ($key === null) { + $this->materializeKnownNamespaces(); + + return parent::set($key, $value, $overwrite); + } + + $this->materializeMutationTargets($key); + + return parent::set($key, $value, $overwrite); + } + + #[\Override] + public function fill(string|array $key, mixed $value = null): bool + { + $this->materializeMutationTargets($key); + + return parent::fill($key, $value); + } + + #[\Override] + public function forget(string|int|array $key): bool + { + $this->materializeMutationTargets($key); + + return parent::forget($key); + } + + #[\Override] + public function merge(array $items): bool + { + $this->materializeMutationTargets($items); + + return parent::merge($items); + } + + #[\Override] + public function overlay(array $overlay): bool + { + return $this->merge($overlay); + } + + #[\Override] + public function loadArray(array $resource): bool + { + $this->materializeKnownNamespaces(); + + return parent::loadArray($resource); + } + + #[\Override] + public function loadFile(string $path): bool + { + $this->materializeKnownNamespaces(); + + return parent::loadFile($path); + } + + #[\Override] + public function replace(array $items): bool + { + $result = parent::replace($items); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($items); + } + + return $result; + } + + #[\Override] + public function reload(array|string $source): bool + { + $result = parent::reload($source); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } + + return $result; + } + + #[\Override] + public function snapshot(string $name = 'default'): bool + { + $this->materializeKnownNamespaces(); + + return parent::snapshot($name); + } + + #[\Override] + public function restore(string $name = 'default'): bool + { + $restored = parent::restore($name); + if ($restored) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } + + return $restored; + } + + #[\Override] + public function changed(string $snapshot = 'default'): bool + { + $this->materializeKnownNamespaces(); + + return parent::changed($snapshot); + } + + #[\Override] + public function exportCache(string $path): bool + { + $this->materializeKnownNamespaces(); + + return parent::exportCache($path); + } + public function clearNamespaceCache(): static { $this->source->flushNamespaceCache(); @@ -101,9 +229,35 @@ protected function resolveRawValue(int|string $key): mixed return parent::resolveRawValue($key); } + private function materializeKnownNamespaces(): void + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializeNamespace($namespace); + } + } + + /** + * @param string|int|array $targets + */ + private function materializeMutationTargets(string|int|array $targets): void + { + if (is_array($targets)) { + foreach ($targets as $key => $value) { + $path = is_int($key) ? $value : $key; + if (is_int($path) || is_string($path)) { + $this->materializeNamespace($this->namespaceFromPath((string) $path)); + } + } + + return; + } + + $this->materializeNamespace($this->namespaceFromPath((string) $targets)); + } + private function materializeNamespace(string $namespace): void { - if (isset($this->materializedNamespaces[$namespace])) { + if ($namespace === '' || isset($this->materializedNamespaces[$namespace])) { return; } @@ -135,6 +289,26 @@ private function materializeNamespace(string $namespace): void $this->flushReadCache(); } + private function markAllKnownNamespacesMaterialized(): void + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializedNamespaces[$namespace] = true; + } + } + + /** + * @param array $items + */ + private function registerNamespaces(array $items): void + { + foreach ($items as $namespace => $_value) { + if (is_string($namespace) && $namespace !== '') { + $this->knownNamespaces[$namespace] = true; + $this->materializedNamespaces[$namespace] = true; + } + } + } + private function namespaceFromPath(string $path): string { $dot = strpos($path, '.'); From cf5b3e72a4c655ad00e2a9ac5bc76da738920334 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:34:04 +0600 Subject: [PATCH 006/129] fix(dot): share safe traversal budgets across requested paths --- .../Concerns/DotNotationPublicApiTrait.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index 0048e86..a3a4bd4 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -160,6 +160,8 @@ public static function getSafe( return []; } + $visitedNodes = 0; + if (is_array($keys)) { $results = []; foreach ($keys as $k) { @@ -171,13 +173,26 @@ public static function getSafe( $maxDepth, $maxNodes, $throwOnTooDeep, + $visitedNodes, ); + + if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + break; + } } return $results; } - return self::getValueSafe($array, $keys, $default, $maxDepth, $maxNodes, $throwOnTooDeep); + return self::getValueSafe( + $array, + $keys, + $default, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $visitedNodes, + ); } /** From 463267d30810ca18c64e1d61ddcdd7f2371efbf2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:34:11 +0600 Subject: [PATCH 007/129] fix(dot): carry traversal state through safe resolution --- src/Array/DotNotation.php | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index b2ff86b..bcd9df8 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -101,8 +101,41 @@ private static function getValueSafe( int $maxDepth, int $maxNodes, bool $throwOnTooDeep, + int &$visitedNodes, ): mixed { - return self::resolveValue($target, $key, $default, $maxDepth, $maxNodes, $throwOnTooDeep); + if (self::isDirectKey($key) && is_array($target) && ArraySingle::exists($target, $key)) { + $visitedNodes++; + if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return self::value($default); + } + + return $target[$key]; + } + + $keyPath = (string) $key; + if (!str_contains($keyPath, '.') && !str_contains($keyPath, '\\')) { + return self::value($default); + } + + $missing = self::missing(); + $resolved = DotNotationPathOps::traverseGet( + $target, + self::splitPath($keyPath), + $default, + $missing, + static fn(mixed $value): mixed => self::value($value), + $maxDepth, + $maxNodes, + $throwOnTooDeep, + 1, + $visitedNodes, + ); + + return $resolved === $missing ? self::value($default) : $resolved; } /** From 1823fd6dfa8edc55714835578d2906207088022a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:34:34 +0600 Subject: [PATCH 008/129] fix(dot): bound wildcard fan-out and depth traversal --- src/Array/DotNotationPathOps.php | 79 ++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index 45a49db..b3dc38f 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -157,13 +157,15 @@ public static function traverseGet( ): mixed { $segmentCount = count($segments); for ($index = $position; $index < $segmentCount; $index++) { - $segment = $segments[$index]; + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + } - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { - return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max node count.'); + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + return $missing; } + $segment = $segments[$index]; if ($segment === '*') { return self::traverseWildcard( $target, @@ -174,16 +176,12 @@ public static function traverseGet( $maxDepth, $maxNodes, $throwOnTooDeep, - $currentDepth, + $currentDepth + 1, $visitedNodes, $index + 1, ); } - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); - } - $normalized = self::normalizeSegment($segment, $target); $target = self::accessSegment($target, $normalized, $missing); if ($target === $missing) { @@ -223,6 +221,23 @@ private static function handleTraversalLimit(object $missing, bool $throwOnTooDe return $missing; } + private static function consumeTraversalNode( + int &$visitedNodes, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + $visitedNodes++; + if ($maxNodes <= 0 || $visitedNodes <= $maxNodes) { + return true; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return false; + } + /** * @param array $segments */ @@ -307,20 +322,38 @@ private static function traverseWildcard( $result = []; foreach ($target as $item) { - $resolved = self::traverseGet( - $item, - $segments, - $default, - $missing, - $defaultResolver, - $maxDepth, - $maxNodes, - $throwOnTooDeep, - $currentDepth, - $visitedNodes, - $position, - ); - $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + + break; + } + + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + + if ($position >= count($segments)) { + $result[] = $item; + } else { + $resolved = self::traverseGet( + $item, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth, + $visitedNodes, + $position, + ); + $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; + } + + if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + break; + } } if (self::hasWildcardFrom($segments, $position)) { From ee7b1eef59756c7b62466befdc120f7acd54f4d4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:34:51 +0600 Subject: [PATCH 009/129] fix(array): count examined elements in guarded traversals --- src/Array/ArrayMulti.php | 88 ++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 2eeb14c..378f7e4 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -376,27 +376,33 @@ public static function values(array $array): array return array_values($array); } - private static function assertTraversalWithinLimits( + private static function traversalDepthAllowed( int $currentDepth, - int &$visitedNodes, int $maxDepth, - int $maxNodes, bool $throwOnTooDeep, ): bool { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); - - return false; + if ($maxDepth <= 0 || $currentDepth <= $maxDepth) { + return true; } - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { - self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max node count.'); + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); - return false; + return false; + } + + private static function consumeTraversalNode( + int &$visitedNodes, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + $visitedNodes++; + if ($maxNodes <= 0 || $visitedNodes <= $maxNodes) { + return true; } - return true; + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max node count.'); + + return false; } /** @@ -429,25 +435,55 @@ private static function flattenIntoGuarded( int $maxNodes, bool $throwOnTooDeep, ): array { - if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { return []; } $result = []; foreach ($array as $item) { + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + if (!is_array($item)) { $result[] = $item; continue; } - $values = ($depth === 1) - ? array_values($item) - : self::flattenIntoGuarded($item, $depth - 1, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep); + if ($depth === 1) { + if (!self::traversalDepthAllowed($currentDepth + 1, $maxDepth, $throwOnTooDeep)) { + break; + } + + foreach ($item as $value) { + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break 2; + } + + $result[] = $value; + } + + continue; + } + + $values = self::flattenIntoGuarded( + $item, + $depth - 1, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); foreach ($values as $value) { $result[] = $value; } + + if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + break; + } } return $result; @@ -487,22 +523,38 @@ private static function measureDepthGuarded( int $maxNodes, bool $throwOnTooDeep, ): int { - if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { return 0; } $resolvedMaxDepth = 1; foreach ($array as $value) { + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + if (!is_array($value) || $value === []) { continue; } $resolvedMaxDepth = max( $resolvedMaxDepth, - self::measureDepthGuarded($value, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep) + 1, + self::measureDepthGuarded( + $value, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ) + 1, ); + + if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + break; + } } return $resolvedMaxDepth; } + } From 98834a228bf01f2efd0f39ca91e06d9b3191dda5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:35:11 +0600 Subject: [PATCH 010/129] fix(array): bound recursive sort work by element budget --- .../Concerns/ArrayMultiQuerySortTrait.php | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 7e5c042..824c783 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -800,7 +800,8 @@ private static function buildInLookup(array $values, bool $strict): ?array return $lookup; } - private static function canTraverse( + private static function reserveSortNodes( + array $array, int $currentDepth, int &$visitedNodes, int $maxDepth, @@ -815,8 +816,8 @@ private static function canTraverse( return false; } - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + $requiredNodes = count($array); + if ($maxNodes > 0 && $requiredNodes > ($maxNodes - $visitedNodes)) { if ($throwOnTooDeep) { throw new \RuntimeException('Recursive sort exceeded max node count.'); } @@ -824,6 +825,8 @@ private static function canTraverse( return false; } + $visitedNodes += $requiredNodes; + return true; } @@ -1251,23 +1254,32 @@ private static function sortRecursiveWithGuards( int $maxNodes, bool $throwOnTooDeep, ): array { - if (!self::canTraverse($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::reserveSortNodes( + $array, + $currentDepth, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + )) { return $array; } foreach ($array as &$value) { - if (is_array($value)) { - $value = self::sortRecursiveWithGuards( - $value, - $options, - $descending, - $currentDepth + 1, - $visitedNodes, - $maxDepth, - $maxNodes, - $throwOnTooDeep, - ); + if (!is_array($value)) { + continue; } + + $value = self::sortRecursiveWithGuards( + $value, + $options, + $descending, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); } unset($value); From b7cfac3e26d8939dbb445a0da379beac34d53fc8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:35:35 +0600 Subject: [PATCH 011/129] docs(facade): document reference-preserving proxy mutators --- docs/rule-reference.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index f7a4bd5..dc67cbf 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -149,6 +149,13 @@ Facade ModuleProxy public function __construct(private string $targetClass) public function __call(string $method, array $arguments): mixed + public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + public function fill(array &$array, array|string $keys, mixed $value = null): void + public function forget(array &$array, array|string|int|null $keys): void + public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + public function move(array &$array, string $from, string $to, bool $overwrite = true): bool + public function offsetSet(array &$array, string $key, mixed $value): void + public function offsetUnset(array &$array, string $key): void BaseArrayHelper --------------------------------------- From b3f575dbaacb068ceade43189278d7642905e033 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:35:38 +0600 Subject: [PATCH 012/129] docs(facade): clarify mutation reference behavior From ccd38ba302c4f0ebd50668c0880081fc5b4f0473 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:36:12 +0600 Subject: [PATCH 013/129] docs(plan): track batch A implementation and QA --- arraykit-review-and-release-plan.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 073c603..dbd02b9 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -2,7 +2,7 @@ Date: 2026-10-04 (Asia/Dhaka) -Status: review complete; scope consolidated for a single 5.3.0 release; implementation and release acceptance remain open. +Status: implementation in progress; Batch A (R01/R02/R03/R07) is implemented and awaiting PR workflow QA before closure. Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. @@ -225,6 +225,17 @@ Implement the binding at `LazyCollection`'s existing iteration owner, with an ad Compare the direct binding against the existing factory-composition prototype for correctness, consumer complexity, fairness, cancellation and overhead. If a candidate design fails a gate, revise it within the 5.3.0 scope; do not split the release or claim acceptance from prototype feasibility alone. +## Implementation tracker + +| Batch | Status | Evidence | +| --- | --- | --- | +| A — R01/R02/R03/R07 | In QA | Regression coverage and owner fixes committed on PR #35; workflow validation pending. | +| B — R04/R05/R06/R08 | Pending | Starts only after Batch A QA is green and findings are revalidated. | +| C — R09/R10/R11/R12 + I03 | Pending | Not started. | +| D — I01/I02/I04/I05 | Pending | Not started. | +| E — Runwire 2.1.1 integration | Pending | Not started. | +| F — integrated release acceptance | Pending | Not started. | + ## Implementation sequence | Batch | Scope | Completion evidence | From 2f0bb406e57c44456f728b1467370865f327c414 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:42:48 +0600 Subject: [PATCH 014/129] fix(array): simplify bounded traversal accounting --- src/Array/ArrayMulti.php | 165 ++++++++++++++++++++++++++------------- 1 file changed, 111 insertions(+), 54 deletions(-) diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 378f7e4..0fb80fe 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -376,20 +376,9 @@ public static function values(array $array): array return array_values($array); } - private static function traversalDepthAllowed( - int $currentDepth, - int $maxDepth, - bool $throwOnTooDeep, - ): bool { - if ($maxDepth <= 0 || $currentDepth <= $maxDepth) { - return true; - } - - self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); - - return false; - } - + /** + * @phpstan-impure + */ private static function consumeTraversalNode( int &$visitedNodes, int $maxNodes, @@ -445,48 +434,98 @@ private static function flattenIntoGuarded( break; } - if (!is_array($item)) { + if (is_array($item)) { + self::flattenNestedGuarded( + $item, + $depth, + $currentDepth, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $result, + ); + } else { $result[] = $item; - - continue; } - if ($depth === 1) { - if (!self::traversalDepthAllowed($currentDepth + 1, $maxDepth, $throwOnTooDeep)) { - break; - } - - foreach ($item as $value) { - if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { - break 2; - } - - $result[] = $value; - } - - continue; + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { + break; } + } - $values = self::flattenIntoGuarded( + return $result; + } + + /** + * @param array $item + * @param array $result + */ + private static function flattenNestedGuarded( + array $item, + float|int $depth, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + array &$result, + ): void { + if ($depth === 1) { + self::flattenOneLevelGuarded( $item, - $depth - 1, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep, + $result, ); - foreach ($values as $value) { - $result[] = $value; + return; + } + + foreach (self::flattenIntoGuarded( + $item, + $depth - 1, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ) as $value) { + $result[] = $value; + } + } + + /** + * @param array $item + * @param array $result + */ + private static function flattenOneLevelGuarded( + array $item, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + array &$result, + ): void { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { + return; + } + + foreach ($item as $value) { + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + return; } - if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { - break; + $result[] = $value; + + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { + return; } } - - return $result; } private static function handleTraversalLimit(bool $throwOnTooDeep, string $message): void @@ -533,23 +572,21 @@ private static function measureDepthGuarded( break; } - if (!is_array($value) || $value === []) { - continue; + if (is_array($value) && $value !== []) { + $resolvedMaxDepth = max( + $resolvedMaxDepth, + self::measureDepthGuarded( + $value, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ) + 1, + ); } - $resolvedMaxDepth = max( - $resolvedMaxDepth, - self::measureDepthGuarded( - $value, - $currentDepth + 1, - $visitedNodes, - $maxDepth, - $maxNodes, - $throwOnTooDeep, - ) + 1, - ); - - if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { break; } } @@ -557,4 +594,24 @@ private static function measureDepthGuarded( return $resolvedMaxDepth; } + private static function traversalBudgetExhausted(int $visitedNodes, int $maxNodes): bool + { + return $maxNodes > 0 && $visitedNodes >= $maxNodes; + } + + private static function traversalDepthAllowed( + int $currentDepth, + int $maxDepth, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth <= 0 || $currentDepth <= $maxDepth) { + return true; + } + + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); + + return false; + } + } + From b7454b45585a972a051be08095e10afc33e2388c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:42:56 +0600 Subject: [PATCH 015/129] fix(array): align sort budget helper with analysis rules --- .../Concerns/ArrayMultiQuerySortTrait.php | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 824c783..4641c6a 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -800,35 +800,6 @@ private static function buildInLookup(array $values, bool $strict): ?array return $lookup; } - private static function reserveSortNodes( - array $array, - int $currentDepth, - int &$visitedNodes, - int $maxDepth, - int $maxNodes, - bool $throwOnTooDeep, - ): bool { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort exceeded max depth.'); - } - - return false; - } - - $requiredNodes = count($array); - if ($maxNodes > 0 && $requiredNodes > ($maxNodes - $visitedNodes)) { - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort exceeded max node count.'); - } - - return false; - } - - $visitedNodes += $requiredNodes; - - return true; - } /** * @param array $array @@ -1159,6 +1130,39 @@ private static function requireArrayKey(mixed $value, string $operation): int|st ); } + /** + * @param array $array + */ + private static function reserveSortNodes( + array $array, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max depth.'); + } + + return false; + } + + $requiredNodes = count($array); + if ($maxNodes > 0 && $requiredNodes > ($maxNodes - $visitedNodes)) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max node count.'); + } + + return false; + } + + $visitedNodes += $requiredNodes; + + return true; + } + private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed { if (!is_string($keyOrCallback)) { From ab61aaf0fe15948653072d6226f8dfc9e2ca08b2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:43:22 +0600 Subject: [PATCH 016/129] fix(dot): preserve bounded wildcard fallback semantics --- src/Array/DotNotationPathOps.php | 118 ++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 40 deletions(-) diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index b3dc38f..f1c04ce 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -157,10 +157,6 @@ public static function traverseGet( ): mixed { $segmentCount = count($segments); for ($index = $position; $index < $segmentCount; $index++) { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); - } - if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { return $missing; } @@ -182,6 +178,10 @@ public static function traverseGet( ); } + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + } + $normalized = self::normalizeSegment($segment, $target); $target = self::accessSegment($target, $normalized, $missing); if ($target === $missing) { @@ -212,15 +212,9 @@ public static function unescapeSegment(string $segment): string ); } - private static function handleTraversalLimit(object $missing, bool $throwOnTooDeep, string $message): mixed - { - if ($throwOnTooDeep) { - throw new \RuntimeException($message); - } - - return $missing; - } - + /** + * @phpstan-impure + */ private static function consumeTraversalNode( int &$visitedNodes, int $maxNodes, @@ -238,6 +232,15 @@ private static function consumeTraversalNode( return false; } + private static function handleTraversalLimit(object $missing, bool $throwOnTooDeep, string $message): mixed + { + if ($throwOnTooDeep) { + throw new \RuntimeException($message); + } + + return $missing; + } + /** * @param array $segments */ @@ -295,6 +298,50 @@ private static function resolveLast(mixed $target): string|int|null return '{last}'; } + /** + * @param array $segments + * @param callable(mixed): mixed $defaultResolver + */ + private static function resolveWildcardItem( + mixed $item, + array $segments, + mixed $default, + object $missing, + callable $defaultResolver, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + int $currentDepth, + int &$visitedNodes, + int $position, + ): mixed { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + + return $defaultResolver($default); + } + + if ($position >= count($segments)) { + return $item; + } + + $resolved = self::traverseGet( + $item, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth, + $visitedNodes, + $position, + ); + + return $resolved === $missing ? $defaultResolver($default) : $resolved; + } + /** * Traverse a target array/object using dot-notation with wildcard support. * @@ -322,44 +369,35 @@ private static function traverseWildcard( $result = []; foreach ($target as $item) { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); - - break; - } - if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { break; } - if ($position >= count($segments)) { - $result[] = $item; - } else { - $resolved = self::traverseGet( - $item, - $segments, - $default, - $missing, - $defaultResolver, - $maxDepth, - $maxNodes, - $throwOnTooDeep, - $currentDepth, - $visitedNodes, - $position, - ); - $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; - } - - if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { + $result[] = self::resolveWildcardItem( + $item, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth, + $visitedNodes, + $position, + ); + + if (!$throwOnTooDeep && $maxNodes > 0 && $visitedNodes >= $maxNodes) { break; } } if (self::hasWildcardFrom($segments, $position)) { - $result = ArrayMulti::collapse($result); + return ArrayMulti::collapse($result); } return $result; } + } + From ec34d1dce57850a88f42f661ea7bb8bfca8be39d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:43:30 +0600 Subject: [PATCH 017/129] fix(collection): retain lazy failure state explicitly --- src/Collection/LazyCollection.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 7f2af07..490e2c0 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -227,7 +227,9 @@ private static function replayableFactory(iterable $source): \Closure $sourceCursor = null; $sourceAdvancePending = false; $exhausted = false; - $sourceFailure = null; + $state = new class { + public ?\Throwable $failure = null; + }; return static function () use ( $source, @@ -235,7 +237,7 @@ private static function replayableFactory(iterable $source): \Closure &$sourceCursor, &$sourceAdvancePending, &$exhausted, - &$sourceFailure, + $state, ): Generator { $position = 0; @@ -248,8 +250,8 @@ private static function replayableFactory(iterable $source): \Closure continue; } - if ($sourceFailure instanceof \Throwable) { - throw $sourceFailure; + if ($state->failure !== null) { + throw $state->failure; } if ($exhausted) { @@ -275,7 +277,7 @@ private static function replayableFactory(iterable $source): \Closure $entry = [$sourceCursor->key(), $sourceCursor->current()]; } catch (\Throwable $error) { - $sourceFailure = $error; + $state->failure = $error; $sourceCursor = null; $sourceAdvancePending = false; From 7e026387799acb08d3087e7624db179600f82859 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:43:41 +0600 Subject: [PATCH 018/129] fix(facade): validate typed proxy mutation results --- src/Facade/ModuleProxy.php | 48 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/Facade/ModuleProxy.php b/src/Facade/ModuleProxy.php index 5865571..2a29378 100644 --- a/src/Facade/ModuleProxy.php +++ b/src/Facade/ModuleProxy.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\Facade; use BadMethodCallException; +use UnexpectedValueException; final readonly class ModuleProxy { @@ -23,15 +24,6 @@ public function __call(string $method, array $arguments): mixed return $this->invoke($method, $arguments); } - /** - * @param array $array - * @param array|string|null $keys - */ - public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool - { - return $this->invoke('set', [&$array, $keys, $value, $overwrite]); - } - /** * @param array $array * @param array|string $keys @@ -53,33 +45,42 @@ public function forget(array &$array, array|string|int|null $keys): void /** * @param array $array */ - public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + public function move(array &$array, string $from, string $to, bool $overwrite = true): bool { - return $this->invoke('rename', [&$array, $from, $to, $overwrite]); + return $this->invokeBool('move', [&$array, $from, $to, $overwrite]); } /** * @param array $array */ - public function move(array &$array, string $from, string $to, bool $overwrite = true): bool + public function offsetSet(array &$array, string $key, mixed $value): void { - return $this->invoke('move', [&$array, $from, $to, $overwrite]); + $this->invoke('offsetSet', [&$array, $key, $value]); } /** * @param array $array */ - public function offsetSet(array &$array, string $key, mixed $value): void + public function offsetUnset(array &$array, string $key): void { - $this->invoke('offsetSet', [&$array, $key, $value]); + $this->invoke('offsetUnset', [&$array, $key]); } /** * @param array $array */ - public function offsetUnset(array &$array, string $key): void + public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool { - $this->invoke('offsetUnset', [&$array, $key]); + return $this->invokeBool('rename', [&$array, $from, $to, $overwrite]); + } + + /** + * @param array $array + * @param array|string|null $keys + */ + public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + { + return $this->invokeBool('set', [&$array, $keys, $value, $overwrite]); } /** @@ -93,4 +94,17 @@ private function invoke(string $method, array $arguments): mixed return $this->targetClass::$method(...$arguments); } + + /** + * @param array $arguments + */ + private function invokeBool(string $method, array $arguments): bool + { + $result = $this->invoke($method, $arguments); + if (!is_bool($result)) { + throw new UnexpectedValueException("Method {$this->targetClass}::{$method} must return bool."); + } + + return $result; + } } From fe70ccf05a2f076c3104955a379c259ad8a6960d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:44:04 +0600 Subject: [PATCH 019/129] style(config): align layered ownership with project ordering --- src/Config/LayeredLazyFileConfig.php | 128 +++++++++++++-------------- 1 file changed, 62 insertions(+), 66 deletions(-) diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index 9845bf4..b445faf 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -65,27 +65,26 @@ public function all(): array } #[\Override] - public function get(string|int|array|null $key = null, mixed $default = null): mixed + public function changed(string $snapshot = 'default'): bool { - if ($key === null) { - return $this->all(); - } + $this->materializeKnownNamespaces(); - return parent::get($key, $default); + return parent::changed($snapshot); } - #[\Override] - public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool + public function clearNamespaceCache(): static { - if ($key === null) { - $this->materializeKnownNamespaces(); + $this->source->flushNamespaceCache(); - return parent::set($key, $value, $overwrite); - } + return $this; + } - $this->materializeMutationTargets($key); + #[\Override] + public function exportCache(string $path): bool + { + $this->materializeKnownNamespaces(); - return parent::set($key, $value, $overwrite); + return parent::exportCache($path); } #[\Override] @@ -105,17 +104,13 @@ public function forget(string|int|array $key): bool } #[\Override] - public function merge(array $items): bool + public function get(string|int|array|null $key = null, mixed $default = null): mixed { - $this->materializeMutationTargets($items); - - return parent::merge($items); - } + if ($key === null) { + return $this->all(); + } - #[\Override] - public function overlay(array $overlay): bool - { - return $this->merge($overlay); + return parent::get($key, $default); } #[\Override] @@ -135,15 +130,22 @@ public function loadFile(string $path): bool } #[\Override] - public function replace(array $items): bool + public function merge(array $items): bool { - $result = parent::replace($items); - if ($result) { - $this->markAllKnownNamespacesMaterialized(); - $this->registerNamespaces($items); - } + $this->materializeMutationTargets($items); - return $result; + return parent::merge($items); + } + + public function namespaceCacheDirectory(): ?string + { + return $this->source->namespaceCacheDirectory(); + } + + #[\Override] + public function overlay(array $overlay): bool + { + return $this->merge($overlay); } #[\Override] @@ -159,11 +161,15 @@ public function reload(array|string $source): bool } #[\Override] - public function snapshot(string $name = 'default'): bool + public function replace(array $items): bool { - $this->materializeKnownNamespaces(); + $result = parent::replace($items); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($items); + } - return parent::snapshot($name); + return $result; } #[\Override] @@ -179,31 +185,25 @@ public function restore(string $name = 'default'): bool } #[\Override] - public function changed(string $snapshot = 'default'): bool + public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { - $this->materializeKnownNamespaces(); + if ($key === null) { + $this->materializeKnownNamespaces(); - return parent::changed($snapshot); - } + return parent::set($key, $value, $overwrite); + } - #[\Override] - public function exportCache(string $path): bool - { - $this->materializeKnownNamespaces(); + $this->materializeMutationTargets($key); - return parent::exportCache($path); + return parent::set($key, $value, $overwrite); } - public function clearNamespaceCache(): static + #[\Override] + public function snapshot(string $name = 'default'): bool { - $this->source->flushNamespaceCache(); - - return $this; - } + $this->materializeKnownNamespaces(); - public function namespaceCacheDirectory(): ?string - { - return $this->source->namespaceCacheDirectory(); + return parent::snapshot($name); } /** @@ -229,6 +229,13 @@ protected function resolveRawValue(int|string $key): mixed return parent::resolveRawValue($key); } + private function markAllKnownNamespacesMaterialized(): void + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializedNamespaces[$namespace] = true; + } + } + private function materializeKnownNamespaces(): void { foreach (array_keys($this->knownNamespaces) as $namespace) { @@ -289,11 +296,13 @@ private function materializeNamespace(string $namespace): void $this->flushReadCache(); } - private function markAllKnownNamespacesMaterialized(): void + private function namespaceFromPath(string $path): string { - foreach (array_keys($this->knownNamespaces) as $namespace) { - $this->materializedNamespaces[$namespace] = true; - } + $dot = strpos($path, '.'); + $namespace = $dot === false ? $path : substr($path, 0, $dot); + $namespace = trim($namespace); + + return $namespace === '' ? $path : $namespace; } /** @@ -308,17 +317,4 @@ private function registerNamespaces(array $items): void } } } - - private function namespaceFromPath(string $path): string - { - $dot = strpos($path, '.'); - $namespace = $dot === false ? $path : substr($path, 0, $dot); - $namespace = trim($namespace); - - if ($namespace === '') { - return $path; - } - - return $namespace; - } } From bdd251ab828b2469ddad63e485ddddc61fadc94c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:45:01 +0600 Subject: [PATCH 020/129] fix(config): keep full layered replacements authoritative --- src/Config/LayeredLazyFileConfig.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index b445faf..bbcbd07 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -189,8 +189,13 @@ public function set(string|array|null $key = null, mixed $value = null, bool $ov { if ($key === null) { $this->materializeKnownNamespaces(); + $result = parent::set($key, $value, $overwrite); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } - return parent::set($key, $value, $overwrite); + return $result; } $this->materializeMutationTargets($key); From 7cf797407e80628b3410c34be33be9aa1bae16b4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:45:34 +0600 Subject: [PATCH 021/129] test(arraykit): complete batch A acceptance coverage --- tests/Feature/Release530BatchATest.php | 127 +++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/Feature/Release530BatchATest.php b/tests/Feature/Release530BatchATest.php index 0018454..d2d7ed3 100644 --- a/tests/Feature/Release530BatchATest.php +++ b/tests/Feature/Release530BatchATest.php @@ -185,3 +185,130 @@ function () use (&$defaults): string { expect(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure') ->and(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure'); }); + +it('enforces guarded array budgets at exact flat and cyclic boundaries', function () { + expect(ArrayMulti::flattenGuarded([1, 2], maxNodes: 2, throwOnTooDeep: true))->toBe([1, 2]) + ->and(ArrayMulti::flattenGuarded(range(1, 100), maxNodes: 2))->toBe([1, 2]) + ->and(fn () => ArrayMulti::depthGuarded(range(1, 100), maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(fn () => ArrayMulti::sortRecursiveGuarded([3, 2, 1], maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(ArrayMulti::sortRecursiveGuarded([3, 2, 1], maxNodes: 2))->toBe([3, 2, 1]); + + $cycle = []; + $cycle['self'] = &$cycle; + + expect(fn () => ArrayMulti::depthGuarded($cycle, maxNodes: 3, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class); +}); + +it('shares safe dot budgets across multiple requested paths', function () { + $data = [ + 'a' => ['value' => 1], + 'b' => ['value' => 2], + ]; + + expect(DotNotation::getSafe( + $data, + ['a.value', 'b.value'], + 'missing', + maxNodes: 3, + ))->toBe([ + 'a.value' => 1, + 'b.value' => 'missing', + ])->and(fn () => DotNotation::getSafe( + $data, + ['a.value', 'b.value'], + 'missing', + maxNodes: 3, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('treats non-positive safe traversal limits as unbounded', function () { + $data = ['one' => ['two' => ['three' => 'value']]]; + + expect(DotNotation::getSafe($data, 'one.two.three', maxDepth: 0, maxNodes: 0))->toBe('value') + ->and(DotNotation::getSafe($data, 'one.two.three', maxDepth: -1, maxNodes: -1))->toBe('value'); +}); + +it('keeps full layered replacements authoritative over previously unknown source namespaces', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents($directory . '/app.php', " 'source-app'];\n"); + file_put_contents($directory . '/extra.php', " 'source-extra'];\n"); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->set(null, ['extra' => ['name' => 'caller-extra']]))->toBeTrue() + ->and($config->get('extra.name'))->toBe('caller-extra') + ->and($config->get('app.name', 'missing'))->toBe('missing'); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('keeps inherited layered mutations coherent before first read', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source', 'items' => ['middle'], 'remove' => true];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->fill('app.debug', true))->toBeTrue() + ->and($config->append('app.items', 'last'))->toBeTrue() + ->and($config->prepend('app.items', 'first'))->toBeTrue() + ->and($config->forget('app.remove'))->toBeTrue() + ->and($config->get('app'))->toBe([ + 'name' => 'source', + 'items' => ['first', 'middle', 'last'], + 'debug' => true, + ]); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('preserves named and offset facade mutations', function () { + $data = ['literal.key' => 'old', 'remove' => true]; + + expect(ArrayKit::dot()->set(array: $data, keys: 'literal\\.key', value: 'new'))->toBeTrue() + ->and($data['literal.key'])->toBe('new'); + + ArrayKit::dot()->offsetSet($data, 'added', 1); + ArrayKit::dot()->offsetUnset($data, 'remove'); + ArrayKit::helper()->forget(array: $data, keys: 'added'); + + expect($data)->toBe(['literal.key' => 'new']); +}); + +it('allows successful lazy prefix replay while preserving a later terminal failure', function () { + $lazy = LazyCollection::from((function () { + yield 'first' => 1; + yield 'second' => 2; + throw new RuntimeException('later failure'); + })()); + + expect(fn () => $lazy->all())->toThrow(RuntimeException::class, 'later failure') + ->and($lazy->take(2)->all())->toBe(['first' => 1, 'second' => 2]) + ->and(fn () => $lazy->all())->toThrow(RuntimeException::class, 'later failure'); +}); + +it('does not confuse lazy consumer callback failures with source failures', function () { + $lazy = LazyCollection::from((function () { + yield 1; + yield 2; + })()); + + expect(fn () => $lazy->mapLazy( + static fn (int $value): int => $value === 1 + ? throw new RuntimeException('callback failure') + : $value, + )->all())->toThrow(RuntimeException::class, 'callback failure') + ->and($lazy->all())->toBe([1, 2]); +}); From d28b996e4bdf4e154bf3186b7be1369cef80bad4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:48:09 +0600 Subject: [PATCH 022/129] style(arraykit): satisfy PHPForge class separation --- src/Array/ArrayMulti.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 0fb80fe..6e04a67 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -612,6 +612,4 @@ private static function traversalDepthAllowed( return false; } - } - From 60435662c96e1397b8adfa22d59db3d56f093f85 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:48:12 +0600 Subject: [PATCH 023/129] style(arraykit): satisfy PHPForge class separation --- src/Array/Concerns/ArrayMultiQuerySortTrait.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 4641c6a..958b8f7 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -800,7 +800,6 @@ private static function buildInLookup(array $values, bool $strict): ?array return $lookup; } - /** * @param array $array * @return array From 51787b552fea3f2daa0eb9df00914cc9333da85b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:48:14 +0600 Subject: [PATCH 024/129] style(arraykit): satisfy PHPForge class separation --- src/Array/DotNotationPathOps.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index f1c04ce..4995fdc 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -398,6 +398,4 @@ private static function traverseWildcard( return $result; } - } - From f2f01a3b7ac8d51e2dd4514b4ab540052deb730f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:50:05 +0600 Subject: [PATCH 025/129] docs(plan): close batch A and start batch B --- arraykit-review-and-release-plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index dbd02b9..cce0644 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -2,7 +2,7 @@ Date: 2026-10-04 (Asia/Dhaka) -Status: implementation in progress; Batch A (R01/R02/R03/R07) is implemented and awaiting PR workflow QA before closure. +Status: implementation in progress; Batch A (R01/R02/R03/R07) is complete and Batch B (R04/R05/R06/R08) is in progress. Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. @@ -229,8 +229,8 @@ Compare the direct binding against the existing factory-composition prototype fo | Batch | Status | Evidence | | --- | --- | --- | -| A — R01/R02/R03/R07 | In QA | Regression coverage and owner fixes committed on PR #35; workflow validation pending. | -| B — R04/R05/R06/R08 | Pending | Starts only after Batch A QA is green and findings are revalidated. | +| A — R01/R02/R03/R07 | Complete | Expanded regressions pass; PHP 8.4/8.5 analysis, clean install, and all stable/lowest QA jobs are green in workflow run #89. | +| B — R04/R05/R06/R08 | In progress | Cache/memo/export regressions and owner fixes are being implemented after Batch A closure. | | C — R09/R10/R11/R12 + I03 | Pending | Not started. | | D — I01/I02/I04/I05 | Pending | Not started. | | E — Runwire 2.1.1 integration | Pending | Not started. | From e39005f3a1841d67aa98da6dac1cc5e1065ddaff Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:50:32 +0600 Subject: [PATCH 026/129] test(config): add batch B cache lifecycle regressions --- tests/Feature/Release530BatchBTest.php | 210 +++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/Feature/Release530BatchBTest.php diff --git a/tests/Feature/Release530BatchBTest.php b/tests/Feature/Release530BatchBTest.php new file mode 100644 index 0000000..7ac5dac --- /dev/null +++ b/tests/Feature/Release530BatchBTest.php @@ -0,0 +1,210 @@ + 'before']; + $referenced = 'before'; + + $config = new Config(); + $config->loadArray([ + 'object' => $object, + 'reference' => ['name' => &$referenced], + ]); + + expect($config->get('object.name'))->toBe('before') + ->and($config->get('reference.name'))->toBe('before'); + + $object->name = 'after'; + $referenced = 'after'; + + expect($config->get('object.name'))->toBe('after') + ->and($config->get('reference.name'))->toBe('after'); +}); + +it('invalidates flat and resolved reads when the namespace cache source changes', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + $warmer = new LazyFileConfig( + $source, + items: ['db' => ['host' => 'cache']], + namespaceCacheDirectory: $cache, + ); + $warmer->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($config->get('db.host'))->toBe('cache') + ->and($config->loaded('db'))->toBeFalse(); + + $config->namespaceCache(null); + + expect($config->get('db.host'))->toBe('source') + ->and($config->loaded('db'))->toBeTrue(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('keeps the valid __flat namespace distinct from internal flat-index metadata', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, '__flat', ['name' => 'namespace']); + + try { + $warmer = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $warmer->warmNamespaceCache('__flat'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('__flat.name'))->toBe('namespace'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rebuilds generated namespace caches from authoritative source values', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents( + $source . '/db.php', + <<<'PHP' + Environment::ref('ARRAYKIT_BATCH_B_HOST', 'fallback')]; +PHP, + ); + + try { + $_ENV['ARRAYKIT_BATCH_B_HOST'] = 'first'; + + $first = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $first->warmNamespaceCache('db'); + + $_ENV['ARRAYKIT_BATCH_B_HOST'] = 'second'; + + $second = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $second->warmNamespaceCache('db'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('db.host'))->toBe('second'); + } finally { + unset($_ENV['ARRAYKIT_BATCH_B_HOST']); + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('publishes namespace cache rebuilds as an immutable generation', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'app', ['name' => 'ArrayKit']); + batchBWriteConfig($source, 'db', ['host' => 'localhost']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['app', 'db']); + + $pointer = $cache . '/.arraykit-generation'; + + expect(is_file($pointer))->toBeTrue(); + + $generation = trim((string) file_get_contents($pointer)); + $generationDirectory = $cache . DIRECTORY_SEPARATOR . $generation; + + expect(str_starts_with($generation, '.arraykit-gen-'))->toBeTrue() + ->and(is_dir($generationDirectory))->toBeTrue() + ->and(is_file($generationDirectory . '/app.php'))->toBeTrue() + ->and(is_file($generationDirectory . '/db.php'))->toBeTrue() + ->and(is_file($generationDirectory . '/.arraykit-flat.php'))->toBeTrue() + ->and(is_file($cache . '/app.php'))->toBeFalse() + ->and(is_file($cache . '/db.php'))->toBeFalse(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rejects unsupported compiled-cache values without replacing the last valid artifact', function () { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + + try { + $valid = new Config(); + $valid->loadArray(['app' => ['name' => 'stable']]); + expect($valid->exportCache($cache))->toBeTrue(); + + $invalid = new Config(); + $invalid->loadArray([ + 'app' => [ + 'value' => new class { + public string $name = 'unsupported'; + }, + ], + ]); + + expect(fn () => $invalid->exportCache($cache)) + ->toThrow(UnexpectedValueException::class); + + expect(include $cache)->toBe(['app' => ['name' => 'stable']]); + } finally { + if (is_file($cache)) { + unlink($cache); + } + } +}); From 583a444db53c8d238bfc1cb207eeb68a6603a6ec Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:52:14 +0600 Subject: [PATCH 027/129] fix(config): harden memoization and compiled cache export --- src/Config/Concerns/BaseConfigTrait.php | 113 ++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index bf0eeda..97da2e9 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -580,23 +580,113 @@ protected function hasResolvedValue(int|string $key): bool return $this->resolveRawValue($key) !== $this->missingValueMarker(); } + protected function isReadCacheSafePath(string $path): bool + { + if ( + !str_contains($path, '.') + || str_contains($path, '\\') + || str_contains($path, '*') + || str_contains($path, '{') + ) { + return false; + } + + $cursor = $this->items; + foreach (explode('.', $path) as $segment) { + if (!is_array($cursor)) { + return false; + } + + if (!array_key_exists($segment, $cursor)) { + return true; + } + + if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { + return false; + } + + $cursor = $cursor[$segment]; + } + + return $cursor === null || is_scalar($cursor); + } + protected function materializeCacheValue(mixed $value): mixed { - if ($value instanceof EnvReference) { - return $this->materializeCacheValue($value->resolve()); + $activeReferences = []; + $activeObjects = []; + + return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); + } + + /** + * @param array $activeReferences + * @param array $activeObjects + */ + private function materializeCacheValueRecursive( + mixed $value, + array &$activeReferences, + array &$activeObjects, + ): mixed { + if ($value instanceof EnvReference || $value instanceof \Closure) { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); + } + + $activeObjects[$objectId] = true; + + try { + $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); + + return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); + } finally { + unset($activeObjects[$objectId]); + } } - if ($value instanceof \Closure) { - return $this->materializeCacheValue($value()); + if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { + return $value; } if (is_array($value)) { + $materialized = []; foreach ($value as $key => $entry) { - $value[$key] = $this->materializeCacheValue($entry); + $reference = \ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + } finally { + unset($activeReferences[$referenceId]); + } } + + return $materialized; } - return $value; + throw new UnexpectedValueException( + 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', + ); } protected function missingValueMarker(): object @@ -626,7 +716,7 @@ protected function resolveRawValue(int|string $key): mixed : $this->missingValueMarker(); } - if (!$this->readCacheEnabled) { + if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { return DotNotation::get($this->items, $key, $this->missingValueMarker()); } @@ -648,13 +738,20 @@ protected function valueCacheKey(int|string $key): string protected function writeCacheFile(string $path, string $contents): bool { + try { + token_get_all($contents, TOKEN_PARSE); + } catch (\ParseError $error) { + throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); + } + $directory = dirname($path); $temporaryPath = tempnam($directory, '.arraykit-'); if ($temporaryPath === false) { return false; } - if (file_put_contents($temporaryPath, $contents, LOCK_EX) === false) { + $written = file_put_contents($temporaryPath, $contents, LOCK_EX); + if ($written !== strlen($contents)) { unlink($temporaryPath); return false; From 9972347a2213ce84dab0dd036d4df7dfe3c5e0fa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:53:31 +0600 Subject: [PATCH 028/129] fix(config): track lazy namespace provenance --- src/Config/LazyFileConfig.php | 91 +++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 8352258..698f5ea 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -14,13 +14,16 @@ class LazyFileConfig extends Config { use LazyFileConfigCacheTrait; - private const string FLAT_INDEX_FILE = '__flat.php'; - /** * @var array */ protected array $loadedNamespaces; + /** + * @var array + */ + protected array $loadedNamespaceOrigins = []; + /** * @param array $items */ @@ -274,11 +277,14 @@ protected function forgetPath(string $path): void $this->loadNamespace($namespace); if (!array_key_exists($namespace, $this->items)) { + $this->markNamespaceRuntime($namespace); + return; } if ($rest === null || $rest === '') { unset($this->items[$namespace]); + $this->markNamespaceRuntime($namespace); return; } @@ -288,6 +294,7 @@ protected function forgetPath(string $path): void } DotNotation::forget($this->items[$namespace], $rest); + $this->markNamespaceRuntime($namespace); } protected function getPath(string $path, mixed $default): mixed @@ -336,9 +343,13 @@ protected function loadNamespace(string $namespace): void return; } - $file = $this->resolveCachedNamespaceFile($namespace) ?? $this->resolveNamespaceFile($namespace); + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + $sourceFile = $this->resolveNamespaceFile($namespace); + $file = $cachedFile ?? $sourceFile; + if ($file === null) { $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; return; } @@ -349,15 +360,18 @@ protected function loadNamespace(string $namespace): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; if (!array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $loaded; + $this->flushReadCache(); return; } if (is_array($this->items[$namespace])) { $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); } } @@ -444,7 +458,7 @@ protected function resolveRawValue(int|string $key): mixed return parent::resolveRawValue($key); } - if (!$this->readCacheEnabled()) { + if (!$this->readCacheEnabled() || !$this->isReadCacheSafePath($key)) { return $this->resolveLazyRawValue($key); } @@ -464,6 +478,7 @@ protected function setPath(string $path, mixed $value, bool $overwrite): void if ($rest === null || $rest === '') { if ($overwrite || !array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $value; + $this->markNamespaceRuntime($namespace); } return; @@ -476,6 +491,7 @@ protected function setPath(string $path, mixed $value, bool $overwrite): void DotNotation::set($namespaceConfig, $rest, $value, $overwrite); $this->items[$namespace] = $namespaceConfig; + $this->markNamespaceRuntime($namespace); } /** @@ -505,6 +521,7 @@ protected function syncLoadedNamespacesFromItems(): void { parent::flushReadCache(); $this->loadedNamespaces = []; + $this->loadedNamespaceOrigins = []; foreach ($this->items as $namespace => $_) { if (!is_string($namespace) || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { @@ -512,7 +529,73 @@ protected function syncLoadedNamespacesFromItems(): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'runtime'; + } + } + + protected function invalidateGeneratedNamespaceState(): void + { + foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { + if ($origin === 'cache') { + unset($this->items[$namespace]); + } + + if ($origin === 'cache' || $origin === 'missing') { + unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); + } + } + + $this->flushReadCache(); + } + + /** + * @return array + */ + protected function namespaceCacheWarmValue(string $namespace): array + { + if ( + ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' + && array_key_exists($namespace, $this->items) + ) { + $value = $this->items[$namespace]; + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + return $value; + } + + $sourceFile = $this->resolveNamespaceFile($namespace); + if ($sourceFile !== null) { + $value = include $sourceFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); + } + + return $value; + } + + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + if ($cachedFile !== null) { + $value = include $cachedFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); + } + + return $value; + } + + if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { + return $this->items[$namespace]; } + + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + private function markNamespaceRuntime(string $namespace): void + { + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'runtime'; } private function syncLoadedNamespacesAfter(bool $changed): bool From 52dd7dffd4bff9cf0485e506de6c7e991e87c2fe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:54:29 +0600 Subject: [PATCH 029/129] fix(config): publish immutable cache generations --- .../Concerns/LazyFileConfigCacheTrait.php | 415 ++++++++++++------ 1 file changed, 286 insertions(+), 129 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 38e9374..33e9994 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -10,8 +10,16 @@ /** @internal */ trait LazyFileConfigCacheTrait { + private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; + + private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; + + private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; + private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; + private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; + /** * @var array */ @@ -26,37 +34,36 @@ trait LazyFileConfigCacheTrait */ public function flushNamespaceCache(string|array|null $namespaces = null): static { - if ($this->namespaceCacheDirectory === null) { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { return $this; } - if (!is_dir($this->namespaceCacheDirectory)) { + if (!is_dir($directory)) { $this->flatLeafIndex = []; $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); return $this; } - return $this->withNamespaceCacheLock(function () use ($namespaces): void { - if ($namespaces === null) { - $this->flushAllNamespaceCacheFiles(); + $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); - return; - } + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishFlushGeneration($resolved); + }); - foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { - $path = $this->cachedNamespacePath($namespace); - if ($path !== null && is_file($path)) { - unlink($path); - } - } + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); - $this->writeFlatLeafIndexFromCacheDirectory(); - }); + return $this; } public function namespaceCache(?string $directory): static { + $this->invalidateGeneratedNamespaceState(); + $this->namespaceCacheDirectory = $directory !== null ? rtrim($directory, DIRECTORY_SEPARATOR) : null; @@ -85,33 +92,31 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); } - return $this->withNamespaceCacheLock(function () use ($namespaces): void { - foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { - $this->loadNamespace($namespace); + $resolved = $this->resolveWarmNamespaces($namespaces); - if (!array_key_exists($namespace, $this->items) || !is_array($this->items[$namespace])) { - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } - - $export = var_export($this->materializeCacheValue($this->items[$namespace]), true); - $path = $this->cachedNamespacePath($namespace); + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishWarmGeneration($resolved); + }); - if ($path === null || !$this->writeCacheFile($path, "flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); - $this->writeFlatLeafIndexFromCacheDirectory(); - }); + return $this; } protected function cachedNamespacePath(string $namespace): ?string { - if ($this->namespaceCacheDirectory === null) { + $directory = $this->activeNamespaceCacheDirectory(); + if ($directory === null) { + return null; + } + + if ($directory === $this->namespaceCacheDirectory && $namespace === '__flat') { return null; } - return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; } /** @@ -147,32 +152,20 @@ protected function discoverNamespaces(): array $namespaces = []; foreach ($this->items as $namespace => $_) { - if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { $namespaces[$namespace] = true; } } - if (!is_dir($this->directory)) { - return array_keys($namespaces); - } - - $entries = scandir($this->directory); - if ($entries === false) { - return array_keys($namespaces); - } - - $suffix = '.' . $this->extension; - foreach ($entries as $entry) { - if (!str_ends_with($entry, $suffix)) { - continue; - } - - $namespace = substr($entry, 0, -strlen($suffix)); - if ($namespace === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { - continue; - } + $this->discoverNamespacesInDirectory($this->directory, $namespaces, false); - $namespaces[$namespace] = true; + $cacheDirectory = $this->activeNamespaceCacheDirectory(); + if ($cacheDirectory !== null) { + $this->discoverNamespacesInDirectory( + $cacheDirectory, + $namespaces, + $cacheDirectory === $this->namespaceCacheDirectory, + ); } return array_keys($namespaces); @@ -180,11 +173,12 @@ protected function discoverNamespaces(): array protected function flatLeafIndexPath(): ?string { - if ($this->namespaceCacheDirectory === null) { + $directory = $this->activeNamespaceCacheDirectory(); + if ($directory === null) { return null; } - return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::FLAT_INDEX_FILE; + return $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; } protected function flatLeafValue(string $path): mixed @@ -229,8 +223,7 @@ protected function loadFlatLeafIndex(): void $this->flatLeafIndex = $this->filterFlatLeafIndex($loaded); } } catch (\Throwable) { - // Generated flat indexes are disposable acceleration artifacts. - // A corrupt index is a cache miss; namespace/source loading remains authoritative. + // Generated indexes are disposable acceleration artifacts. } } @@ -255,77 +248,132 @@ protected function resolveWarmNamespaces(string|array|null $namespaces): array return array_values(array_unique($resolved)); } - protected function writeFlatLeafIndexFromCacheDirectory(): void + private function activeNamespaceCacheDirectory(): ?string { - $indexPath = $this->flatLeafIndexPath(); - $directory = $this->namespaceCacheDirectory; - - if ($indexPath === null || $directory === null) { - return; + $root = $this->namespaceCacheDirectory; + if ($root === null || !is_dir($root)) { + return null; } - $index = $this->buildFlatLeafIndexFromDirectory($directory); - ksort($index); + $pointer = $this->generationPointerPath(); + if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { + return $root; + } - if (!$this->writeCacheFile($indexPath, "flatLeafIndex = $index; - $this->flatLeafIndexLoaded = true; + $directory = $root . DIRECTORY_SEPARATOR . $generation; + + return is_dir($directory) ? $directory : $root; } - /** @param array $index */ + /** + * @param array $index + */ private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void { - if ( - $value === null - || is_bool($value) - || is_int($value) - || is_float($value) - || is_string($value) - ) { + if ($this->isCacheableLeafValue($value)) { $index[$path] = $value; } } + private function activateGeneration(string $stage): void + { + $root = $this->namespaceCacheDirectory; + if ($root === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + $generation = self::CACHE_GENERATION_PREFIX . bin2hex(random_bytes(8)); + $destination = $root . DIRECTORY_SEPARATOR . $generation; + + if (!rename($stage, $destination)) { + throw new RuntimeException('Unable to publish lazy-config cache generation.'); + } + + try { + $this->writeGenerationPointer($generation); + } catch (\Throwable $error) { + $this->removeGenerationDirectory($destination); + + throw $error; + } + } + /** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { - /** @var array $index */ $index = []; - $entries = scandir($directory); - if ($entries === false) { - return $index; - } - $suffix = '.' . $this->extension; - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || $entry === self::FLAT_INDEX_FILE || !str_ends_with($entry, $suffix)) { + foreach ($this->namespaceCacheEntries($directory, false) as [$namespace, $path]) { + try { + $loaded = include $path; + } catch (\Throwable) { continue; } - $namespace = substr($entry, 0, -strlen($suffix)); - if ($namespace === '' || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1) { - continue; + if (is_array($loaded)) { + $this->collectFlatLeafIndex($namespace, $loaded, $index); } + } - $path = $directory . DIRECTORY_SEPARATOR . $entry; + return $index; + } - try { - $loaded = include $path; - } catch (\Throwable) { + /** + * @param array $excluded + */ + private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): void + { + $active = $this->activeNamespaceCacheDirectory(); + if ($active === null) { + return; + } + + $legacy = $active === $this->namespaceCacheDirectory; + + foreach ($this->namespaceCacheEntries($active, $legacy) as [$namespace, $path]) { + if (isset($excluded[$namespace])) { continue; } - if (!is_array($loaded)) { - continue; + $destination = $stage . DIRECTORY_SEPARATOR . basename($path); + if (!copy($path, $destination)) { + throw new RuntimeException("Unable to copy namespace cache for [{$namespace}]."); } + } + } - $this->collectFlatLeafIndex($namespace, $loaded, $index); + private function createGenerationStage(): string + { + $root = $this->namespaceCacheDirectory; + if ($root === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); } - return $index; + $stage = $root . DIRECTORY_SEPARATOR . self::CACHE_STAGE_PREFIX . bin2hex(random_bytes(8)); + if (!mkdir($stage, 0755)) { + throw new RuntimeException('Unable to create lazy-config cache staging directory.'); + } + + return $stage; + } + + /** + * @param array $namespaces + */ + private function discoverNamespacesInDirectory(string $directory, array &$namespaces, bool $legacy): void + { + if (!is_dir($directory)) { + return; + } + + foreach ($this->namespaceCacheEntries($directory, $legacy) as [$namespace]) { + $namespaces[$namespace] = true; + } } /** @@ -334,7 +382,6 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array */ private function filterFlatLeafIndex(array $loaded): array { - /** @var array $index */ $index = []; foreach ($loaded as $key => $value) { @@ -348,32 +395,11 @@ private function filterFlatLeafIndex(array $loaded): array return $index; } - private function flushAllNamespaceCacheFiles(): void + private function generationPointerPath(): ?string { - $directory = $this->namespaceCacheDirectory; - if ($directory === null || !is_dir($directory)) { - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - - return; - } - - $entries = scandir($directory); - if ($entries !== false) { - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || !$this->isOwnedNamespaceCacheEntry($entry)) { - continue; - } - - $path = $directory . DIRECTORY_SEPARATOR . $entry; - if (is_file($path)) { - unlink($path); - } - } - } - - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; + return $this->namespaceCacheDirectory === null + ? null + : $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::CACHE_GENERATION_POINTER; } private function isFlatPathSafeSegment(string $segment): bool @@ -384,20 +410,113 @@ private function isFlatPathSafeSegment(string $segment): bool && !str_contains($segment, '{'); } - private function isOwnedNamespaceCacheEntry(string $entry): bool + /** + * @return array + */ + private function namespaceCacheEntries(string $directory, bool $legacy): array { - if ($entry === self::FLAT_INDEX_FILE) { - return true; + $entries = scandir($directory); + if ($entries === false) { + return []; } + $resolved = []; $suffix = '.' . $this->extension; - if (!str_ends_with($entry, $suffix)) { - return false; + + foreach ($entries as $entry) { + if (!str_ends_with($entry, $suffix) || $entry === self::CACHE_FLAT_INDEX_FILE) { + continue; + } + + $namespace = substr($entry, 0, -strlen($suffix)); + if ( + $namespace === '' + || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1 + || ($legacy && $namespace === '__flat') + ) { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_file($path) && is_readable($path)) { + $resolved[] = [$namespace, $path]; + } } - $namespace = substr($entry, 0, -strlen($suffix)); + return $resolved; + } - return $namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1; + /** + * @param string[]|null $namespaces + */ + private function publishFlushGeneration(?array $namespaces): void + { + $stage = $this->createGenerationStage(); + + try { + if ($namespaces !== null) { + $this->copyActiveNamespaceCacheFiles($stage, array_fill_keys($namespaces, true)); + } + + $this->writeGenerationFlatIndex($stage); + $this->activateGeneration($stage); + } catch (\Throwable $error) { + if (is_dir($stage)) { + $this->removeGenerationDirectory($stage); + } + + throw $error; + } + } + + /** + * @param string[] $namespaces + */ + private function publishWarmGeneration(array $namespaces): void + { + $stage = $this->createGenerationStage(); + + try { + $this->copyActiveNamespaceCacheFiles($stage, array_fill_keys($namespaces, true)); + + foreach ($namespaces as $namespace) { + $value = $this->materializeCacheValue($this->namespaceCacheWarmValue($namespace)); + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + $path = $stage . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + $export = var_export($value, true); + if (!$this->writeCacheFile($path, "writeGenerationFlatIndex($stage); + $this->activateGeneration($stage); + } catch (\Throwable $error) { + if (is_dir($stage)) { + $this->removeGenerationDirectory($stage); + } + + throw $error; + } + } + + private function removeGenerationDirectory(string $directory): void + { + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_file($path)) { + unlink($path); + } + } + + rmdir($directory); } private function withNamespaceCacheLock(\Closure $operation): static @@ -425,4 +544,42 @@ private function withNamespaceCacheLock(\Closure $operation): static return $this; } + + private function writeGenerationFlatIndex(string $directory): void + { + $index = $this->buildFlatLeafIndexFromDirectory($directory); + ksort($index); + + $path = $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; + if (!$this->writeCacheFile($path, "generationPointerPath(); + if ($path === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + $temporary = tempnam(dirname($path), '.arraykit-pointer-'); + if ($temporary === false) { + throw new RuntimeException('Unable to create lazy-config generation pointer.'); + } + + $contents = $generation . PHP_EOL; + $written = file_put_contents($temporary, $contents, LOCK_EX); + if ($written !== strlen($contents)) { + unlink($temporary); + + throw new RuntimeException('Unable to write lazy-config generation pointer.'); + } + + if (!rename($temporary, $path)) { + unlink($temporary); + + throw new RuntimeException('Unable to publish lazy-config generation pointer.'); + } + } } From 464568e629f535b844adba4f92715fc3c922ede2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:54:43 +0600 Subject: [PATCH 030/129] fix(config): preserve resilient cache provenance --- src/Config/ResilientLazyFileConfig.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Config/ResilientLazyFileConfig.php b/src/Config/ResilientLazyFileConfig.php index 197114b..02945f5 100644 --- a/src/Config/ResilientLazyFileConfig.php +++ b/src/Config/ResilientLazyFileConfig.php @@ -19,11 +19,15 @@ protected function loadNamespace(string $namespace): void return; } - $loaded = $this->loadCachedNamespace($namespace); + $cache = $this->resolveCachedNamespaceFile($namespace); + $loaded = $cache === null ? null : $this->loadCachedNamespace($cache); + $origin = $loaded === null ? 'source' : 'cache'; + if ($loaded === null) { $source = $this->resolveNamespaceFile($namespace); if ($source === null) { $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; return; } @@ -35,28 +39,26 @@ protected function loadNamespace(string $namespace): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $origin; if (!array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $loaded; + $this->flushReadCache(); return; } if (is_array($this->items[$namespace])) { $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); } } /** * @return array|null */ - private function loadCachedNamespace(string $namespace): ?array + private function loadCachedNamespace(string $cache): ?array { - $cache = $this->resolveCachedNamespaceFile($namespace); - if ($cache === null) { - return null; - } - try { $loaded = include $cache; } catch (\Throwable) { From bf6e2b1cae48d46b656d110d9492ab0eb3122b20 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:55:21 +0600 Subject: [PATCH 031/129] test(config): resolve active cache generations --- tests/Feature/LazyFileConfigTest.php | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index 8f4d0b9..d8bc367 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -52,12 +52,27 @@ function lazyConfigItems(LazyFileConfig $config): array return (fn (): array => $this->items)->call($config); } +function lazyConfigActiveCacheDirectory(string $directory): string +{ + $pointer = $directory.DIRECTORY_SEPARATOR.'.arraykit-generation'; + if (! is_file($pointer)) { + return $directory; + } + + $generation = trim((string) file_get_contents($pointer)); + $active = $directory.DIRECTORY_SEPARATOR.$generation; + + return is_dir($active) ? $active : $directory; +} + /** * @return array */ function lazyConfigFlatIndex(string $directory): array { - $path = $directory.DIRECTORY_SEPARATOR.'__flat.php'; + $path = lazyConfigActiveCacheDirectory($directory) + .DIRECTORY_SEPARATOR + .'.arraykit-flat.php'; if (! is_file($path)) { return []; @@ -362,9 +377,11 @@ function lazyConfigFlatIndex(string $directory): array $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); $config->warmNamespaceCache('db')->flushNamespaceCache(); + $active = lazyConfigActiveCacheDirectory($this->cachePath); + expect(is_file($this->cachePath.DIRECTORY_SEPARATOR.'keep.txt'))->toBeTrue() - ->and(is_file($this->cachePath.DIRECTORY_SEPARATOR.'db.php'))->toBeFalse() - ->and(is_file($this->cachePath.DIRECTORY_SEPARATOR.'__flat.php'))->toBeFalse(); + ->and(is_file($active.DIRECTORY_SEPARATOR.'db.php'))->toBeFalse() + ->and(lazyConfigFlatIndex($this->cachePath))->toBe([]); }); it('materializes environment references and closures when warming namespace cache', function () { @@ -498,7 +515,7 @@ function lazyConfigFlatIndex(string $directory): array $config->warmNamespaceCache('db'); unlink($this->configPath.DIRECTORY_SEPARATOR.'db.php'); - unlink($this->cachePath.DIRECTORY_SEPARATOR.'db.php'); + unlink(lazyConfigActiveCacheDirectory($this->cachePath).DIRECTORY_SEPARATOR.'db.php'); $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); From 10b2bbab89430bcbb9b0ce3d09826cba5385413b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:55:24 +0600 Subject: [PATCH 032/129] test(config): clean generated benchmark generations --- benchmarks/LazyFileConfigBench.php | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/benchmarks/LazyFileConfigBench.php b/benchmarks/LazyFileConfigBench.php index 2be78f0..e3806f7 100644 --- a/benchmarks/LazyFileConfigBench.php +++ b/benchmarks/LazyFileConfigBench.php @@ -58,29 +58,29 @@ public function setUp(): void public function tearDown(): void { - foreach (['app.php', '__flat.php'] as $file) { - $cachePath = $this->cacheDirectory . DIRECTORY_SEPARATOR . $file; - if (is_file($cachePath)) { - unlink($cachePath); + $remove = static function (string $path) use (&$remove): void { + if (is_file($path)) { + unlink($path); + + return; } - } - $sourcePath = $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php'; - if (is_file($sourcePath)) { - unlink($sourcePath); - } + if (!is_dir($path)) { + return; + } - if (is_dir($this->cacheDirectory)) { - rmdir($this->cacheDirectory); - } - if (is_dir($this->sourceDirectory)) { - rmdir($this->sourceDirectory); - } + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } - $base = dirname($this->sourceDirectory); - if (is_dir($base)) { - rmdir($base); - } + $remove($path . DIRECTORY_SEPARATOR . $entry); + } + + rmdir($path); + }; + + $remove(dirname($this->sourceDirectory)); } public function benchAlreadyLoadedNamespace(): void From b0a69734978dbaf1c23a5dac94fda4a0c0d55265 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:57:40 +0600 Subject: [PATCH 033/129] style(config): align cache lifecycle owners with PHPForge ordering --- src/Config/Concerns/BaseConfigTrait.php | 428 ++++++++++++------------ 1 file changed, 214 insertions(+), 214 deletions(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 97da2e9..f656e10 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -18,6 +18,220 @@ trait BaseConfigTrait { private const int MAX_READ_CACHE_ENTRIES = 1024; + protected function assertWritable(): void + { + if ($this->readOnly) { + throw new RuntimeException('Configuration is read-only.'); + } + } + + protected function cacheResolvedValue(string $cacheKey, mixed $value): mixed + { + if (count($this->resolvedValueCache) >= self::MAX_READ_CACHE_ENTRIES) { + $this->resolvedValueCache = []; + } + + return $this->resolvedValueCache[$cacheKey] = $value; + } + + protected function getResolvedValue(int|string $key, mixed $default = null): mixed + { + $resolved = $this->resolveRawValue($key); + + return $resolved === $this->missingValueMarker() ? $this->resolveDefault($default) : $resolved; + } + + protected function hasResolvedValue(int|string $key): bool + { + return $this->resolveRawValue($key) !== $this->missingValueMarker(); + } + + protected function isReadCacheSafePath(string $path): bool + { + if ( + !str_contains($path, '.') + || str_contains($path, '\\') + || str_contains($path, '*') + || str_contains($path, '{') + ) { + return false; + } + + $cursor = $this->items; + foreach (explode('.', $path) as $segment) { + if (!is_array($cursor)) { + return false; + } + + if (!array_key_exists($segment, $cursor)) { + return true; + } + + if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { + return false; + } + + $cursor = $cursor[$segment]; + } + + return $cursor === null || is_scalar($cursor); + } + + protected function materializeCacheValue(mixed $value): mixed + { + $activeReferences = []; + $activeObjects = []; + + return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); + } + + /** + * @param array $activeReferences + * @param array $activeObjects + */ + private function materializeCacheValueRecursive( + mixed $value, + array &$activeReferences, + array &$activeObjects, + ): mixed { + if ($value instanceof EnvReference || $value instanceof \Closure) { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); + } + + $activeObjects[$objectId] = true; + + try { + $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); + + return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); + } finally { + unset($activeObjects[$objectId]); + } + } + + if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { + return $value; + } + + if (is_array($value)) { + $materialized = []; + foreach ($value as $key => $entry) { + $reference = \ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + } finally { + unset($activeReferences[$referenceId]); + } + } + + return $materialized; + } + + throw new UnexpectedValueException( + 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', + ); + } + + protected function missingValueMarker(): object + { + static $missing; + + if (!is_object($missing)) { + $missing = new \stdClass(); + } + + return $missing; + } + + protected function resolveDefault(mixed $default): mixed + { + return $default instanceof \Closure ? $default() : $default; + } + + protected function resolveRawValue(int|string $key): mixed + { + if ( + is_int($key) + || (!str_contains($key, '.') && !str_contains($key, '\\')) + ) { + return array_key_exists($key, $this->items) + ? $this->items[$key] + : $this->missingValueMarker(); + } + + if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { + return DotNotation::get($this->items, $key, $this->missingValueMarker()); + } + + $cacheKey = $this->valueCacheKey($key); + if (array_key_exists($cacheKey, $this->resolvedValueCache)) { + return $this->resolvedValueCache[$cacheKey]; + } + + return $this->cacheResolvedValue( + $cacheKey, + DotNotation::get($this->items, $key, $this->missingValueMarker()), + ); + } + + protected function valueCacheKey(int|string $key): string + { + return is_int($key) ? 'i:' . $key : 's:' . $key; + } + + protected function writeCacheFile(string $path, string $contents): bool + { + try { + token_get_all($contents, TOKEN_PARSE); + } catch (\ParseError $error) { + throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); + } + + $directory = dirname($path); + $temporaryPath = tempnam($directory, '.arraykit-'); + if ($temporaryPath === false) { + return false; + } + + $written = file_put_contents($temporaryPath, $contents, LOCK_EX); + if ($written !== strlen($contents)) { + unlink($temporaryPath); + + return false; + } + + if (rename($temporaryPath, $path)) { + return true; + } + + unlink($temporaryPath); + + return false; + } + /** * @var array Internal storage for config items */ @@ -551,218 +765,4 @@ public function snapshot(string $name = 'default'): bool return true; } - - protected function assertWritable(): void - { - if ($this->readOnly) { - throw new RuntimeException('Configuration is read-only.'); - } - } - - protected function cacheResolvedValue(string $cacheKey, mixed $value): mixed - { - if (count($this->resolvedValueCache) >= self::MAX_READ_CACHE_ENTRIES) { - $this->resolvedValueCache = []; - } - - return $this->resolvedValueCache[$cacheKey] = $value; - } - - protected function getResolvedValue(int|string $key, mixed $default = null): mixed - { - $resolved = $this->resolveRawValue($key); - - return $resolved === $this->missingValueMarker() ? $this->resolveDefault($default) : $resolved; - } - - protected function hasResolvedValue(int|string $key): bool - { - return $this->resolveRawValue($key) !== $this->missingValueMarker(); - } - - protected function isReadCacheSafePath(string $path): bool - { - if ( - !str_contains($path, '.') - || str_contains($path, '\\') - || str_contains($path, '*') - || str_contains($path, '{') - ) { - return false; - } - - $cursor = $this->items; - foreach (explode('.', $path) as $segment) { - if (!is_array($cursor)) { - return false; - } - - if (!array_key_exists($segment, $cursor)) { - return true; - } - - if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { - return false; - } - - $cursor = $cursor[$segment]; - } - - return $cursor === null || is_scalar($cursor); - } - - protected function materializeCacheValue(mixed $value): mixed - { - $activeReferences = []; - $activeObjects = []; - - return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); - } - - /** - * @param array $activeReferences - * @param array $activeObjects - */ - private function materializeCacheValueRecursive( - mixed $value, - array &$activeReferences, - array &$activeObjects, - ): mixed { - if ($value instanceof EnvReference || $value instanceof \Closure) { - $objectId = spl_object_id($value); - if (isset($activeObjects[$objectId])) { - throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); - } - - $activeObjects[$objectId] = true; - - try { - $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); - - return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); - } finally { - unset($activeObjects[$objectId]); - } - } - - if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { - return $value; - } - - if (is_array($value)) { - $materialized = []; - foreach ($value as $key => $entry) { - $reference = \ReflectionReference::fromArrayElement($value, $key); - if ($reference === null) { - $materialized[$key] = $this->materializeCacheValueRecursive( - $entry, - $activeReferences, - $activeObjects, - ); - - continue; - } - - $referenceId = bin2hex($reference->getId()); - if (isset($activeReferences[$referenceId])) { - throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); - } - - $activeReferences[$referenceId] = true; - - try { - $materialized[$key] = $this->materializeCacheValueRecursive( - $entry, - $activeReferences, - $activeObjects, - ); - } finally { - unset($activeReferences[$referenceId]); - } - } - - return $materialized; - } - - throw new UnexpectedValueException( - 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', - ); - } - - protected function missingValueMarker(): object - { - static $missing; - - if (!is_object($missing)) { - $missing = new \stdClass(); - } - - return $missing; - } - - protected function resolveDefault(mixed $default): mixed - { - return $default instanceof \Closure ? $default() : $default; - } - - protected function resolveRawValue(int|string $key): mixed - { - if ( - is_int($key) - || (!str_contains($key, '.') && !str_contains($key, '\\')) - ) { - return array_key_exists($key, $this->items) - ? $this->items[$key] - : $this->missingValueMarker(); - } - - if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { - return DotNotation::get($this->items, $key, $this->missingValueMarker()); - } - - $cacheKey = $this->valueCacheKey($key); - if (array_key_exists($cacheKey, $this->resolvedValueCache)) { - return $this->resolvedValueCache[$cacheKey]; - } - - return $this->cacheResolvedValue( - $cacheKey, - DotNotation::get($this->items, $key, $this->missingValueMarker()), - ); - } - - protected function valueCacheKey(int|string $key): string - { - return is_int($key) ? 'i:' . $key : 's:' . $key; - } - - protected function writeCacheFile(string $path, string $contents): bool - { - try { - token_get_all($contents, TOKEN_PARSE); - } catch (\ParseError $error) { - throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); - } - - $directory = dirname($path); - $temporaryPath = tempnam($directory, '.arraykit-'); - if ($temporaryPath === false) { - return false; - } - - $written = file_put_contents($temporaryPath, $contents, LOCK_EX); - if ($written !== strlen($contents)) { - unlink($temporaryPath); - - return false; - } - - if (rename($temporaryPath, $path)) { - return true; - } - - unlink($temporaryPath); - - return false; - } } From 88c19541b829ec3d726283e9c90e2d10280c2f5a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:57:44 +0600 Subject: [PATCH 034/129] style(config): align cache lifecycle owners with PHPForge ordering --- .../Concerns/LazyFileConfigCacheTrait.php | 250 ++++++++++-------- 1 file changed, 135 insertions(+), 115 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 33e9994..f255bb6 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -20,91 +20,6 @@ trait LazyFileConfigCacheTrait private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; - /** - * @var array - */ - protected array $flatLeafIndex = []; - - protected bool $flatLeafIndexLoaded = false; - - protected ?string $namespaceCacheDirectory = null; - - /** - * @param string|array|null $namespaces - */ - public function flushNamespaceCache(string|array|null $namespaces = null): static - { - $directory = $this->namespaceCacheDirectory; - if ($directory === null) { - return $this; - } - - if (!is_dir($directory)) { - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - - $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); - - $this->withNamespaceCacheLock(function () use ($resolved): void { - $this->publishFlushGeneration($resolved); - }); - - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - - public function namespaceCache(?string $directory): static - { - $this->invalidateGeneratedNamespaceState(); - - $this->namespaceCacheDirectory = $directory !== null - ? rtrim($directory, DIRECTORY_SEPARATOR) - : null; - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - - return $this; - } - - public function namespaceCacheDirectory(): ?string - { - return $this->namespaceCacheDirectory; - } - - /** - * @param string|array|null $namespaces - */ - public function warmNamespaceCache(string|array|null $namespaces = null): static - { - $directory = $this->namespaceCacheDirectory; - if ($directory === null) { - throw new RuntimeException('Namespace cache directory is not configured.'); - } - - if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { - throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); - } - - $resolved = $this->resolveWarmNamespaces($namespaces); - - $this->withNamespaceCacheLock(function () use ($resolved): void { - $this->publishWarmGeneration($resolved); - }); - - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - protected function cachedNamespacePath(string $namespace): ?string { $directory = $this->activeNamespaceCacheDirectory(); @@ -171,6 +86,10 @@ protected function discoverNamespaces(): array return array_keys($namespaces); } + + + + protected function flatLeafIndexPath(): ?string { $directory = $this->activeNamespaceCacheDirectory(); @@ -248,37 +167,7 @@ protected function resolveWarmNamespaces(string|array|null $namespaces): array return array_values(array_unique($resolved)); } - private function activeNamespaceCacheDirectory(): ?string - { - $root = $this->namespaceCacheDirectory; - if ($root === null || !is_dir($root)) { - return null; - } - - $pointer = $this->generationPointerPath(); - if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { - return $root; - } - - $generation = trim((string) file_get_contents($pointer)); - if (preg_match('/^\\.arraykit-gen-[a-f0-9]+$/', $generation) !== 1) { - return $root; - } - - $directory = $root . DIRECTORY_SEPARATOR . $generation; - return is_dir($directory) ? $directory : $root; - } - - /** - * @param array $index - */ - private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void - { - if ($this->isCacheableLeafValue($value)) { - $index[$path] = $value; - } - } private function activateGeneration(string $stage): void { @@ -347,6 +236,44 @@ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): } } + private function activeNamespaceCacheDirectory(): ?string + { + $root = $this->namespaceCacheDirectory; + if ($root === null || !is_dir($root)) { + return null; + } + + $pointer = $this->generationPointerPath(); + if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { + return $root; + } + + $generation = trim((string) file_get_contents($pointer)); + if (preg_match('/^\\.arraykit-gen-[a-f0-9]+$/', $generation) !== 1) { + return $root; + } + + $directory = $root . DIRECTORY_SEPARATOR . $generation; + + return is_dir($directory) ? $directory : $root; + } + + /** + * @param array $index + */ + private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void + { + if ($this->isCacheableLeafValue($value)) { + $index[$path] = $value; + } + } + + + + + + + private function createGenerationStage(): string { $root = $this->namespaceCacheDirectory; @@ -395,6 +322,10 @@ private function filterFlatLeafIndex(array $loaded): array return $index; } + + + + private function generationPointerPath(): ?string { return $this->namespaceCacheDirectory === null @@ -503,6 +434,95 @@ private function publishWarmGeneration(array $namespaces): void } } + + + + + /** + * @var array + */ + protected array $flatLeafIndex = []; + + protected bool $flatLeafIndexLoaded = false; + + protected ?string $namespaceCacheDirectory = null; + + /** + * @param string|array|null $namespaces + */ + public function flushNamespaceCache(string|array|null $namespaces = null): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + return $this; + } + + if (!is_dir($directory)) { + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + + $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); + + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishFlushGeneration($resolved); + }); + + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + + public function namespaceCache(?string $directory): static + { + $this->invalidateGeneratedNamespaceState(); + + $this->namespaceCacheDirectory = $directory !== null + ? rtrim($directory, DIRECTORY_SEPARATOR) + : null; + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + + return $this; + } + + public function namespaceCacheDirectory(): ?string + { + return $this->namespaceCacheDirectory; + } + + /** + * @param string|array|null $namespaces + */ + public function warmNamespaceCache(string|array|null $namespaces = null): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); + } + + $resolved = $this->resolveWarmNamespaces($namespaces); + + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishWarmGeneration($resolved); + }); + + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + private function removeGenerationDirectory(string $directory): void { foreach (scandir($directory) ?: [] as $entry) { From d081e6e96790e130d9b36e2d12ac98c3faf8cb94 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:57:48 +0600 Subject: [PATCH 035/129] style(config): align cache lifecycle owners with PHPForge ordering --- src/Config/LazyFileConfig.php | 332 +++++++++++++++++----------------- 1 file changed, 167 insertions(+), 165 deletions(-) diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 698f5ea..832e321 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -14,16 +14,179 @@ class LazyFileConfig extends Config { use LazyFileConfigCacheTrait; + protected function forgetPath(string $path): void + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + $this->markNamespaceRuntime($namespace); + + return; + } + + if ($rest === null || $rest === '') { + unset($this->items[$namespace]); + $this->markNamespaceRuntime($namespace); + + return; + } + + if (!is_array($this->items[$namespace])) { + return; + } + + DotNotation::forget($this->items[$namespace], $rest); + $this->markNamespaceRuntime($namespace); + } + + protected function getPath(string $path, mixed $default): mixed + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return $this->resolveDefault($default); + } + + if ($rest === null || $rest === '') { + return $this->items[$namespace]; + } + + if (!is_array($this->items[$namespace])) { + return $this->resolveDefault($default); + } + + return DotNotation::get($this->items[$namespace], $rest, $default); + } + + protected function hasPath(string $path): bool + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return false; + } + + if ($rest === null || $rest === '') { + return true; + } + + if (!is_array($this->items[$namespace])) { + return false; + } + + return DotNotation::has($this->items[$namespace], $rest); + } + + protected function invalidateGeneratedNamespaceState(): void + { + foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { + if ($origin === 'cache') { + unset($this->items[$namespace]); + } + + if ($origin === 'cache' || $origin === 'missing') { + unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); + } + } + + $this->flushReadCache(); + } + /** - * @var array + * @return array */ - protected array $loadedNamespaces; + protected function namespaceCacheWarmValue(string $namespace): array + { + if ( + ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' + && array_key_exists($namespace, $this->items) + ) { + $value = $this->items[$namespace]; + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + return $value; + } + + $sourceFile = $this->resolveNamespaceFile($namespace); + if ($sourceFile !== null) { + $value = include $sourceFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); + } + + return $value; + } + + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + if ($cachedFile !== null) { + $value = include $cachedFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); + } + + return $value; + } + + if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { + return $this->items[$namespace]; + } + + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + protected function loadNamespace(string $namespace): void + { + if (isset($this->loadedNamespaces[$namespace])) { + return; + } + + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + $sourceFile = $this->resolveNamespaceFile($namespace); + $file = $cachedFile ?? $sourceFile; + + if ($file === null) { + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; + + return; + } + + $loaded = include $file; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$file}] must return an array."); + } + + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; + + if (!array_key_exists($namespace, $this->items)) { + $this->items[$namespace] = $loaded; + $this->flushReadCache(); + + return; + } + + if (is_array($this->items[$namespace])) { + $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); + } + } /** * @var array */ protected array $loadedNamespaceOrigins = []; + /** + * @var array + */ + protected array $loadedNamespaces; + /** * @param array $items */ @@ -271,110 +434,6 @@ public function set(string|array|null $key = null, mixed $value = null, bool $ov return true; } - protected function forgetPath(string $path): void - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - $this->markNamespaceRuntime($namespace); - - return; - } - - if ($rest === null || $rest === '') { - unset($this->items[$namespace]); - $this->markNamespaceRuntime($namespace); - - return; - } - - if (!is_array($this->items[$namespace])) { - return; - } - - DotNotation::forget($this->items[$namespace], $rest); - $this->markNamespaceRuntime($namespace); - } - - protected function getPath(string $path, mixed $default): mixed - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - return $this->resolveDefault($default); - } - - if ($rest === null || $rest === '') { - return $this->items[$namespace]; - } - - if (!is_array($this->items[$namespace])) { - return $this->resolveDefault($default); - } - - return DotNotation::get($this->items[$namespace], $rest, $default); - } - - protected function hasPath(string $path): bool - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - return false; - } - - if ($rest === null || $rest === '') { - return true; - } - - if (!is_array($this->items[$namespace])) { - return false; - } - - return DotNotation::has($this->items[$namespace], $rest); - } - - protected function loadNamespace(string $namespace): void - { - if (isset($this->loadedNamespaces[$namespace])) { - return; - } - - $cachedFile = $this->resolveCachedNamespaceFile($namespace); - $sourceFile = $this->resolveNamespaceFile($namespace); - $file = $cachedFile ?? $sourceFile; - - if ($file === null) { - $this->loadedNamespaces[$namespace] = true; - $this->loadedNamespaceOrigins[$namespace] = 'missing'; - - return; - } - - $loaded = include $file; - if (!is_array($loaded)) { - throw new UnexpectedValueException("Config file [{$file}] must return an array."); - } - - $this->loadedNamespaces[$namespace] = true; - $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; - - if (!array_key_exists($namespace, $this->items)) { - $this->items[$namespace] = $loaded; - $this->flushReadCache(); - - return; - } - - if (is_array($this->items[$namespace])) { - $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); - $this->flushReadCache(); - } - } - protected function normalizeNamespace(string $namespace): string { $trimmed = trim($namespace); @@ -517,6 +576,8 @@ protected function splitPath(string $path): array return [$namespace, $rest === '' ? null : $rest]; } + + protected function syncLoadedNamespacesFromItems(): void { parent::flushReadCache(); @@ -533,65 +594,6 @@ protected function syncLoadedNamespacesFromItems(): void } } - protected function invalidateGeneratedNamespaceState(): void - { - foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { - if ($origin === 'cache') { - unset($this->items[$namespace]); - } - - if ($origin === 'cache' || $origin === 'missing') { - unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); - } - } - - $this->flushReadCache(); - } - - /** - * @return array - */ - protected function namespaceCacheWarmValue(string $namespace): array - { - if ( - ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' - && array_key_exists($namespace, $this->items) - ) { - $value = $this->items[$namespace]; - if (!is_array($value)) { - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } - - return $value; - } - - $sourceFile = $this->resolveNamespaceFile($namespace); - if ($sourceFile !== null) { - $value = include $sourceFile; - if (!is_array($value)) { - throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); - } - - return $value; - } - - $cachedFile = $this->resolveCachedNamespaceFile($namespace); - if ($cachedFile !== null) { - $value = include $cachedFile; - if (!is_array($value)) { - throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); - } - - return $value; - } - - if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { - return $this->items[$namespace]; - } - - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } - private function markNamespaceRuntime(string $namespace): void { $this->loadedNamespaces[$namespace] = true; From 944843350c281e27cf1078a433fe645b6b442dfa Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:59:40 +0600 Subject: [PATCH 036/129] docs(config): document immutable namespace cache generations --- docs/lazy-config.rst | 63 ++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 362ff83..69bed38 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -50,8 +50,9 @@ Important Behavior Namespace Cache --------------- -``LazyFileConfig`` can warm one cache file per namespace plus a shared exact-leaf -index. +``LazyFileConfig`` publishes generated namespace caches for deployment/bootstrap +use. Publication uses immutable generations rather than overwriting files that +active workers may already be reading. .. code-block:: php @@ -68,11 +69,14 @@ index. Cache behavior: -- ``namespaceCache()`` configures an optional per-namespace cache directory. -- ``warmNamespaceCache()`` writes cached namespace files and a shared ``__flat.php`` exact-leaf index. -- Exact-key scalar reads check ``__flat.php`` first. -- Structural, wildcard, and namespace reads fall back to namespace cache files. -- ``Environment::ref()`` values and closures are resolved before namespace cache files are written. +- ``namespaceCache()`` configures an optional generated-cache root. +- ``warmNamespaceCache()`` builds a complete hidden generation and atomically switches ``.arraykit-generation`` only after publication succeeds. +- Each generation contains one file per cached namespace plus ``.arraykit-flat.php`` for exact scalar/null leaves. +- Exact-key scalar reads may use the flat index without materializing the namespace. +- Structural, wildcard, and namespace reads use the active immutable namespace file. +- ``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. Existing readers may continue using the previous immutable generation. Environment Values in Namespace Files ------------------------------------- @@ -90,9 +94,9 @@ Example namespace file with delayed environment values: 'port' => fn () => env('DB_PORT', 3306), ]; -``warmNamespaceCache('db')`` resolves those values before writing -``bootstrap/cache/config/db.php`` and before adding scalar leaves to -``bootstrap/cache/config/__flat.php``. +``warmNamespaceCache('db')`` resolves those values before writing the namespace +inside the newly published generation and before adding scalar leaves to that +generation's ``.arraykit-flat.php``. Generated namespace cache file: @@ -100,7 +104,7 @@ Generated namespace cache file: /db.php return [ 'host' => 'localhost', 'port' => 3306, @@ -112,7 +116,7 @@ Generated flat leaf index: /.arraykit-flat.php return [ 'db.host' => 'localhost', 'db.port' => 3306, @@ -153,7 +157,7 @@ Warm the lazy cache during deployment or first boot: namespaceCacheDirectory: $basePath.'/bootstrap/cache/config', ); - // Writes bootstrap/cache/config/db.php and updates __flat.php. + // Publishes a new immutable generation containing db.php + .arraykit-flat.php. $config->warmNamespaceCache('db'); Read from the warmed cache on later requests: @@ -170,7 +174,7 @@ Read from the warmed cache on later requests: namespaceCacheDirectory: $basePath.'/bootstrap/cache/config', ); - // Exact scalar reads can come from __flat.php without loading db.php. + // Exact scalar reads can come from the active .arraykit-flat.php without loading db.php. $host = $config->get('db.host'); // Structural reads load bootstrap/cache/config/db.php when available. @@ -178,17 +182,26 @@ Read from the warmed cache on later requests: Important lazy-cache details: -- ``warmNamespaceCache('db')`` writes ``bootstrap/cache/config/db.php``. -- ``warmNamespaceCache(['db', 'cache'])`` writes one file per namespace. -- ``__flat.php`` stores exact scalar/null leaves such as ``db.host``. -- Original source files are preferred only when namespace cache files do not - exist or are not readable. -- If env values change, rerun ``warmNamespaceCache()`` or flush and rebuild the - namespace cache. -- A namespace is marked loaded only after its source returns a valid array, so - a corrected file can be retried after a failed read. -- A full cache flush removes only ``__flat.php`` and valid namespace cache - files; unrelated files in the configured directory are preserved. +- ``warmNamespaceCache(['db', 'cache'])`` publishes both namespace files and one shared ``.arraykit-flat.php`` inside a new immutable generation. +- ``.arraykit-generation`` is the atomic pointer to the active generation. +- Source-backed warm-up rereads current source/environment values; explicit in-memory runtime overrides remain authoritative. +- ``flushNamespaceCache()`` publishes a generation with the selected cached namespaces removed; unrelated root files are preserved. +- Namespace and generated cache PHP files are trusted deployment-owned inputs, not a sandbox boundary. +- Prefer deployment/bootstrap/admin warm-up, not request-time regeneration. +- Retire old generation directories only after workers that may still reference them have been replaced. + + +5.3 Cache Migration +------------------- + +ArrayKit 5.2 used direct root namespace files plus ``__flat.php`` as an internal +flat index. ArrayKit 5.3 keeps ``__flat`` valid as a caller namespace and moves +internal metadata to ``.arraykit-flat.php`` inside immutable generations. + +After upgrading, rebuild the namespace cache. The old ``__flat.php`` acceleration +artifact is deliberately not interpreted as the 5.3 flat index because it is +ambiguous with the valid ``__flat`` namespace. Ordinary legacy namespace files +remain a compatibility fallback until a 5.3 generation is published. Method Summary -------------- From 4ed71008035b8206b19def436935e476c2cb2d52 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:59:44 +0600 Subject: [PATCH 037/129] docs(config): align layered and resilient cache semantics --- docs/config-layering.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-layering.rst b/docs/config-layering.rst index 941742e..05e8dec 100644 --- a/docs/config-layering.rst +++ b/docs/config-layering.rst @@ -38,7 +38,7 @@ LayeredLazyFileConfig ``LayeredLazyFileConfig`` composes three layers with this precedence: -``fallback < lazy source < overrides`` +``fallback < lazy source < overrides < runtime mutations`` Only the requested namespace is materialized. Exact path reads are resolved from the fully merged namespace, so list replacement and scalar shadowing cannot leak @@ -76,9 +76,9 @@ cache miss and retries the authoritative source namespace. Invalid source files still fail normally; resilience applies only to disposable generated cache artifacts. -Malformed or invalid ``__flat.php`` indexes are also treated as cache misses. -This keeps an acceleration artifact from preventing source configuration from -loading. +Malformed generated namespace files and invalid ``.arraykit-flat.php`` indexes +are disposable cache artifacts. Generated files live in immutable generations +selected by ``.arraykit-generation``; authoritative source failures remain visible. Environment Enumeration ----------------------- From 6ec773200ea60a27c25b35bc8b7467144febca52 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:59:54 +0600 Subject: [PATCH 038/129] docs(config): define export and memoization contracts --- docs/config.rst | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 496c891..dec88dc 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -435,8 +435,11 @@ Compiled Cache + Read Memoization $host = $cached->get('db.host'); When ``exportCache()`` writes the PHP cache file, ``Environment::ref()`` values -and closures are recursively resolved first. The generated cache contains only -the resolved values, not closures or reference objects. +and closures are recursively resolved first. Export accepts scalar/null values, +arrays of supported values, and enum cases. Unsupported objects/resources and +cyclic value graphs raise ``UnexpectedValueException`` before publication, so an +existing valid cache file is left intact. Generated PHP syntax is validated +before the atomic rename. Method Summary -------------- @@ -458,10 +461,12 @@ Config methods: - ``readonly()``, ``isReadonly()`` Read memoization is bounded to 1,024 resolved paths for predictable memory use -in persistent workers. Plain top-level string and integer keys use direct array -lookup instead of entering the path cache; nested, escaped, and wildcard paths -remain memoized. Every mutation family, restore, and reload invalidates the -memoized values. +in persistent workers. Plain top-level keys use direct array lookup. Nested +scalar/null paths through ordinary non-referenced arrays can be memoized; +object-backed, referenced, escaped, wildcard, selector, and structural values +are resolved live so externally mutable state cannot become stale. Every +mutation family, restore, reload, and generated-cache source transition +invalidates affected memoized values. Hook-aware methods: From 9cefa6266d414bc0288bad1d74381a6693d384a5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 21:59:58 +0600 Subject: [PATCH 039/129] docs(config): update namespace cache layout --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6135d13..4907f4b 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ $row = ArrayShape::require( $config = new LazyFileConfig(__DIR__ . '/config', namespaceCacheDirectory: __DIR__ . '/bootstrap/cache/config'); $config->warmNamespaceCache(['db', 'cache']); -// Exact scalar leaf reads can hit bootstrap/cache/config/__flat.php first. +// Exact scalar leaf reads can hit the active generation's .arraykit-flat.php first. $host = $config->get('db.host'); ``` @@ -308,7 +308,7 @@ $host = $config->get('db.host'); - `DotNotation` treats existing `null` keys/properties as present (does not fall back to defaults). - `DotNotation::hasWildcard()`, `paths()`, `matches()`, `rename()`, and `move()` are available for wildcard/path operations. - For untrusted/deep payloads, use bounded traversal variants: `DotNotation::getSafe()`, `ArrayMulti::depthGuarded()`, `flattenGuarded()`, and `sortRecursiveGuarded()`. -- `LazyFileConfig` namespace cache writes one cache file per namespace plus a shared `__flat.php` file containing only final scalar/null leaf values for exact-key fast paths. +- `LazyFileConfig` publishes immutable cache generations selected by `.arraykit-generation`; each generation contains namespace files plus `.arraykit-flat.php` for exact scalar/null fast paths. ## Security From 27093ac247afcbf2ca8f265192a8036908d0d082 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:00:41 +0600 Subject: [PATCH 040/129] test(config): complete batch B acceptance matrix --- tests/Feature/Release530BatchBTest.php | 288 +++++++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/tests/Feature/Release530BatchBTest.php b/tests/Feature/Release530BatchBTest.php index 7ac5dac..2b16722 100644 --- a/tests/Feature/Release530BatchBTest.php +++ b/tests/Feature/Release530BatchBTest.php @@ -6,6 +6,12 @@ use Infocyph\ArrayKit\Config\LazyFileConfig; use Infocyph\ArrayKit\Config\Support\Environment; + +enum BatchBCacheMode: string +{ + case Production = 'production'; +} + function batchBRemoveDirectory(string $path): void { if (is_file($path) || is_link($path)) { @@ -208,3 +214,285 @@ function batchBWriteConfig(string $directory, string $namespace, array $value): } } }); + +it('switches generated cache roots without retaining cache-derived state', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cacheA = sys_get_temp_dir() . '/arraykit-batch-b-cache-a-' . bin2hex(random_bytes(5)); + $cacheB = sys_get_temp_dir() . '/arraykit-batch-b-cache-b-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cacheA, 0777, true); + mkdir($cacheB, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-a']], namespaceCacheDirectory: $cacheA)) + ->warmNamespaceCache('db'); + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-b']], namespaceCacheDirectory: $cacheB)) + ->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cacheA); + + expect($config->get('db.host'))->toBe('cache-a'); + + $config->namespaceCache($cacheB); + expect($config->get('db.host'))->toBe('cache-b'); + + $config->namespaceCache(null); + expect($config->get('db.host'))->toBe('source'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cacheA); + batchBRemoveDirectory($cacheB); + } +}); + +it('preserves runtime namespace mutations when generated cache roots change', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cacheA = sys_get_temp_dir() . '/arraykit-batch-b-cache-a-' . bin2hex(random_bytes(5)); + $cacheB = sys_get_temp_dir() . '/arraykit-batch-b-cache-b-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cacheA, 0777, true); + mkdir($cacheB, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-a']], namespaceCacheDirectory: $cacheA)) + ->warmNamespaceCache('db'); + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-b']], namespaceCacheDirectory: $cacheB)) + ->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cacheA); + $config->set('db.host', 'runtime'); + + $config->namespaceCache($cacheB); + + expect($config->get('db.host'))->toBe('runtime'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cacheA); + batchBRemoveDirectory($cacheB); + } +}); + +it('loads namespace structure coherently after an exact flat-index hit', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'localhost', 'options' => ['timeout' => 5]]); + + try { + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($config->get('db.host'))->toBe('localhost') + ->and($config->loaded('db'))->toBeFalse() + ->and($config->get('db'))->toBe([ + 'host' => 'localhost', + 'options' => ['timeout' => 5], + ]) + ->and($config->loaded('db'))->toBeTrue(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('supports selective generated-cache flushes without disturbing other namespaces', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'app', ['name' => 'ArrayKit']); + batchBWriteConfig($source, 'db', ['host' => 'localhost']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['app', 'db'])->flushNamespaceCache('db'); + + unlink($source . '/app.php'); + unlink($source . '/db.php'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('app.name'))->toBe('ArrayKit') + ->and($fresh->get('db.host', 'missing'))->toBe('missing'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('supports valid cache namespace names and alternate source extensions', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents($source . '/foo-bar.inc', " 'dash'];\n"); + file_put_contents($source . '/foo_bar.inc', " 'underscore'];\n"); + file_put_contents($source . '/__flat.inc', " 'flat-namespace'];\n"); + + try { + $config = new LazyFileConfig($source, 'inc', namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['foo-bar', 'foo_bar', '__flat']); + + $fresh = new LazyFileConfig($source, 'inc', namespaceCacheDirectory: $cache); + + expect($fresh->get('foo-bar.value'))->toBe('dash') + ->and($fresh->get('foo_bar.value'))->toBe('underscore') + ->and($fresh->get('__flat.value'))->toBe('flat-namespace'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rebuilds environment-backed caches on the same warmer instance', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents( + $source . '/db.php', + <<<'PHP' + Environment::ref('ARRAYKIT_BATCH_B_SAME_HOST', 'fallback')]; +PHP, + ); + + try { + $warmer = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + $_ENV['ARRAYKIT_BATCH_B_SAME_HOST'] = 'first'; + $warmer->warmNamespaceCache('db'); + + $_ENV['ARRAYKIT_BATCH_B_SAME_HOST'] = 'second'; + $warmer->warmNamespaceCache('db'); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('second'); + } finally { + unset($_ENV['ARRAYKIT_BATCH_B_SAME_HOST']); + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('can republish a generated namespace in a cache-only deployment', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'cached']); + + try { + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + unlink($source . '/db.php'); + + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('cached'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('round-trips supported compiled-cache values including nulls and enums', function () { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + + try { + $config = new Config(); + $config->loadArray([ + 'app' => [ + 'enabled' => true, + 'mode' => BatchBCacheMode::Production, + 'nullable' => null, + 'ports' => [80, 443], + ], + ]); + + expect($config->exportCache($cache))->toBeTrue() + ->and(include $cache)->toBe([ + 'app' => [ + 'enabled' => true, + 'mode' => BatchBCacheMode::Production, + 'nullable' => null, + 'ports' => [80, 443], + ], + ]); + } finally { + if (is_file($cache)) { + unlink($cache); + } + } +}); + +it('rejects resources named objects and cyclic compiled-cache graphs', function (string $case) { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + $resource = null; + + try { + $value = match ($case) { + 'resource' => $resource = fopen('php://memory', 'r'), + 'object' => new stdClass(), + 'array-cycle' => (static function (): array { + $cycle = []; + $cycle['self'] = &$cycle; + + return $cycle; + })(), + 'closure-cycle' => (static function (): Closure { + $closure = null; + $closure = static function () use (&$closure): Closure { + return $closure; + }; + + return $closure; + })(), + }; + + $config = new Config(); + $config->loadArray(['value' => $value]); + + expect(fn () => $config->exportCache($cache)) + ->toThrow(UnexpectedValueException::class); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + if (is_file($cache)) { + unlink($cache); + } + } +})->with(['resource', 'object', 'array-cycle', 'closure-cycle']); + +it('keeps the previous generation active when namespace publication rejects a value', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'stable']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache('db'); + $config->set('db.invalid', new stdClass()); + + expect(fn () => $config->warmNamespaceCache('db')) + ->toThrow(UnexpectedValueException::class); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('stable'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); From 371c597ba16ca96c860e01a5aef36c0554b28839 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:02:29 +0600 Subject: [PATCH 041/129] fix(config): consume generated syntax validation result --- src/Config/Concerns/BaseConfigTrait.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index f656e10..ebd0290 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -205,7 +205,9 @@ protected function valueCacheKey(int|string $key): string protected function writeCacheFile(string $path, string $contents): bool { try { - token_get_all($contents, TOKEN_PARSE); + if (token_get_all($contents, TOKEN_PARSE) === []) { + throw new UnexpectedValueException('Generated configuration cache is empty.'); + } } catch (\ParseError $error) { throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); } From d207ce13b7ec0f314986698689c2da07448d61a9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:02:33 +0600 Subject: [PATCH 042/129] refactor(config): move generated cache state ownership to cache trait --- src/Config/LazyFileConfig.php | 59 ----------------------------------- 1 file changed, 59 deletions(-) diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 832e321..a817eb9 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -80,65 +80,6 @@ protected function hasPath(string $path): bool return DotNotation::has($this->items[$namespace], $rest); } - protected function invalidateGeneratedNamespaceState(): void - { - foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { - if ($origin === 'cache') { - unset($this->items[$namespace]); - } - - if ($origin === 'cache' || $origin === 'missing') { - unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); - } - } - - $this->flushReadCache(); - } - - /** - * @return array - */ - protected function namespaceCacheWarmValue(string $namespace): array - { - if ( - ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' - && array_key_exists($namespace, $this->items) - ) { - $value = $this->items[$namespace]; - if (!is_array($value)) { - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } - - return $value; - } - - $sourceFile = $this->resolveNamespaceFile($namespace); - if ($sourceFile !== null) { - $value = include $sourceFile; - if (!is_array($value)) { - throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); - } - - return $value; - } - - $cachedFile = $this->resolveCachedNamespaceFile($namespace); - if ($cachedFile !== null) { - $value = include $cachedFile; - if (!is_array($value)) { - throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); - } - - return $value; - } - - if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { - return $this->items[$namespace]; - } - - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } - protected function loadNamespace(string $namespace): void { if (isset($this->loadedNamespaces[$namespace])) { From 41cadbaacf7fb7bb7ca57223f1d68931a8250bcf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:02:51 +0600 Subject: [PATCH 043/129] fix(config): centralize generated cache lifecycle state --- .../Concerns/LazyFileConfigCacheTrait.php | 77 +++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index f255bb6..7a67870 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -55,8 +55,8 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData, continue; } - if ($this->isCacheableLeafValue($value)) { - $this->addFlatLeafIndexValue($index, $path, $value); + if ($value === null || is_scalar($value)) { + $index[$path] = $value; } } } @@ -109,6 +109,21 @@ protected function flatLeafValue(string $path): mixed : $this->missingValueMarker(); } + protected function invalidateGeneratedNamespaceState(): void + { + foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { + if ($origin === 'cache') { + unset($this->items[$namespace]); + } + + if ($origin === 'cache' || $origin === 'missing') { + unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); + } + } + + $this->flushReadCache(); + } + protected function isCacheableLeafValue(mixed $value): bool { return $value === null @@ -153,6 +168,50 @@ protected function loadFlatLeafIndex(): void * @param string|array|null $namespaces * @return string[] */ + /** + * @return array + */ + protected function namespaceCacheWarmValue(string $namespace): array + { + if ( + ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' + && array_key_exists($namespace, $this->items) + ) { + $value = $this->items[$namespace]; + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + return $value; + } + + $sourceFile = $this->resolveNamespaceFile($namespace); + if ($sourceFile !== null) { + $value = include $sourceFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); + } + + return $value; + } + + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + if ($cachedFile !== null) { + $value = include $cachedFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); + } + + return $value; + } + + if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { + return $this->items[$namespace]; + } + + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + protected function resolveWarmNamespaces(string|array|null $namespaces): array { if ($namespaces === null) { @@ -258,16 +317,6 @@ private function activeNamespaceCacheDirectory(): ?string return is_dir($directory) ? $directory : $root; } - /** - * @param array $index - */ - private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void - { - if ($this->isCacheableLeafValue($value)) { - $index[$path] = $value; - } - } - @@ -316,7 +365,9 @@ private function filterFlatLeafIndex(array $loaded): array continue; } - $this->addFlatLeafIndexValue($index, $key, $value); + if ($value === null || is_scalar($value)) { + $index[$key] = $value; + } } return $index; From 4ae82515c85dddf2fa28821ee86b5914bb52fb23 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:03:16 +0600 Subject: [PATCH 044/129] style(config): finalize cache trait method ordering --- .../Concerns/LazyFileConfigCacheTrait.php | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 7a67870..90621a3 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -251,6 +251,28 @@ private function activateGeneration(string $stage): void } } + private function activeNamespaceCacheDirectory(): ?string + { + $root = $this->namespaceCacheDirectory; + if ($root === null || !is_dir($root)) { + return null; + } + + $pointer = $this->generationPointerPath(); + if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { + return $root; + } + + $generation = trim((string) file_get_contents($pointer)); + if (preg_match('/^\\.arraykit-gen-[a-f0-9]+$/', $generation) !== 1) { + return $root; + } + + $directory = $root . DIRECTORY_SEPARATOR . $generation; + + return is_dir($directory) ? $directory : $root; + } + /** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { @@ -295,28 +317,6 @@ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): } } - private function activeNamespaceCacheDirectory(): ?string - { - $root = $this->namespaceCacheDirectory; - if ($root === null || !is_dir($root)) { - return null; - } - - $pointer = $this->generationPointerPath(); - if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { - return $root; - } - - $generation = trim((string) file_get_contents($pointer)); - if (preg_match('/^\\.arraykit-gen-[a-f0-9]+$/', $generation) !== 1) { - return $root; - } - - $directory = $root . DIRECTORY_SEPARATOR . $generation; - - return is_dir($directory) ? $directory : $root; - } - From e64e23b9924f63d0a15acb67b17e2ad62f4bc0cf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:07:18 +0600 Subject: [PATCH 045/129] style(config): restore base config class-element structure --- src/Config/Concerns/BaseConfigTrait.php | 432 ++++++++++++------------ 1 file changed, 216 insertions(+), 216 deletions(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index ebd0290..475bef7 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -18,222 +18,6 @@ trait BaseConfigTrait { private const int MAX_READ_CACHE_ENTRIES = 1024; - protected function assertWritable(): void - { - if ($this->readOnly) { - throw new RuntimeException('Configuration is read-only.'); - } - } - - protected function cacheResolvedValue(string $cacheKey, mixed $value): mixed - { - if (count($this->resolvedValueCache) >= self::MAX_READ_CACHE_ENTRIES) { - $this->resolvedValueCache = []; - } - - return $this->resolvedValueCache[$cacheKey] = $value; - } - - protected function getResolvedValue(int|string $key, mixed $default = null): mixed - { - $resolved = $this->resolveRawValue($key); - - return $resolved === $this->missingValueMarker() ? $this->resolveDefault($default) : $resolved; - } - - protected function hasResolvedValue(int|string $key): bool - { - return $this->resolveRawValue($key) !== $this->missingValueMarker(); - } - - protected function isReadCacheSafePath(string $path): bool - { - if ( - !str_contains($path, '.') - || str_contains($path, '\\') - || str_contains($path, '*') - || str_contains($path, '{') - ) { - return false; - } - - $cursor = $this->items; - foreach (explode('.', $path) as $segment) { - if (!is_array($cursor)) { - return false; - } - - if (!array_key_exists($segment, $cursor)) { - return true; - } - - if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { - return false; - } - - $cursor = $cursor[$segment]; - } - - return $cursor === null || is_scalar($cursor); - } - - protected function materializeCacheValue(mixed $value): mixed - { - $activeReferences = []; - $activeObjects = []; - - return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); - } - - /** - * @param array $activeReferences - * @param array $activeObjects - */ - private function materializeCacheValueRecursive( - mixed $value, - array &$activeReferences, - array &$activeObjects, - ): mixed { - if ($value instanceof EnvReference || $value instanceof \Closure) { - $objectId = spl_object_id($value); - if (isset($activeObjects[$objectId])) { - throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); - } - - $activeObjects[$objectId] = true; - - try { - $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); - - return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); - } finally { - unset($activeObjects[$objectId]); - } - } - - if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { - return $value; - } - - if (is_array($value)) { - $materialized = []; - foreach ($value as $key => $entry) { - $reference = \ReflectionReference::fromArrayElement($value, $key); - if ($reference === null) { - $materialized[$key] = $this->materializeCacheValueRecursive( - $entry, - $activeReferences, - $activeObjects, - ); - - continue; - } - - $referenceId = bin2hex($reference->getId()); - if (isset($activeReferences[$referenceId])) { - throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); - } - - $activeReferences[$referenceId] = true; - - try { - $materialized[$key] = $this->materializeCacheValueRecursive( - $entry, - $activeReferences, - $activeObjects, - ); - } finally { - unset($activeReferences[$referenceId]); - } - } - - return $materialized; - } - - throw new UnexpectedValueException( - 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', - ); - } - - protected function missingValueMarker(): object - { - static $missing; - - if (!is_object($missing)) { - $missing = new \stdClass(); - } - - return $missing; - } - - protected function resolveDefault(mixed $default): mixed - { - return $default instanceof \Closure ? $default() : $default; - } - - protected function resolveRawValue(int|string $key): mixed - { - if ( - is_int($key) - || (!str_contains($key, '.') && !str_contains($key, '\\')) - ) { - return array_key_exists($key, $this->items) - ? $this->items[$key] - : $this->missingValueMarker(); - } - - if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { - return DotNotation::get($this->items, $key, $this->missingValueMarker()); - } - - $cacheKey = $this->valueCacheKey($key); - if (array_key_exists($cacheKey, $this->resolvedValueCache)) { - return $this->resolvedValueCache[$cacheKey]; - } - - return $this->cacheResolvedValue( - $cacheKey, - DotNotation::get($this->items, $key, $this->missingValueMarker()), - ); - } - - protected function valueCacheKey(int|string $key): string - { - return is_int($key) ? 'i:' . $key : 's:' . $key; - } - - protected function writeCacheFile(string $path, string $contents): bool - { - try { - if (token_get_all($contents, TOKEN_PARSE) === []) { - throw new UnexpectedValueException('Generated configuration cache is empty.'); - } - } catch (\ParseError $error) { - throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); - } - - $directory = dirname($path); - $temporaryPath = tempnam($directory, '.arraykit-'); - if ($temporaryPath === false) { - return false; - } - - $written = file_put_contents($temporaryPath, $contents, LOCK_EX); - if ($written !== strlen($contents)) { - unlink($temporaryPath); - - return false; - } - - if (rename($temporaryPath, $path)) { - return true; - } - - unlink($temporaryPath); - - return false; - } - /** * @var array Internal storage for config items */ @@ -767,4 +551,220 @@ public function snapshot(string $name = 'default'): bool return true; } + + protected function assertWritable(): void + { + if ($this->readOnly) { + throw new RuntimeException('Configuration is read-only.'); + } + } + + protected function cacheResolvedValue(string $cacheKey, mixed $value): mixed + { + if (count($this->resolvedValueCache) >= self::MAX_READ_CACHE_ENTRIES) { + $this->resolvedValueCache = []; + } + + return $this->resolvedValueCache[$cacheKey] = $value; + } + + protected function getResolvedValue(int|string $key, mixed $default = null): mixed + { + $resolved = $this->resolveRawValue($key); + + return $resolved === $this->missingValueMarker() ? $this->resolveDefault($default) : $resolved; + } + + protected function hasResolvedValue(int|string $key): bool + { + return $this->resolveRawValue($key) !== $this->missingValueMarker(); + } + + protected function isReadCacheSafePath(string $path): bool + { + if ( + !str_contains($path, '.') + || str_contains($path, '\\') + || str_contains($path, '*') + || str_contains($path, '{') + ) { + return false; + } + + $cursor = $this->items; + foreach (explode('.', $path) as $segment) { + if (!is_array($cursor)) { + return false; + } + + if (!array_key_exists($segment, $cursor)) { + return true; + } + + if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { + return false; + } + + $cursor = $cursor[$segment]; + } + + return $cursor === null || is_scalar($cursor); + } + + protected function materializeCacheValue(mixed $value): mixed + { + $activeReferences = []; + $activeObjects = []; + + return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); + } + + protected function missingValueMarker(): object + { + static $missing; + + if (!is_object($missing)) { + $missing = new \stdClass(); + } + + return $missing; + } + + protected function resolveDefault(mixed $default): mixed + { + return $default instanceof \Closure ? $default() : $default; + } + + protected function resolveRawValue(int|string $key): mixed + { + if ( + is_int($key) + || (!str_contains($key, '.') && !str_contains($key, '\\')) + ) { + return array_key_exists($key, $this->items) + ? $this->items[$key] + : $this->missingValueMarker(); + } + + if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { + return DotNotation::get($this->items, $key, $this->missingValueMarker()); + } + + $cacheKey = $this->valueCacheKey($key); + if (array_key_exists($cacheKey, $this->resolvedValueCache)) { + return $this->resolvedValueCache[$cacheKey]; + } + + return $this->cacheResolvedValue( + $cacheKey, + DotNotation::get($this->items, $key, $this->missingValueMarker()), + ); + } + + protected function valueCacheKey(int|string $key): string + { + return is_int($key) ? 'i:' . $key : 's:' . $key; + } + + protected function writeCacheFile(string $path, string $contents): bool + { + try { + if (token_get_all($contents, TOKEN_PARSE) === []) { + throw new UnexpectedValueException('Generated configuration cache is empty.'); + } + } catch (\ParseError $error) { + throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); + } + + $directory = dirname($path); + $temporaryPath = tempnam($directory, '.arraykit-'); + if ($temporaryPath === false) { + return false; + } + + $written = file_put_contents($temporaryPath, $contents, LOCK_EX); + if ($written !== strlen($contents)) { + unlink($temporaryPath); + + return false; + } + + if (rename($temporaryPath, $path)) { + return true; + } + + unlink($temporaryPath); + + return false; + } + + /** + * @param array $activeReferences + * @param array $activeObjects + */ + private function materializeCacheValueRecursive( + mixed $value, + array &$activeReferences, + array &$activeObjects, + ): mixed { + if ($value instanceof EnvReference || $value instanceof \Closure) { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); + } + + $activeObjects[$objectId] = true; + + try { + $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); + + return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); + } finally { + unset($activeObjects[$objectId]); + } + } + + if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { + return $value; + } + + if (is_array($value)) { + $materialized = []; + foreach ($value as $key => $entry) { + $reference = \ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + } finally { + unset($activeReferences[$referenceId]); + } + } + + return $materialized; + } + + throw new UnexpectedValueException( + 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', + ); + } } From 42cae54605a0055ff70c074a2829682aa2ce9649 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:07:37 +0600 Subject: [PATCH 046/129] style(config): restore lazy config class-element structure --- src/Config/LazyFileConfig.php | 210 +++++++++++++++++----------------- 1 file changed, 104 insertions(+), 106 deletions(-) diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index a817eb9..b6a1efc 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -14,110 +14,6 @@ class LazyFileConfig extends Config { use LazyFileConfigCacheTrait; - protected function forgetPath(string $path): void - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - $this->markNamespaceRuntime($namespace); - - return; - } - - if ($rest === null || $rest === '') { - unset($this->items[$namespace]); - $this->markNamespaceRuntime($namespace); - - return; - } - - if (!is_array($this->items[$namespace])) { - return; - } - - DotNotation::forget($this->items[$namespace], $rest); - $this->markNamespaceRuntime($namespace); - } - - protected function getPath(string $path, mixed $default): mixed - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - return $this->resolveDefault($default); - } - - if ($rest === null || $rest === '') { - return $this->items[$namespace]; - } - - if (!is_array($this->items[$namespace])) { - return $this->resolveDefault($default); - } - - return DotNotation::get($this->items[$namespace], $rest, $default); - } - - protected function hasPath(string $path): bool - { - [$namespace, $rest] = $this->splitPath($path); - $this->loadNamespace($namespace); - - if (!array_key_exists($namespace, $this->items)) { - return false; - } - - if ($rest === null || $rest === '') { - return true; - } - - if (!is_array($this->items[$namespace])) { - return false; - } - - return DotNotation::has($this->items[$namespace], $rest); - } - - protected function loadNamespace(string $namespace): void - { - if (isset($this->loadedNamespaces[$namespace])) { - return; - } - - $cachedFile = $this->resolveCachedNamespaceFile($namespace); - $sourceFile = $this->resolveNamespaceFile($namespace); - $file = $cachedFile ?? $sourceFile; - - if ($file === null) { - $this->loadedNamespaces[$namespace] = true; - $this->loadedNamespaceOrigins[$namespace] = 'missing'; - - return; - } - - $loaded = include $file; - if (!is_array($loaded)) { - throw new UnexpectedValueException("Config file [{$file}] must return an array."); - } - - $this->loadedNamespaces[$namespace] = true; - $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; - - if (!array_key_exists($namespace, $this->items)) { - $this->items[$namespace] = $loaded; - $this->flushReadCache(); - - return; - } - - if (is_array($this->items[$namespace])) { - $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); - $this->flushReadCache(); - } - } - /** * @var array */ @@ -375,6 +271,110 @@ public function set(string|array|null $key = null, mixed $value = null, bool $ov return true; } + protected function forgetPath(string $path): void + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + $this->markNamespaceRuntime($namespace); + + return; + } + + if ($rest === null || $rest === '') { + unset($this->items[$namespace]); + $this->markNamespaceRuntime($namespace); + + return; + } + + if (!is_array($this->items[$namespace])) { + return; + } + + DotNotation::forget($this->items[$namespace], $rest); + $this->markNamespaceRuntime($namespace); + } + + protected function getPath(string $path, mixed $default): mixed + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return $this->resolveDefault($default); + } + + if ($rest === null || $rest === '') { + return $this->items[$namespace]; + } + + if (!is_array($this->items[$namespace])) { + return $this->resolveDefault($default); + } + + return DotNotation::get($this->items[$namespace], $rest, $default); + } + + protected function hasPath(string $path): bool + { + [$namespace, $rest] = $this->splitPath($path); + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return false; + } + + if ($rest === null || $rest === '') { + return true; + } + + if (!is_array($this->items[$namespace])) { + return false; + } + + return DotNotation::has($this->items[$namespace], $rest); + } + + protected function loadNamespace(string $namespace): void + { + if (isset($this->loadedNamespaces[$namespace])) { + return; + } + + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + $sourceFile = $this->resolveNamespaceFile($namespace); + $file = $cachedFile ?? $sourceFile; + + if ($file === null) { + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; + + return; + } + + $loaded = include $file; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$file}] must return an array."); + } + + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; + + if (!array_key_exists($namespace, $this->items)) { + $this->items[$namespace] = $loaded; + $this->flushReadCache(); + + return; + } + + if (is_array($this->items[$namespace])) { + $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); + } + } + protected function normalizeNamespace(string $namespace): string { $trimmed = trim($namespace); @@ -517,8 +517,6 @@ protected function splitPath(string $path): array return [$namespace, $rest === '' ? null : $rest]; } - - protected function syncLoadedNamespacesFromItems(): void { parent::flushReadCache(); From b1a93f4a9d67ec0fdac258c65170e961b0334f66 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:08:06 +0600 Subject: [PATCH 047/129] style(config): restore cache trait class-element structure --- .../Concerns/LazyFileConfigCacheTrait.php | 261 ++++++++---------- 1 file changed, 121 insertions(+), 140 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 90621a3..6306696 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -10,17 +10,102 @@ /** @internal */ trait LazyFileConfigCacheTrait { - private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; +private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; - private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; +private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; - private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; +private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; - private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; +private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; - private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; +private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; - protected function cachedNamespacePath(string $namespace): ?string +/** + * @var array + */ + protected array $flatLeafIndex = []; + +protected bool $flatLeafIndexLoaded = false; + +protected ?string $namespaceCacheDirectory = null; + +/** + * @param string|array|null $namespaces + */ + public function flushNamespaceCache(string|array|null $namespaces = null): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + return $this; + } + + if (!is_dir($directory)) { + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + + $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); + + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishFlushGeneration($resolved); + }); + + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + +public function namespaceCache(?string $directory): static + { + $this->invalidateGeneratedNamespaceState(); + + $this->namespaceCacheDirectory = $directory !== null + ? rtrim($directory, DIRECTORY_SEPARATOR) + : null; + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + + return $this; + } + +public function namespaceCacheDirectory(): ?string + { + return $this->namespaceCacheDirectory; + } + +/** + * @param string|array|null $namespaces + */ + public function warmNamespaceCache(string|array|null $namespaces = null): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); + } + + $resolved = $this->resolveWarmNamespaces($namespaces); + + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishWarmGeneration($resolved); + }); + + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); + + return $this; + } + +protected function cachedNamespacePath(string $namespace): ?string { $directory = $this->activeNamespaceCacheDirectory(); if ($directory === null) { @@ -34,7 +119,7 @@ protected function cachedNamespacePath(string $namespace): ?string return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; } - /** +/** * @param array $namespaceData * @param array $index */ @@ -61,7 +146,7 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData, } } - /** @return string[] */ +/** @return string[] */ protected function discoverNamespaces(): array { $namespaces = []; @@ -86,11 +171,7 @@ protected function discoverNamespaces(): array return array_keys($namespaces); } - - - - - protected function flatLeafIndexPath(): ?string +protected function flatLeafIndexPath(): ?string { $directory = $this->activeNamespaceCacheDirectory(); if ($directory === null) { @@ -100,7 +181,7 @@ protected function flatLeafIndexPath(): ?string return $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; } - protected function flatLeafValue(string $path): mixed +protected function flatLeafValue(string $path): mixed { $this->loadFlatLeafIndex(); @@ -109,7 +190,7 @@ protected function flatLeafValue(string $path): mixed : $this->missingValueMarker(); } - protected function invalidateGeneratedNamespaceState(): void +protected function invalidateGeneratedNamespaceState(): void { foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { if ($origin === 'cache') { @@ -124,7 +205,7 @@ protected function invalidateGeneratedNamespaceState(): void $this->flushReadCache(); } - protected function isCacheableLeafValue(mixed $value): bool +protected function isCacheableLeafValue(mixed $value): bool { return $value === null || is_bool($value) @@ -133,7 +214,7 @@ protected function isCacheableLeafValue(mixed $value): bool || is_string($value); } - protected function isEligibleFlatLookupPath(string $path): bool +protected function isEligibleFlatLookupPath(string $path): bool { return str_contains($path, '.') && !str_contains($path, '*') @@ -141,7 +222,7 @@ protected function isEligibleFlatLookupPath(string $path): bool && !str_contains($path, '{'); } - protected function loadFlatLeafIndex(): void +protected function loadFlatLeafIndex(): void { if ($this->flatLeafIndexLoaded) { return; @@ -164,11 +245,7 @@ protected function loadFlatLeafIndex(): void $this->flatLeafIndexLoaded = true; } - /** - * @param string|array|null $namespaces - * @return string[] - */ - /** +/** * @return array */ protected function namespaceCacheWarmValue(string $namespace): array @@ -212,6 +289,10 @@ protected function namespaceCacheWarmValue(string $namespace): array throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); } +/** + * @param string|array|null $namespaces + * @return string[] + */ protected function resolveWarmNamespaces(string|array|null $namespaces): array { if ($namespaces === null) { @@ -226,9 +307,7 @@ protected function resolveWarmNamespaces(string|array|null $namespaces): array return array_values(array_unique($resolved)); } - - - private function activateGeneration(string $stage): void +private function activateGeneration(string $stage): void { $root = $this->namespaceCacheDirectory; if ($root === null) { @@ -251,7 +330,7 @@ private function activateGeneration(string $stage): void } } - private function activeNamespaceCacheDirectory(): ?string +private function activeNamespaceCacheDirectory(): ?string { $root = $this->namespaceCacheDirectory; if ($root === null || !is_dir($root)) { @@ -273,7 +352,7 @@ private function activeNamespaceCacheDirectory(): ?string return is_dir($directory) ? $directory : $root; } - /** @return array */ +/** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { $index = []; @@ -293,7 +372,7 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array return $index; } - /** +/** * @param array $excluded */ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): void @@ -317,13 +396,7 @@ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): } } - - - - - - - private function createGenerationStage(): string +private function createGenerationStage(): string { $root = $this->namespaceCacheDirectory; if ($root === null) { @@ -338,7 +411,7 @@ private function createGenerationStage(): string return $stage; } - /** +/** * @param array $namespaces */ private function discoverNamespacesInDirectory(string $directory, array &$namespaces, bool $legacy): void @@ -352,7 +425,7 @@ private function discoverNamespacesInDirectory(string $directory, array &$namesp } } - /** +/** * @param array $loaded * @return array */ @@ -373,18 +446,14 @@ private function filterFlatLeafIndex(array $loaded): array return $index; } - - - - - private function generationPointerPath(): ?string +private function generationPointerPath(): ?string { return $this->namespaceCacheDirectory === null ? null : $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::CACHE_GENERATION_POINTER; } - private function isFlatPathSafeSegment(string $segment): bool +private function isFlatPathSafeSegment(string $segment): bool { return !str_contains($segment, '.') && !str_contains($segment, '\\') @@ -392,7 +461,7 @@ private function isFlatPathSafeSegment(string $segment): bool && !str_contains($segment, '{'); } - /** +/** * @return array */ private function namespaceCacheEntries(string $directory, bool $legacy): array @@ -428,7 +497,7 @@ private function namespaceCacheEntries(string $directory, bool $legacy): array return $resolved; } - /** +/** * @param string[]|null $namespaces */ private function publishFlushGeneration(?array $namespaces): void @@ -451,7 +520,7 @@ private function publishFlushGeneration(?array $namespaces): void } } - /** +/** * @param string[] $namespaces */ private function publishWarmGeneration(array $namespaces): void @@ -485,96 +554,7 @@ private function publishWarmGeneration(array $namespaces): void } } - - - - - /** - * @var array - */ - protected array $flatLeafIndex = []; - - protected bool $flatLeafIndexLoaded = false; - - protected ?string $namespaceCacheDirectory = null; - - /** - * @param string|array|null $namespaces - */ - public function flushNamespaceCache(string|array|null $namespaces = null): static - { - $directory = $this->namespaceCacheDirectory; - if ($directory === null) { - return $this; - } - - if (!is_dir($directory)) { - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - - $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); - - $this->withNamespaceCacheLock(function () use ($resolved): void { - $this->publishFlushGeneration($resolved); - }); - - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - - public function namespaceCache(?string $directory): static - { - $this->invalidateGeneratedNamespaceState(); - - $this->namespaceCacheDirectory = $directory !== null - ? rtrim($directory, DIRECTORY_SEPARATOR) - : null; - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - - return $this; - } - - public function namespaceCacheDirectory(): ?string - { - return $this->namespaceCacheDirectory; - } - - /** - * @param string|array|null $namespaces - */ - public function warmNamespaceCache(string|array|null $namespaces = null): static - { - $directory = $this->namespaceCacheDirectory; - if ($directory === null) { - throw new RuntimeException('Namespace cache directory is not configured.'); - } - - if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { - throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); - } - - $resolved = $this->resolveWarmNamespaces($namespaces); - - $this->withNamespaceCacheLock(function () use ($resolved): void { - $this->publishWarmGeneration($resolved); - }); - - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; - $this->invalidateGeneratedNamespaceState(); - - return $this; - } - - private function removeGenerationDirectory(string $directory): void +private function removeGenerationDirectory(string $directory): void { foreach (scandir($directory) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { @@ -590,7 +570,7 @@ private function removeGenerationDirectory(string $directory): void rmdir($directory); } - private function withNamespaceCacheLock(\Closure $operation): static +private function withNamespaceCacheLock(\Closure $operation): static { $directory = $this->namespaceCacheDirectory; if ($directory === null) { @@ -616,7 +596,7 @@ private function withNamespaceCacheLock(\Closure $operation): static return $this; } - private function writeGenerationFlatIndex(string $directory): void +private function writeGenerationFlatIndex(string $directory): void { $index = $this->buildFlatLeafIndexFromDirectory($directory); ksort($index); @@ -627,7 +607,7 @@ private function writeGenerationFlatIndex(string $directory): void } } - private function writeGenerationPointer(string $generation): void +private function writeGenerationPointer(string $generation): void { $path = $this->generationPointerPath(); if ($path === null) { @@ -653,4 +633,5 @@ private function writeGenerationPointer(string $generation): void throw new RuntimeException('Unable to publish lazy-config generation pointer.'); } } + } From 7bf6be45e0f1f7cf0717533551018398c6548407 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:08:16 +0600 Subject: [PATCH 048/129] test(config): reuse fixture enum for export coverage --- tests/Feature/Release530BatchBTest.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/Feature/Release530BatchBTest.php b/tests/Feature/Release530BatchBTest.php index 2b16722..2731889 100644 --- a/tests/Feature/Release530BatchBTest.php +++ b/tests/Feature/Release530BatchBTest.php @@ -5,12 +5,7 @@ use Infocyph\ArrayKit\Config\Config; use Infocyph\ArrayKit\Config\LazyFileConfig; use Infocyph\ArrayKit\Config\Support\Environment; - - -enum BatchBCacheMode: string -{ - case Production = 'production'; -} +use Infocyph\ArrayKit\Tests\Fixtures\ConfigMode; function batchBRemoveDirectory(string $path): void { @@ -413,7 +408,7 @@ function batchBWriteConfig(string $directory, string $namespace, array $value): $config->loadArray([ 'app' => [ 'enabled' => true, - 'mode' => BatchBCacheMode::Production, + 'mode' => ConfigMode::Prod, 'nullable' => null, 'ports' => [80, 443], ], @@ -423,7 +418,7 @@ function batchBWriteConfig(string $directory, string $namespace, array $value): ->and(include $cache)->toBe([ 'app' => [ 'enabled' => true, - 'mode' => BatchBCacheMode::Production, + 'mode' => ConfigMode::Prod, 'nullable' => null, 'ports' => [80, 443], ], From a77f12d613ba3bfa7d173fef7bccb76e76c7a1e9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:08:47 +0600 Subject: [PATCH 049/129] style(config): restore trait member indentation --- .../Concerns/LazyFileConfigCacheTrait.php | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 6306696..5303daa 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -10,26 +10,26 @@ /** @internal */ trait LazyFileConfigCacheTrait { -private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; + private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; -private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; + private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; -private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; + private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; -private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; + private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; -private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; + private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; -/** + /** * @var array */ protected array $flatLeafIndex = []; -protected bool $flatLeafIndexLoaded = false; + protected bool $flatLeafIndexLoaded = false; -protected ?string $namespaceCacheDirectory = null; + protected ?string $namespaceCacheDirectory = null; -/** + /** * @param string|array|null $namespaces */ public function flushNamespaceCache(string|array|null $namespaces = null): static @@ -60,7 +60,7 @@ public function flushNamespaceCache(string|array|null $namespaces = null): stati return $this; } -public function namespaceCache(?string $directory): static + public function namespaceCache(?string $directory): static { $this->invalidateGeneratedNamespaceState(); @@ -73,12 +73,12 @@ public function namespaceCache(?string $directory): static return $this; } -public function namespaceCacheDirectory(): ?string + public function namespaceCacheDirectory(): ?string { return $this->namespaceCacheDirectory; } -/** + /** * @param string|array|null $namespaces */ public function warmNamespaceCache(string|array|null $namespaces = null): static @@ -105,7 +105,7 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static return $this; } -protected function cachedNamespacePath(string $namespace): ?string + protected function cachedNamespacePath(string $namespace): ?string { $directory = $this->activeNamespaceCacheDirectory(); if ($directory === null) { @@ -119,7 +119,7 @@ protected function cachedNamespacePath(string $namespace): ?string return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; } -/** + /** * @param array $namespaceData * @param array $index */ @@ -146,7 +146,7 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData, } } -/** @return string[] */ + /** @return string[] */ protected function discoverNamespaces(): array { $namespaces = []; @@ -171,7 +171,7 @@ protected function discoverNamespaces(): array return array_keys($namespaces); } -protected function flatLeafIndexPath(): ?string + protected function flatLeafIndexPath(): ?string { $directory = $this->activeNamespaceCacheDirectory(); if ($directory === null) { @@ -181,7 +181,7 @@ protected function flatLeafIndexPath(): ?string return $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; } -protected function flatLeafValue(string $path): mixed + protected function flatLeafValue(string $path): mixed { $this->loadFlatLeafIndex(); @@ -190,7 +190,7 @@ protected function flatLeafValue(string $path): mixed : $this->missingValueMarker(); } -protected function invalidateGeneratedNamespaceState(): void + protected function invalidateGeneratedNamespaceState(): void { foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { if ($origin === 'cache') { @@ -205,7 +205,7 @@ protected function invalidateGeneratedNamespaceState(): void $this->flushReadCache(); } -protected function isCacheableLeafValue(mixed $value): bool + protected function isCacheableLeafValue(mixed $value): bool { return $value === null || is_bool($value) @@ -214,7 +214,7 @@ protected function isCacheableLeafValue(mixed $value): bool || is_string($value); } -protected function isEligibleFlatLookupPath(string $path): bool + protected function isEligibleFlatLookupPath(string $path): bool { return str_contains($path, '.') && !str_contains($path, '*') @@ -222,7 +222,7 @@ protected function isEligibleFlatLookupPath(string $path): bool && !str_contains($path, '{'); } -protected function loadFlatLeafIndex(): void + protected function loadFlatLeafIndex(): void { if ($this->flatLeafIndexLoaded) { return; @@ -245,7 +245,7 @@ protected function loadFlatLeafIndex(): void $this->flatLeafIndexLoaded = true; } -/** + /** * @return array */ protected function namespaceCacheWarmValue(string $namespace): array @@ -289,7 +289,7 @@ protected function namespaceCacheWarmValue(string $namespace): array throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); } -/** + /** * @param string|array|null $namespaces * @return string[] */ @@ -307,7 +307,7 @@ protected function resolveWarmNamespaces(string|array|null $namespaces): array return array_values(array_unique($resolved)); } -private function activateGeneration(string $stage): void + private function activateGeneration(string $stage): void { $root = $this->namespaceCacheDirectory; if ($root === null) { @@ -330,7 +330,7 @@ private function activateGeneration(string $stage): void } } -private function activeNamespaceCacheDirectory(): ?string + private function activeNamespaceCacheDirectory(): ?string { $root = $this->namespaceCacheDirectory; if ($root === null || !is_dir($root)) { @@ -352,7 +352,7 @@ private function activeNamespaceCacheDirectory(): ?string return is_dir($directory) ? $directory : $root; } -/** @return array */ + /** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { $index = []; @@ -372,7 +372,7 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array return $index; } -/** + /** * @param array $excluded */ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): void @@ -396,7 +396,7 @@ private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): } } -private function createGenerationStage(): string + private function createGenerationStage(): string { $root = $this->namespaceCacheDirectory; if ($root === null) { @@ -411,7 +411,7 @@ private function createGenerationStage(): string return $stage; } -/** + /** * @param array $namespaces */ private function discoverNamespacesInDirectory(string $directory, array &$namespaces, bool $legacy): void @@ -425,7 +425,7 @@ private function discoverNamespacesInDirectory(string $directory, array &$namesp } } -/** + /** * @param array $loaded * @return array */ @@ -446,14 +446,14 @@ private function filterFlatLeafIndex(array $loaded): array return $index; } -private function generationPointerPath(): ?string + private function generationPointerPath(): ?string { return $this->namespaceCacheDirectory === null ? null : $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::CACHE_GENERATION_POINTER; } -private function isFlatPathSafeSegment(string $segment): bool + private function isFlatPathSafeSegment(string $segment): bool { return !str_contains($segment, '.') && !str_contains($segment, '\\') @@ -461,7 +461,7 @@ private function isFlatPathSafeSegment(string $segment): bool && !str_contains($segment, '{'); } -/** + /** * @return array */ private function namespaceCacheEntries(string $directory, bool $legacy): array @@ -497,7 +497,7 @@ private function namespaceCacheEntries(string $directory, bool $legacy): array return $resolved; } -/** + /** * @param string[]|null $namespaces */ private function publishFlushGeneration(?array $namespaces): void @@ -520,7 +520,7 @@ private function publishFlushGeneration(?array $namespaces): void } } -/** + /** * @param string[] $namespaces */ private function publishWarmGeneration(array $namespaces): void @@ -554,7 +554,7 @@ private function publishWarmGeneration(array $namespaces): void } } -private function removeGenerationDirectory(string $directory): void + private function removeGenerationDirectory(string $directory): void { foreach (scandir($directory) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { @@ -570,7 +570,7 @@ private function removeGenerationDirectory(string $directory): void rmdir($directory); } -private function withNamespaceCacheLock(\Closure $operation): static + private function withNamespaceCacheLock(\Closure $operation): static { $directory = $this->namespaceCacheDirectory; if ($directory === null) { @@ -596,7 +596,7 @@ private function withNamespaceCacheLock(\Closure $operation): static return $this; } -private function writeGenerationFlatIndex(string $directory): void + private function writeGenerationFlatIndex(string $directory): void { $index = $this->buildFlatLeafIndexFromDirectory($directory); ksort($index); @@ -607,7 +607,7 @@ private function writeGenerationFlatIndex(string $directory): void } } -private function writeGenerationPointer(string $generation): void + private function writeGenerationPointer(string $generation): void { $path = $this->generationPointerPath(); if ($path === null) { From be7d2772183285a16a345ff7902a0b0efc779b99 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 4 Oct 2026 22:12:38 +0600 Subject: [PATCH 050/129] style(config): remove trailing class separator --- src/Config/Concerns/LazyFileConfigCacheTrait.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 5303daa..5941dbc 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -633,5 +633,4 @@ private function writeGenerationPointer(string $generation): void throw new RuntimeException('Unable to publish lazy-config generation pointer.'); } } - } From dd68adde136e2e8e0cdae8d22e27e4915439fbfe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:38:31 +0600 Subject: [PATCH 051/129] docs(plan): close batch A and start batch B From 7315cb97a9e501088729b0a829a89b6e7ee53837 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:40:29 +0600 Subject: [PATCH 052/129] fix(config): invalidate memos and version lazy flat cache --- src/Config/Concerns/LazyFileConfigCacheTrait.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 5941dbc..92d63c9 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -18,6 +18,8 @@ trait LazyFileConfigCacheTrait private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; + private const string FLAT_INDEX_FILE = '.arraykit-flat-v2.php'; + private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; /** @@ -69,6 +71,7 @@ public function namespaceCache(?string $directory): static : null; $this->flatLeafIndex = []; $this->flatLeafIndexLoaded = false; + $this->flushReadCache(); return $this; } From 7f3f9135842c9ebd310e6706f807a99c06021e05 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:41:52 +0600 Subject: [PATCH 053/129] fix(config): remove duplicate flat-index constant --- src/Config/Concerns/LazyFileConfigCacheTrait.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 92d63c9..2c4e039 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -18,8 +18,6 @@ trait LazyFileConfigCacheTrait private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; - private const string FLAT_INDEX_FILE = '.arraykit-flat-v2.php'; - private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; /** From d4a7c609f98a321c06c8ddb72b75ecbfb8b7e98b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:45:50 +0600 Subject: [PATCH 054/129] refactor(array): centralize strict membership lookup semantics --- src/Array/ArrayValueSetOps.php | 112 +++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php index d4d672f..4b40094 100644 --- a/src/Array/ArrayValueSetOps.php +++ b/src/Array/ArrayValueSetOps.php @@ -23,27 +23,12 @@ final class ArrayValueSetOps */ public static function containsAll(array $array, array $needles, bool $strict): bool { - if (!$strict) { - return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, false)); - } - - if (count($needles) < self::CONTAINS_ALL_LOOKUP_MIN_NEEDLES) { - return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); - } - - $firstKey = array_key_first($needles); - if (!in_array($needles[$firstKey], $array, true)) { - return false; - } - - $lookup = self::buildStrictLookup($array); - if ($lookup === null) { - return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); - } - - return array_all( + return self::containsByMembership( + $array, $needles, - static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + $strict, + self::CONTAINS_ALL_LOOKUP_MIN_NEEDLES, + requireAll: true, ); } @@ -53,27 +38,12 @@ public static function containsAll(array $array, array $needles, bool $strict): */ public static function containsAny(array $array, array $needles, bool $strict): bool { - if (!$strict) { - return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, false)); - } - - if (count($needles) < self::CONTAINS_ANY_LOOKUP_MIN_NEEDLES) { - return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); - } - - $firstKey = array_key_first($needles); - if (in_array($needles[$firstKey], $array, true)) { - return true; - } - - $lookup = self::buildStrictLookup($array); - if ($lookup === null) { - return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); - } - - return array_any( + return self::containsByMembership( + $array, $needles, - static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + $strict, + self::CONTAINS_ANY_LOOKUP_MIN_NEEDLES, + requireAll: false, ); } @@ -158,6 +128,24 @@ public static function same(array $left, array $right, bool $strict): bool return $leftCounts === $rightCounts; } + /** + * @param array $values + * @return array|null + */ + public static function strictLookup(array $values): ?array + { + return self::buildStrictLookup($values); + } + + /** + * @param array $lookup + */ + public static function strictLookupContains(array $lookup, mixed $value): bool + { + return self::isStrictHashable($value) + && isset($lookup[self::fingerprintStrict($value)]); + } + /** * Track strict membership with canonical fingerprints and a safe scan fallback. * @@ -220,6 +208,50 @@ public static function unique(array $array, bool $strict): array return $result; } + /** + * @param array $array + * @param array $needles + */ + private static function containsByMembership( + array $array, + array $needles, + bool $strict, + int $lookupThreshold, + bool $requireAll, + ): bool { + $matcher = $requireAll ? 'array_all' : 'array_any'; + + if (!$strict || count($needles) < $lookupThreshold) { + return $matcher( + $needles, + static fn(mixed $needle): bool => in_array($needle, $array, $strict), + ); + } + + $firstKey = array_key_first($needles); + if ($firstKey === null) { + return $requireAll; + } + + $firstMatches = in_array($needles[$firstKey], $array, true); + if ($firstMatches !== $requireAll) { + return !$requireAll; + } + + $lookup = self::buildStrictLookup($array); + if ($lookup === null) { + return $matcher( + $needles, + static fn(mixed $needle): bool => in_array($needle, $array, true), + ); + } + + return $matcher( + $needles, + static fn(mixed $needle): bool => self::strictLookupContains($lookup, $needle), + ); + } + /** * @param array $array */ From 4ff084424b802f234e7f8919a05c362bcfaebfa7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:46:11 +0600 Subject: [PATCH 055/129] fix(array): preserve membership and SQL-like match semantics --- .../Concerns/ArrayMultiQuerySortTrait.php | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 958b8f7..e4af091 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -658,7 +658,7 @@ public static function whereIn(array $array, string $key, array $values, bool $s public static function whereLike(array $array, string $key, string $pattern, bool $caseSensitive = false): array { $quoted = preg_quote($pattern, '/'); - $regex = '/^' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '$/' . ($caseSensitive ? '' : 'i'); + $regex = '/\\A' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '\\z/s' . ($caseSensitive ? '' : 'i'); $results = []; foreach ($array as $index => $row) { @@ -671,8 +671,12 @@ public static function whereLike(array $array, string $key, string $pattern, boo continue; } - $text = (string) $value; - if (preg_match($regex, $text) === 1) { + $matched = preg_match($regex, (string) $value); + if ($matched === false) { + throw new \RuntimeException('SQL-like pattern matching failed: ' . preg_last_error_msg()); + } + + if ($matched === 1) { $results[$index] = $row; } } @@ -776,16 +780,7 @@ private static function buildInLookup(array $values, bool $strict): ?array } if ($strict) { - $lookup = []; - foreach ($values as $value) { - if (self::containsNonReflexiveStrictValue($value)) { - return null; - } - - $lookup[ArraySingleOps::fingerprint($value, true)] = true; - } - - return $lookup; + return ArrayValueSetOps::strictLookup($values); } $lookup = ['type:non-numeric-string' => true]; @@ -931,19 +926,6 @@ private static function compareSortValues(mixed $left, mixed $right, int $option }; } - private static function containsNonReflexiveStrictValue(mixed $value): bool - { - if (is_float($value)) { - return is_nan($value); - } - - if (!is_array($value)) { - return false; - } - - return array_any($value, self::containsNonReflexiveStrictValue(...)); - } - private static function extractComparableValue(mixed $row, string|callable $keyOrCallback, int|string $key): float|int|null { if (!is_string($keyOrCallback)) { @@ -1187,7 +1169,7 @@ private static function resolveSortByManyValue(mixed $row, string|callable $by, private static function rowLookupContains(array $lookup, array $values, mixed $candidate, bool $strict): bool { if ($strict) { - return isset($lookup[ArraySingleOps::fingerprint($candidate, true)]); + return ArrayValueSetOps::strictLookupContains($lookup, $candidate); } if (is_string($candidate) && !is_numeric($candidate)) { From 520aa43de1aa84d3fe6190c0916774b5c3c23e95 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:46:28 +0600 Subject: [PATCH 056/129] fix(dot): distinguish wildcard presence from leaf values --- src/Array/DotNotationPathOps.php | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index 4995fdc..e03ed47 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -41,6 +41,41 @@ public static function escapePathSegment(string $segment): string ); } + /** + * Determine whether a path resolves to at least one existing value. + * + * @param array $segments + */ + public static function matchesPath(mixed $target, array $segments, int $position = 0): bool + { + if ($position >= count($segments)) { + return true; + } + + $segment = $segments[$position]; + if ($segment === '*') { + $target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target; + if (!is_array($target)) { + return false; + } + + foreach ($target as $item) { + if (self::matchesPath($item, $segments, $position + 1)) { + return true; + } + } + + return false; + } + + $missing = new \stdClass(); + $normalized = self::normalizeSegment($segment, $target); + $next = self::accessSegment($target, $normalized, $missing); + + return $next !== $missing + && self::matchesPath($next, $segments, $position + 1); + } + /** * Normalize a dot-notation segment by replacing escaped values and resolving * special values such as '{first}' and '{last}'. From 83767a2b8a633ad97244c183f946885db5be4684 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:46:31 +0600 Subject: [PATCH 057/129] fix(dot): use explicit wildcard existence traversal --- .../Concerns/DotNotationPublicApiTrait.php | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index a3a4bd4..d9eeb15 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -279,10 +279,7 @@ public static function matches(array $array, string $path): bool return self::has($array, $path); } - $missing = self::missing(); - $resolved = self::get($array, $path, $missing); - - return self::containsResolvedValue($resolved, $missing); + return DotNotationPathOps::matchesPath($array, self::splitPath($path)); } /** @@ -428,16 +425,4 @@ public static function tap(array $array, callable $callback): array return $array; } - private static function containsResolvedValue(mixed $value, object $missing): bool - { - if ($value === $missing) { - return false; - } - - if (!is_array($value)) { - return true; - } - - return array_any($value, fn($item) => self::containsResolvedValue($item, $missing)); - } } From 0193de4cc61463e4c14ff1d95e71cdfde8d7116e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:46:55 +0600 Subject: [PATCH 058/129] fix(array): make pagination offset overflow-safe --- src/Array/ArraySingle.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index c10290e..c81bc55 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -605,6 +605,16 @@ public static function paginate(array $array, int $page, int $perPage): array throw new InvalidArgumentException('Per-page value must be greater than or equal to 1.'); } + $count = count($array); + if ($count === 0) { + return []; + } + + $lastPage = intdiv($count - 1, $perPage) + 1; + if ($page > $lastPage) { + return []; + } + return array_slice( $array, ($page - 1) * $perPage, From 9e5dacf6c396b1478aa67b1fad0b2647a0cb068a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:46:59 +0600 Subject: [PATCH 059/129] refactor(config): share append and prepend array resolution --- src/Config/Concerns/BaseConfigTrait.php | 36 ++++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 475bef7..f4869ec 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -58,14 +58,7 @@ public function append(string $key, mixed $value): bool { $this->assertWritable(); - $missing = $this->missingValueMarker(); - $array = $this->get($key, $missing); - if ($array === $missing) { - $array = []; - } elseif (!is_array($array)) { - throw new InvalidArgumentException("Config value [{$key}] must be an array."); - } - + $array = $this->arrayValueForMutation($key); $array[] = $value; return $this->set($key, $array); @@ -427,14 +420,7 @@ public function prepend(string $key, mixed $value): bool { $this->assertWritable(); - $missing = $this->missingValueMarker(); - $array = $this->get($key, $missing); - if ($array === $missing) { - $array = []; - } elseif (!is_array($array)) { - throw new InvalidArgumentException("Config value [{$key}] must be an array."); - } - + $array = $this->arrayValueForMutation($key); array_unshift($array, $value); return $this->set($key, $array); @@ -552,6 +538,24 @@ public function snapshot(string $name = 'default'): bool return true; } + /** + * @return array + */ + private function arrayValueForMutation(string $key): array + { + $missing = $this->missingValueMarker(); + $array = $this->get($key, $missing); + if ($array === $missing) { + return []; + } + + if (!is_array($array)) { + throw new InvalidArgumentException("Config value [{$key}] must be an array."); + } + + return $array; + } + protected function assertWritable(): void { if ($this->readOnly) { From cd8f572829c2433d06357bd7c45e5827e714a416 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:47:55 +0600 Subject: [PATCH 060/129] test(array): cover batch C semantic boundaries --- tests/Feature/Release530BatchCTest.php | 182 +++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/Feature/Release530BatchCTest.php diff --git a/tests/Feature/Release530BatchCTest.php b/tests/Feature/Release530BatchCTest.php new file mode 100644 index 0000000..14e4d58 --- /dev/null +++ b/tests/Feature/Release530BatchCTest.php @@ -0,0 +1,182 @@ + ['value' => $candidate], + 'stored' => ['value' => $stored], + ]; + + expect(ArrayMulti::whereIn($rows, 'value', $values, true))->toBe([ + 'stored' => ['value' => $stored], + ])->and(ArrayMulti::whereNotIn($rows, 'value', $values, true))->toBe([ + 'candidate' => ['value' => $candidate], + ])->and(ArrayMulti::firstWhereIn($rows, 'value', $values, true))->toBe([ + 'value' => $stored, + ]); +})->with([255, 256, 257]); + +it('preserves strict membership for nested resources nan objects and scalar edge cases', function () { + $left = batchCClosedResource(); + $right = batchCClosedResource(); + $object = new stdClass(); + $otherObject = new stdClass(); + + $cases = [ + [['resource' => $right], array_fill(0, 255, null) + [['resource' => $left]]], + [NAN, array_fill(0, 255, 0) + [NAN]], + [$otherObject, array_fill(0, 255, null) + [$object]], + [null, array_fill(0, 255, 'x') + [null]], + [false, array_fill(0, 255, 'x') + [0]], + [0, array_fill(0, 255, 'x') + [false]], + ]; + + foreach ($cases as [$candidate, $values]) { + $expected = in_array($candidate, $values, true); + $actual = ArrayMulti::whereIn([['value' => $candidate]], 'value', $values, true); + + expect($actual !== [])->toBe($expected); + } +}); + +it('keeps strict membership semantics through collection pipelines', function () { + $stored = batchCClosedResource(); + $candidate = batchCClosedResource(); + $values = range(1, 255); + $values[] = $stored; + + $collection = new Collection([ + ['value' => $candidate], + ['value' => $stored], + ]); + + expect($collection->process()->whereIn('value', $values, true)->all())->toBe([ + 1 => ['value' => $stored], + ]); +}); + +it('treats existing wildcard leaves as present regardless of leaf truthiness', function () { + foreach ([[], null, false, 0, '', 'value'] as $leaf) { + expect(DotNotation::matches( + ['rows' => [['value' => $leaf]]], + 'rows.*.value', + ))->toBeTrue(); + } + + expect(DotNotation::matches( + ['rows' => [['missing' => true]]], + 'rows.*.value', + ))->toBeFalse() + ->and(DotNotation::matches(['rows' => []], 'rows.*.value'))->toBeFalse(); +}); + +it('supports multiple wildcard presence and escaped literal wildcard keys', function () { + $data = [ + 'groups' => [ + [ + 'rows' => [ + ['value' => []], + ], + ], + ], + 'literal' => [ + '*' => ['value' => null], + ], + ]; + + expect(DotNotation::matches($data, 'groups.*.rows.*.value'))->toBeTrue() + ->and(DotNotation::matches($data, 'literal.\\*.value'))->toBeTrue(); +}); + +it('anchors SQL-like patterns at the true end of the value', function () { + $rows = [ + 'exact' => ['name' => 'admin'], + 'newline' => ['name' => "admin\n"], + 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], + 'case' => ['name' => 'ADMIN'], + 'empty' => ['name' => ''], + ]; + + expect(ArrayMulti::whereLike($rows, 'name', 'admin'))->toBe([ + 'exact' => ['name' => 'admin'], + 'case' => ['name' => 'ADMIN'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'admin', true))->toBe([ + 'exact' => ['name' => 'admin'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a%b', true))->toBe([ + 'internal-newline' => ['name' => "a\nb"], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a_b', true))->toBe([ + 'internal-newline' => ['name' => "a\nb"], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a.b', true))->toBe([ + 'meta' => ['name' => 'a.b'], + ])->and(ArrayMulti::whereLike($rows, 'name', '', true))->toBe([ + 'empty' => ['name' => ''], + ]); +}); + +it('surfaces PCRE execution failures instead of treating them as no match', function () { + $previous = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '1'); + + try { + expect(fn () => ArrayMulti::whereLike( + [['name' => str_repeat('a', 200)]], + 'name', + '%a%a%a%a%a%a%a%z', + true, + ))->toThrow(RuntimeException::class); + } finally { + ini_set('pcre.backtrack_limit', (string) $previous); + } +}); + +it('keeps SQL-like matching equivalent through the collection pipeline', function () { + $collection = new Collection([ + ['name' => 'admin'], + ['name' => "admin\n"], + ]); + + expect($collection->process()->whereLike('name', 'admin')->all())->toBe([ + 0 => ['name' => 'admin'], + ]); +}); + +it('paginates valid huge inputs without integer overflow', function () { + $values = ['first' => 1, 'second' => 2, 'third' => 3]; + + expect(ArraySingle::paginate([], 1, 10))->toBe([]) + ->and(ArraySingle::paginate($values, 1, 2))->toBe([ + 'first' => 1, + 'second' => 2, + ])->and(ArraySingle::paginate($values, 2, 2))->toBe([ + 'third' => 3, + ])->and(ArraySingle::paginate($values, 3, 2))->toBe([]) + ->and(ArraySingle::paginate($values, PHP_INT_MAX, 2))->toBe([]) + ->and(ArraySingle::paginate($values, 1, PHP_INT_MAX))->toBe($values); +}); + +it('keeps overflow-safe pagination through the collection pipeline', function () { + $collection = new Collection(['first' => 1, 'second' => 2]); + + expect($collection->process()->paginate(PHP_INT_MAX, 2)->all())->toBe([]); +}); From 3d875148742e5467f5845e7fab702a0c32cbdfc8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:48:17 +0600 Subject: [PATCH 061/129] test(array): fix strict membership threshold fixtures --- tests/Feature/Release530BatchCTest.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/Feature/Release530BatchCTest.php b/tests/Feature/Release530BatchCTest.php index 14e4d58..930ec40 100644 --- a/tests/Feature/Release530BatchCTest.php +++ b/tests/Feature/Release530BatchCTest.php @@ -43,15 +43,17 @@ function batchCClosedResource(): mixed $otherObject = new stdClass(); $cases = [ - [['resource' => $right], array_fill(0, 255, null) + [['resource' => $left]]], - [NAN, array_fill(0, 255, 0) + [NAN]], - [$otherObject, array_fill(0, 255, null) + [$object]], - [null, array_fill(0, 255, 'x') + [null]], - [false, array_fill(0, 255, 'x') + [0]], - [0, array_fill(0, 255, 'x') + [false]], + [['resource' => $right], ['resource' => $left], null], + [NAN, NAN, 0], + [$otherObject, $object, null], + [null, null, 'x'], + [false, 0, 'x'], + [0, false, 'x'], ]; - foreach ($cases as [$candidate, $values]) { + foreach ($cases as [$candidate, $storedValue, $filler]) { + $values = array_fill(0, 255, $filler); + $values[] = $storedValue; $expected = in_array($candidate, $values, true); $actual = ArrayMulti::whereIn([['value' => $candidate]], 'value', $values, true); From 1c3b74eae81ad0044d849ff212e8ff8f391084b6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:52:31 +0600 Subject: [PATCH 062/129] fix(dot): resolve path owner from concern namespace --- src/Array/Concerns/DotNotationPublicApiTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index d9eeb15..c8796df 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\Array\Concerns; use Infocyph\ArrayKit\Array\ArraySingle; +use Infocyph\ArrayKit\Array\DotNotationPathOps; use InvalidArgumentException; /** @internal */ @@ -424,5 +425,4 @@ public static function tap(array $array, callable $callback): array return $array; } - } From 6fa5b69051627ba03c42cd2f91e6dcaa9be8d347 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:52:34 +0600 Subject: [PATCH 063/129] refactor(dot): align wildcard existence traversal --- src/Array/DotNotationPathOps.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index e03ed47..adce193 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -59,13 +59,10 @@ public static function matchesPath(mixed $target, array $segments, int $position return false; } - foreach ($target as $item) { - if (self::matchesPath($item, $segments, $position + 1)) { - return true; - } - } - - return false; + return array_any( + $target, + static fn(mixed $item): bool => self::matchesPath($item, $segments, $position + 1), + ); } $missing = new \stdClass(); From 47c443617c13348ef5868a2f136e4284214b0e11 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:52:50 +0600 Subject: [PATCH 064/129] style(array): order shared membership helpers --- src/Array/ArrayValueSetOps.php | 52 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php index 4b40094..e8c6bfd 100644 --- a/src/Array/ArrayValueSetOps.php +++ b/src/Array/ArrayValueSetOps.php @@ -208,6 +208,32 @@ public static function unique(array $array, bool $strict): array return $result; } + /** + * @param array $array + */ + private static function allStrictHashable(array $array): bool + { + return array_all($array, fn($value) => self::isStrictHashable($value)); + } + + /** + * @param array $array + * @return array|null + */ + private static function buildStrictLookup(array $array): ?array + { + $lookup = []; + foreach ($array as $value) { + if (!self::isStrictHashable($value)) { + return null; + } + + $lookup[self::fingerprintStrict($value)] = true; + } + + return $lookup; + } + /** * @param array $array * @param array $needles @@ -252,32 +278,6 @@ private static function containsByMembership( ); } - /** - * @param array $array - */ - private static function allStrictHashable(array $array): bool - { - return array_all($array, fn($value) => self::isStrictHashable($value)); - } - - /** - * @param array $array - * @return array|null - */ - private static function buildStrictLookup(array $array): ?array - { - $lookup = []; - foreach ($array as $value) { - if (!self::isStrictHashable($value)) { - return null; - } - - $lookup[self::fingerprintStrict($value)] = true; - } - - return $lookup; - } - /** * @param array $array * @return array From ccd61d199f9496e13eaadbac37e0fe65242ae8df Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:52:53 +0600 Subject: [PATCH 065/129] style(config): order mutation helper after protected API --- src/Config/Concerns/BaseConfigTrait.php | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index f4869ec..3a7f8a7 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -538,24 +538,6 @@ public function snapshot(string $name = 'default'): bool return true; } - /** - * @return array - */ - private function arrayValueForMutation(string $key): array - { - $missing = $this->missingValueMarker(); - $array = $this->get($key, $missing); - if ($array === $missing) { - return []; - } - - if (!is_array($array)) { - throw new InvalidArgumentException("Config value [{$key}] must be an array."); - } - - return $array; - } - protected function assertWritable(): void { if ($this->readOnly) { @@ -702,6 +684,24 @@ protected function writeCacheFile(string $path, string $contents): bool return false; } + /** + * @return array + */ + private function arrayValueForMutation(string $key): array + { + $missing = $this->missingValueMarker(); + $array = $this->get($key, $missing); + if ($array === $missing) { + return []; + } + + if (!is_array($array)) { + throw new InvalidArgumentException("Config value [{$key}] must be an array."); + } + + return $array; + } + /** * @param array $activeReferences * @param array $activeObjects From ac5260ec31f71bd41bcdead1bfe87c5f66a8f6e9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:53:08 +0600 Subject: [PATCH 066/129] test(array): correct SQL wildcard and PCRE failure cases --- tests/Feature/Release530BatchCTest.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/Feature/Release530BatchCTest.php b/tests/Feature/Release530BatchCTest.php index 930ec40..e6102a7 100644 --- a/tests/Feature/Release530BatchCTest.php +++ b/tests/Feature/Release530BatchCTest.php @@ -127,8 +127,10 @@ function batchCClosedResource(): mixed 'exact' => ['name' => 'admin'], ])->and(ArrayMulti::whereLike($rows, 'name', 'a%b', true))->toBe([ 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], ])->and(ArrayMulti::whereLike($rows, 'name', 'a_b', true))->toBe([ 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], ])->and(ArrayMulti::whereLike($rows, 'name', 'a.b', true))->toBe([ 'meta' => ['name' => 'a.b'], ])->and(ArrayMulti::whereLike($rows, 'name', '', true))->toBe([ @@ -137,18 +139,21 @@ function batchCClosedResource(): mixed }); it('surfaces PCRE execution failures instead of treating them as no match', function () { - $previous = ini_get('pcre.backtrack_limit'); - ini_set('pcre.backtrack_limit', '1'); + $previousBacktrackLimit = ini_get('pcre.backtrack_limit'); + $previousJit = ini_get('pcre.jit'); + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '10'); try { expect(fn () => ArrayMulti::whereLike( - [['name' => str_repeat('a', 200)]], + [['name' => str_repeat('a', 100)]], 'name', - '%a%a%a%a%a%a%a%z', + '%a%a%a%aa%', true, ))->toThrow(RuntimeException::class); } finally { - ini_set('pcre.backtrack_limit', (string) $previous); + ini_set('pcre.backtrack_limit', (string) $previousBacktrackLimit); + ini_set('pcre.jit', (string) $previousJit); } }); From 0e2a5b0d7e5626ce3c009a5dcd7384571089ba6b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:53:37 +0600 Subject: [PATCH 067/129] docs(plan): close batch B and track C-D progress --- arraykit-review-and-release-plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index cce0644..4108321 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -230,9 +230,9 @@ Compare the direct binding against the existing factory-composition prototype fo | Batch | Status | Evidence | | --- | --- | --- | | A — R01/R02/R03/R07 | Complete | Expanded regressions pass; PHP 8.4/8.5 analysis, clean install, and all stable/lowest QA jobs are green in workflow run #89. | -| B — R04/R05/R06/R08 | In progress | Cache/memo/export regressions and owner fixes are being implemented after Batch A closure. | -| C — R09/R10/R11/R12 + I03 | Pending | Not started. | -| D — I01/I02/I04/I05 | Pending | Not started. | +| B — R04/R05/R06/R08 | Complete | Batch B regressions pass; PHP 8.4/8.5 analysis, stable/lowest QA, and clean install are green in workflow run #116. Benchmark/security tail jobs were cancelled by newer branch pushes, not failures. | +| C — R09/R10/R11/R12 + I03 | In QA | Semantic fixes and threshold/wrapper regressions are implemented; first QA feedback was resolved and the corrected HEAD is awaiting validation. | +| D — I01/I02/I04/I05 | In progress | Replay-memory specialization, bounded DTO graph APIs, dependency evidence, and lifecycle documentation are being implemented. | | E — Runwire 2.1.1 integration | Pending | Not started. | | F — integrated release acceptance | Pending | Not started. | From 7011a2cf56e42c1598b3ff8669bdd9409ed9dddf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:54:06 +0600 Subject: [PATCH 068/129] perf(collection): avoid replay memo for array lazy sources --- src/Collection/LazyCollection.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 490e2c0..6c9bcb7 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -30,6 +30,10 @@ private function __construct(private \Closure $factory) {} */ public static function from(iterable $source): self { + if (is_array($source)) { + return new self(static fn(): array => $source); + } + return new self(self::replayableFactory($source)); } From 156a6ab00515fc7f7a551d649cd84970b1c9fbfc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:54:10 +0600 Subject: [PATCH 069/129] bench(collection): compare lazy replay source costs --- benchmarks/CollectionBench.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index 366b514..5418bd6 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -37,6 +37,26 @@ public function benchCollectionMap(): void Collection::make($this->data)->map(static fn(int $value): int => $value * 2); } + public function benchLazyArrayReplayMaterialization(): void + { + LazyCollection::from($this->data)->all(); + } + + public function benchLazyFactoryArrayMaterialization(): void + { + LazyCollection::fromFactory(fn(): array => $this->data)->all(); + } + + public function benchLazyGeneratorReplayMaterialization(): void + { + $data = $this->data; + $source = (static function () use ($data): \Generator { + yield from $data; + })(); + + LazyCollection::from($source)->all(); + } + public function benchLazyChunkMaterialization(): void { LazyCollection::fromFactory(fn(): array => $this->data) From f84b003ba0f7c23be54b77970c9699aeb37d6971 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:55:26 +0600 Subject: [PATCH 070/129] feat(dto): add bounded graph traversal guard --- src/DTO/DTOGraphGuard.php | 193 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/DTO/DTOGraphGuard.php diff --git a/src/DTO/DTOGraphGuard.php b/src/DTO/DTOGraphGuard.php new file mode 100644 index 0000000..ab78f03 --- /dev/null +++ b/src/DTO/DTOGraphGuard.php @@ -0,0 +1,193 @@ + $maxDepth) { + throw new RuntimeException('DTO graph traversal exceeded max depth.'); + } + + $visitedNodes++; + if ($visitedNodes > $maxNodes) { + throw new RuntimeException('DTO graph traversal exceeded max node count.'); + } + } + + /** + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walk( + mixed $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + ): void { + self::assertNodeWithinLimits($depth, $visitedNodes, $maxDepth, $maxNodes); + + if (is_array($value)) { + self::walkArray( + $value, + $depth, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + ); + + return; + } + + if (is_object($value) && !$value instanceof UnitEnum) { + self::walkObject( + $value, + $depth, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + ); + } + } + + /** + * @param array $value + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walkArray( + array $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + ): void { + foreach ($value as $key => $entry) { + $reference = ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + self::walk( + $entry, + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + ); + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new RuntimeException('DTO graph contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + self::walk( + $entry, + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + ); + } finally { + unset($activeReferences[$referenceId]); + } + } + } + + /** + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walkObject( + object $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + ): void { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new RuntimeException('DTO graph contains a cyclic object reference.'); + } + + $activeObjects[$objectId] = true; + + try { + foreach (new ReflectionObject($value)->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic() || !$property->isInitialized($value)) { + continue; + } + + self::walk( + $property->getValue($value), + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + ); + } + } finally { + unset($activeObjects[$objectId]); + } + } +} From 9b1069b615510f5dd78d209d388be0bd57757ca7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:55:29 +0600 Subject: [PATCH 071/129] feat(dto): expose bounded nested graph APIs --- src/DTO/Concerns/DTOTrait.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php index 2f559da..312576a 100644 --- a/src/DTO/Concerns/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -4,6 +4,7 @@ namespace Infocyph\ArrayKit\DTO\Concerns; +use Infocyph\ArrayKit\DTO\DTOGraphGuard; use ReflectionNamedType; use ReflectionObject; use ReflectionProperty; @@ -79,6 +80,25 @@ public function hydrateNested(array $values, array $mapping = [], bool $coerce = return $this; } + /** + * Hydrate nested DTO data only after the complete input graph passes the + * configured cycle, depth and node limits. + * + * @param array $values + * @param array $mapping + */ + public function hydrateNestedGuarded( + array $values, + array $mapping = [], + bool $coerce = false, + int $maxDepth = 64, + int $maxNodes = 100000, + ): static { + DTOGraphGuard::assertWithinLimits($values, $maxDepth, $maxNodes); + + return $this->hydrateNested($values, $mapping, $coerce); + } + /** * @param array $values * @param array $mapping @@ -120,6 +140,19 @@ public function toArrayDeep(): array return $result; } + /** + * Export recursively after the complete public DTO/array graph passes the + * configured cycle, depth and node limits. + * + * @return array + */ + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array + { + DTOGraphGuard::assertWithinLimits($this, $maxDepth, $maxNodes); + + return $this->toArrayDeep(); + } + private function assignProperty(string $property, mixed $value, bool $coerce): void { if (!$coerce) { From e1d44f3cdb4b5678006b44189c1705c3bab354c3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:56:24 +0600 Subject: [PATCH 072/129] test(dto): cover batch D graph and lazy replay boundaries --- tests/Feature/Release530BatchDTest.php | 161 +++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/Feature/Release530BatchDTest.php diff --git a/tests/Feature/Release530BatchDTest.php b/tests/Feature/Release530BatchDTest.php new file mode 100644 index 0000000..3d79e9f --- /dev/null +++ b/tests/Feature/Release530BatchDTest.php @@ -0,0 +1,161 @@ + 1, 'second' => 2]; + $lazy = LazyCollection::from($source); + + expect($lazy->all())->toBe($source) + ->and($lazy->all())->toBe($source) + ->and($lazy->mapLazy(static fn(int $value): int => $value * 2)->all())->toBe([ + 'first' => 2, + 'second' => 4, + ]); +}); + +it('exports bounded DTO graphs while allowing shared acyclic objects', function () { + $child = new Release530BatchDAddress(); + $child->city = 'Dhaka'; + + $dto = new Release530BatchDUser(); + $dto->address = $child; + $dto->payload = [ + 'primary' => $child, + 'secondary' => $child, + ]; + + expect($dto->toArrayDeepGuarded())->toBe([ + 'address' => ['city' => 'Dhaka'], + 'payload' => [ + 'primary' => ['city' => 'Dhaka'], + 'secondary' => ['city' => 'Dhaka'], + ], + ]); +}); + +it('rejects self and mutual DTO cycles before deep export', function () { + $self = new Release530BatchDUser(); + $self->address = new Release530BatchDAddress(); + $self->payload = $self; + + expect(fn () => $self->toArrayDeepGuarded()) + ->toThrow(RuntimeException::class, 'cyclic object reference'); + + $left = new Release530BatchDUser(); + $left->address = new Release530BatchDAddress(); + $right = new Release530BatchDUser(); + $right->address = new Release530BatchDAddress(); + $left->payload = $right; + $right->payload = $left; + + expect(fn () => $left->toArrayDeepGuarded()) + ->toThrow(RuntimeException::class, 'cyclic object reference'); +}); + +it('enforces DTO graph depth and node budgets', function () { + $dto = new Release530BatchDUser(); + $dto->address = new Release530BatchDAddress(); + $dto->payload = [ + 'deep' => [ + 'deeper' => [ + 'value' => true, + ], + ], + ]; + + expect(fn () => $dto->toArrayDeepGuarded(maxDepth: 3)) + ->toThrow(RuntimeException::class, 'max depth') + ->and(fn () => $dto->toArrayDeepGuarded(maxNodes: 3)) + ->toThrow(RuntimeException::class, 'max node count'); +}); + +it('rejects cyclic hydration input before mutating the DTO', function () { + $cycle = []; + $cycle['self'] = &$cycle; + + $dto = new Release530BatchDUser(); + $dto->address = new Release530BatchDAddress(); + $dto->payload = 'before'; + + expect(fn () => $dto->hydrateNestedGuarded(['payload' => $cycle])) + ->toThrow(RuntimeException::class, 'cyclic array reference') + ->and($dto->payload)->toBe('before'); +}); + +it('hydrates typed nested DTOs through the guarded entry point', function () { + $dto = new Release530BatchDUser(); + + $dto->hydrateNestedGuarded([ + 'address' => ['city' => 'Dhaka'], + 'payload' => ['roles' => ['admin', 'editor']], + ]); + + expect($dto->address)->toBeInstanceOf(Release530BatchDAddress::class) + ->and($dto->address->city)->toBe('Dhaka') + ->and($dto->payload)->toBe(['roles' => ['admin', 'editor']]); +}); + +it('includes inherited public state in guarded deep export', function () { + $dto = new Release530BatchDInherited(); + + expect($dto->toArrayDeepGuarded())->toBe([ + 'name' => 'child', + 'base' => 'base', + ]); +}); + +it('preserves readonly hydration behavior when the trait owns the property scope', function () { + $dto = new Release530BatchDReadonly(); + $dto->hydrateNestedGuarded(['name' => 'fixed']); + + expect($dto->name)->toBe('fixed') + ->and($dto->toArrayDeepGuarded())->toBe(['name' => 'fixed']); +}); + +it('rejects invalid DTO graph limits explicitly', function () { + $dto = new Release530BatchDInherited(); + + expect(fn () => $dto->toArrayDeepGuarded(maxDepth: 0)) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => $dto->toArrayDeepGuarded(maxNodes: 0)) + ->toThrow(InvalidArgumentException::class); +}); From e652172d9a11d28c7a4d918370d9916f2c0d6303 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:57:04 +0600 Subject: [PATCH 073/129] docs(dto): document guarded graph API signatures --- docs/rule-reference.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index dc67cbf..a97d2bb 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -589,8 +589,10 @@ DTOTrait public function fromArray(array $values): static public function hydrate(array $values, array $mapping = [], bool $coerce = false): static public function hydrateNested(array $values, array $mapping = [], bool $coerce = false): static + public function hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static public function toArray(): array public function toArrayDeep(): array + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static HookTrait From 8b251d75a74c19f2bb6dd0b205ff210687355ea3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:57:30 +0600 Subject: [PATCH 074/129] docs(dto): explain bounded graph ownership --- docs/traits-and-helpers.rst | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index 5972919..f15f90a 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -19,8 +19,10 @@ Main methods: - ``fromArray(array $values): static`` (hydrate current instance) - ``hydrate(array $values, array $mapping = [], bool $coerce = false): static`` - ``hydrateNested(array $values, array $mapping = [], bool $coerce = false): static`` +- ``hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static`` - ``toArray(): array`` (export public properties) - ``toArrayDeep(): array`` (recursive export) +- ``toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array`` - ``replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static`` Basic DTO Flow @@ -58,6 +60,33 @@ Incremental Hydration $user->fromArray(['name' => 'Bob']); $user->fromArray(['age' => 32]); +Bounded DTO Graphs +~~~~~~~~~~~~~~~~~~ + +Use the guarded entry points when nested DTO or array graphs can be large, +recursive, or influenced by external input. The complete graph is validated +before hydration or deep export. ``maxDepth`` and ``maxNodes`` are shared +across the whole call; both must be positive. Cyclic array references, cyclic +public object references, or a limit breach raise ``RuntimeException``. + +Shared acyclic objects are valid and may appear in more than one branch. The +ordinary ``hydrateNested()`` and ``toArrayDeep()`` contracts are unchanged and +remain the lower-overhead choice for trusted, already-bounded graphs. + +.. code-block:: php + + hydrateNestedGuarded( + $payload, + maxDepth: 32, + maxNodes: 10_000, + ); + + $safe = $user->toArrayDeepGuarded( + maxDepth: 32, + maxNodes: 10_000, + ); + Unknown Keys ~~~~~~~~~~~~ From e7a94b019c623b9fa4cd89ee1a638b6579debb3d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:58:07 +0600 Subject: [PATCH 075/129] docs(runtime): define ownership and trust boundaries --- docs/lifecycle.rst | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/lifecycle.rst diff --git a/docs/lifecycle.rst b/docs/lifecycle.rst new file mode 100644 index 0000000..7addbb3 --- /dev/null +++ b/docs/lifecycle.rst @@ -0,0 +1,95 @@ +Runtime Ownership and Trust Boundaries +======================================= + +ArrayKit keeps runtime state local to the object that owns it. This matters in +persistent workers, queue consumers, application servers, and long-running +CLI processes where process lifetime is longer than a single request or job. + +Ownership Model +--------------- + +``Config`` + Configuration items, snapshots, hooks, and resolved-value memoization are + instance-owned. Mutations invalidate the affected memo state. Reuse an + instance across requests only when cross-request configuration state is + intentional. + +``LazyFileConfig`` + Loaded namespaces, source/cache origin tracking, and read memoization are + instance-owned. Namespace cache files are deployment-owned generated + artifacts. Build or refresh them during deployment/admin work rather than + normal request handling. + +``LazyCollection`` + Array sources are replayed directly from the captured array and do not + allocate a per-entry replay memo. One-shot iterators/generators retain the + consumed prefix for repeatable traversal and preserve a terminal source + failure at its stream boundary. That replay state lives as long as the + collection. Use ``fromFactory()`` when a fresh source can be created per + traversal or when retaining a long/unbounded consumed prefix is undesirable. + +DTO graph guards + ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()`` use call-local + depth/node accounting and active-path cycle detection. No graph state is + stored after the call. + +Environment references + ``Environment::ref()`` is process-environment backed and resolves when the + value is materialized. Generated configuration caches intentionally freeze + the resolved value for that cache generation. + +Trusted and Untrusted Inputs +---------------------------- + +- PHP configuration source files and generated PHP cache files are executable + deployment inputs. Keep their directories deployment-owned and non-writable + by untrusted request data. +- Dot-path and guarded array APIs can accept user-controlled structures when + callers set limits appropriate to the request budget. ``getSafe()``, + ``flattenGuarded()``, ``depthGuarded()``, and ``sortRecursiveGuarded()`` are + the bounded entry points. +- Ordinary deep DTO export/hydration is intended for trusted, already-bounded + graphs. Use the guarded variants at external-data boundaries. +- Callbacks, closures, hooks, and factories execute application code. Their + own CPU, I/O, and side effects are application-owned and are not sandboxed by + ArrayKit traversal limits. + +Generated Cache Lifecycle +------------------------- + +Lazy namespace cache publication uses immutable generations plus an active +generation pointer. A rebuild is prepared separately, validated, and only then +activated. Readers therefore keep using the previous valid generation if a +new build fails. The flat leaf index is internal metadata named +``.arraykit-flat.php``; ``__flat`` remains a valid caller namespace. + +On upgrade to 5.3, rebuild generated lazy-config artifacts instead of copying +old ``__flat.php`` metadata forward. Old generation directories are disposable +after they are no longer active. If OPcache is used for generated PHP cache +files, deployment tooling remains responsible for its normal invalidation or +restart policy. + +Persistent Worker Guidance +-------------------------- + +- Prefer request/job-scoped ``Config`` and ``LazyFileConfig`` objects when + runtime mutation is request-specific. +- Do not keep one-shot ``LazyCollection`` instances globally when an unbounded + stream can be consumed indefinitely; replay state is intentionally retained + for repeatability. +- Prefer ``LazyCollection::fromFactory()`` for renewable database cursors, + event streams, and worker jobs that can create a fresh iterator. +- Treat generated cache warm-up as deployment/admin work, not a request-time + recovery path. +- Avoid static/global application bindings for request cancellation or worker + context. Optional runtime integrations should be passed explicitly to the + collection that uses them. + +Mutation and Concurrency +------------------------ + +ArrayKit objects do not provide cross-thread synchronization for in-memory +mutation. Keep mutable instances within one request/job execution context. +Generated lazy-config publication uses filesystem locking for writers and +immutable generations for readers; the generation pointer is the publication +boundary. From 13fea6d3c14ad67142ec93adf432bae89101ca36 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:58:10 +0600 Subject: [PATCH 076/129] docs(runtime): add lifecycle guide to manual --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index f99499e..90a4c4a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ Contents config lazy-config config-layering + lifecycle traits-and-helpers migration rule-reference From 9b2fb670d43390c29623b73431eb85363afdc86f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 10:58:15 +0600 Subject: [PATCH 077/129] docs(collection): document replay memory ownership --- docs/collection.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/collection.rst b/docs/collection.rst index 1a27ac0..f5963fd 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -273,10 +273,11 @@ LazyCollection -------------- Use ``LazyCollection`` for generator-backed transformations over large iterables. -Collections built with ``from()`` replay values already read from a one-shot -generator without eagerly materializing the source. That replay cache grows with -the portion consumed, so use ``fromFactory()`` for long-lived or unbounded -sources when each traversal can create a fresh iterable. +Array sources passed to ``from()`` are replayed directly without allocating a +per-entry replay memo. One-shot iterators and generators replay values already +read without eagerly materializing the remaining source; their replay cache grows +with the portion consumed. Use ``fromFactory()`` for long-lived or unbounded +renewable sources when each traversal can create a fresh iterable. .. code-block:: php From b6d34330f0f4de6e1c4984c21a57fe57e4dd4953 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:02:45 +0600 Subject: [PATCH 078/129] feat(collection): add explicit Runwire lazy binding --- src/Collection/RunwireLazyBinding.php | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/Collection/RunwireLazyBinding.php diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php new file mode 100644 index 0000000..409e4ac --- /dev/null +++ b/src/Collection/RunwireLazyBinding.php @@ -0,0 +1,48 @@ + self::MAX_CHECKPOINT_INTERVAL) { + throw new InvalidArgumentException( + 'Runwire checkpoint interval must be between 1 and 1000000 items.', + ); + } + + if ($context instanceof RequestContext) { + if ($context->completed()) { + throw new LogicException('Completed Runwire request context cannot be bound to a lazy collection.'); + } + + $context = $context->cancellation; + } + + $this->cancellation = $context; + } + + public function checkpoint(): void + { + $this->cancellation->throwIfCancelled(); + $this->scope?->yieldNow(); + $this->cancellation->throwIfCancelled(); + } +} From c5e857e138ebae04d0629a52356ee561d9aa7e97 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:05:55 +0600 Subject: [PATCH 079/129] fix(collection): bind exact Runwire runtime lifecycle --- src/Collection/RunwireLazyBinding.php | 36 ++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php index 409e4ac..f8dcd23 100644 --- a/src/Collection/RunwireLazyBinding.php +++ b/src/Collection/RunwireLazyBinding.php @@ -7,6 +7,8 @@ use Infocyph\Runwire\CancellationToken; use Infocyph\Runwire\Coroutine\CoroutineScope; use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\Runtime\Enum\RuntimeCapability; +use Infocyph\Runwire\RuntimeContext; use InvalidArgumentException; use LogicException; @@ -15,10 +17,15 @@ { private const int MAX_CHECKPOINT_INTERVAL = 1_000_000; - public CancellationToken $cancellation; + private ?CancellationToken $requestCancellation; + + private ?CancellationToken $scopeCancellation; + + private bool $yieldEnabled; public function __construct( - CancellationToken|RequestContext $context, + public RuntimeContext $runtime, + ?RequestContext $request = null, public ?CoroutineScope $scope = null, public int $checkpointEvery = 256, ) { @@ -28,21 +35,32 @@ public function __construct( ); } - if ($context instanceof RequestContext) { - if ($context->completed()) { + if ($request !== null) { + if ($request->completed()) { throw new LogicException('Completed Runwire request context cannot be bound to a lazy collection.'); } - $context = $context->cancellation; + if ($request->runtime() !== $runtime) { + throw new LogicException('Runwire request context belongs to a different runtime context.'); + } } - $this->cancellation = $context; + $this->requestCancellation = $request?->cancellation; + $this->scopeCancellation = $scope?->cancellation(); + $this->yieldEnabled = $scope !== null + && $runtime->supports(RuntimeCapability::RUNWIRE_COROUTINES); } public function checkpoint(): void { - $this->cancellation->throwIfCancelled(); - $this->scope?->yieldNow(); - $this->cancellation->throwIfCancelled(); + $this->requestCancellation?->throwIfCancelled(); + $this->scopeCancellation?->throwIfCancelled(); + + if ($this->yieldEnabled) { + $this->scope?->yieldNow(); + } + + $this->requestCancellation?->throwIfCancelled(); + $this->scopeCancellation?->throwIfCancelled(); } } From 7e7ad80d8096b5fe13677e3b6f5f16d35652f9f8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:06:31 +0600 Subject: [PATCH 080/129] feat(collection): propagate Runwire checkpoints through lazy traversal --- src/Collection/LazyCollection.php | 198 ++++++++++++++++++++++-------- 1 file changed, 145 insertions(+), 53 deletions(-) diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 6c9bcb7..7f345cf 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -5,6 +5,9 @@ namespace Infocyph\ArrayKit\Collection; use Generator; +use Infocyph\Runwire\Coroutine\CoroutineScope; +use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\RuntimeContext; use IteratorAggregate; use Traversable; @@ -17,9 +20,13 @@ final readonly class LazyCollection implements IteratorAggregate { /** - * @param \Closure(): iterable $factory + * @param \Closure(?RunwireLazyBinding): iterable $factory */ - private function __construct(private \Closure $factory) {} + private function __construct( + private \Closure $factory, + private ?RunwireLazyBinding $runwire = null, + private bool $factoryChecksRunwire = false, + ) {} /** * @template TFromKey of array-key @@ -31,7 +38,7 @@ private function __construct(private \Closure $factory) {} public static function from(iterable $source): self { if (is_array($source)) { - return new self(static fn(): array => $source); + return new self(static fn(?RunwireLazyBinding $binding): array => $source); } return new self(self::replayableFactory($source)); @@ -49,7 +56,9 @@ public static function from(iterable $source): self */ public static function fromFactory(\Closure $factory): self { - return new self($factory); + return new self( + static fn(?RunwireLazyBinding $binding): iterable => $factory(), + ); } /** @@ -89,25 +98,29 @@ public function chunkLazy(int $size, bool $preserveKeys = false): self throw new \InvalidArgumentException('Chunk size must be at least 1.'); } - return new self(function () use ($size, $preserveKeys): Generator { - $chunk = []; - foreach ($this->cursor() as $key => $value) { - if ($preserveKeys) { - $chunk[$key] = $value; - } else { - $chunk[] = $value; + return new self( + function (?RunwireLazyBinding $binding) use ($size, $preserveKeys): Generator { + $chunk = []; + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($preserveKeys) { + $chunk[$key] = $value; + } else { + $chunk[] = $value; + } + + if (count($chunk) === $size) { + yield $chunk; + $chunk = []; + } } - if (count($chunk) === $size) { + if ($chunk !== []) { yield $chunk; - $chunk = []; } - } - - if ($chunk !== []) { - yield $chunk; - } - }); + }, + $this->runwire, + true, + ); } /** @@ -115,10 +128,7 @@ public function chunkLazy(int $size, bool $preserveKeys = false): self */ public function cursor(): Generator { - $factory = $this->factory; - foreach ($factory() as $key => $value) { - yield $key => $value; - } + yield from $this->cursorWithBinding($this->runwire); } /** @@ -127,13 +137,17 @@ public function cursor(): Generator */ public function filterLazy(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - if ($callback($value, $key)) { - yield $key => $value; + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($callback($value, $key)) { + yield $key => $value; + } } - } - }); + }, + $this->runwire, + true, + ); } /** @@ -152,11 +166,15 @@ public function getIterator(): Traversable */ public function mapLazy(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - yield $key => $callback($value, $key); - } - }); + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + yield $key => $callback($value, $key); + } + }, + $this->runwire, + true, + ); } /** @@ -169,20 +187,28 @@ public function take(int $limit): self } if ($limit === 0) { - return self::from([]); + return new self( + static fn(?RunwireLazyBinding $binding): array => [], + $this->runwire, + true, + ); } - return new self(function () use ($limit): Generator { - $count = 0; - foreach ($this->cursor() as $key => $value) { - yield $key => $value; - $count++; + return new self( + function (?RunwireLazyBinding $binding) use ($limit): Generator { + $count = 0; + foreach ($this->cursorWithBinding($binding) as $key => $value) { + yield $key => $value; + $count++; - if ($count >= $limit) { - return; + if ($count >= $limit) { + return; + } } - } - }); + }, + $this->runwire, + true, + ); } /** @@ -191,15 +217,37 @@ public function take(int $limit): self */ public function takeUntil(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - if ($callback($value, $key)) { - break; + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($callback($value, $key)) { + break; + } + + yield $key => $value; } + }, + $this->runwire, + true, + ); + } - yield $key => $value; - } - }); + /** + * Bind explicit Runwire runtime/request/scope instances to this collection. + * + * @return self + */ + public function withRunwire( + RuntimeContext $runtime, + ?RequestContext $request = null, + ?CoroutineScope $scope = null, + int $checkpointEvery = 256, + ): self { + return new self( + $this->factory, + new RunwireLazyBinding($runtime, $request, $scope, $checkpointEvery), + $this->factoryChecksRunwire, + ); } /** @@ -222,7 +270,7 @@ private static function fromTraversable(Traversable $source): self * @template TSourceValue * * @param iterable $source - * @return \Closure(): iterable + * @return \Closure(?RunwireLazyBinding): iterable */ private static function replayableFactory(iterable $source): \Closure { @@ -235,7 +283,7 @@ private static function replayableFactory(iterable $source): \Closure public ?\Throwable $failure = null; }; - return static function () use ( + return static function (?RunwireLazyBinding $binding) use ( $source, &$cache, &$sourceCursor, @@ -296,4 +344,48 @@ private static function replayableFactory(iterable $source): \Closure } }; } + + /** + * @return Generator + */ + private function cursorWithBinding(?RunwireLazyBinding $binding): Generator + { + if ($this->factoryChecksRunwire) { + $factory = $this->factory; + yield from $factory($binding); + + return; + } + + yield from $this->sourceCursorWithBinding($binding); + } + + /** + * @return Generator + */ + private function sourceCursorWithBinding(?RunwireLazyBinding $binding): Generator + { + $binding?->checkpoint(); + + $factory = $this->factory; + $iterable = $factory($binding); + $iterator = (static function () use ($iterable): Generator { + yield from $iterable; + })(); + + $processed = 0; + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + $processed++; + + if ( + $binding !== null + && ($processed % $binding->checkpointEvery) === 0 + ) { + $binding->checkpoint(); + } + + $iterator->next(); + } + } } From 16da7d09b73228217d32a29dc48d47c87311dfff Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:07:27 +0600 Subject: [PATCH 081/129] build(arraykit): test optional Runwire 2.1.1 integration --- composer.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e60268a..7dfddea 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,11 @@ "ext-hash": "*" }, "require-dev": { - "infocyph/phpforge": "dev-main@dev" + "infocyph/phpforge": "dev-main@dev", + "infocyph/runwire": "2.1.1" + }, + "suggest": { + "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; ArrayKit 5.3 is tested against Runwire 2.1.1." }, "minimum-stability": "stable", "prefer-stable": true, From 4311dd93b555927cc6ae626d0843d274597617ca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:08:58 +0600 Subject: [PATCH 082/129] test(collection): cover Runwire instance binding matrix --- tests/Feature/Release530BatchETest.php | 345 +++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 tests/Feature/Release530BatchETest.php diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php new file mode 100644 index 0000000..6c5dab6 --- /dev/null +++ b/tests/Feature/Release530BatchETest.php @@ -0,0 +1,345 @@ +withRunwire($runtime, $request, $scope, $checkpointEvery); +} + +it('keeps ordinary lazy collection usage independent from Runwire installation', function () { + $root = dirname(__DIR__, 2); + $source = addslashes($root . DIRECTORY_SEPARATOR . 'src'); + $code = <<<'PHP' +$source = '__SOURCE__'; +spl_autoload_register(static function (string $class) use ($source): void { + $prefix = 'Infocyph\\ArrayKit\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + + $relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix))); + $path = $source . DIRECTORY_SEPARATOR . $relative . '.php'; + if (is_file($path)) { + require $path; + } +}); + +$result = \Infocyph\ArrayKit\Collection\LazyCollection::from(['a' => 1, 'b' => 2])->all(); +exit($result === ['a' => 1, 'b' => 2] ? 0 : 1); +PHP; + $code = str_replace('__SOURCE__', $source, $code); + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + + expect(is_resource($process))->toBeTrue(); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + expect(proc_close($process))->toBe(0, $stdout . $stderr); +}); + +it('keeps installed but unbound lazy collections on the ordinary path', function () { + expect(LazyCollection::from(['a' => 1, 'b' => 2])->all())->toBe([ + 'a' => 1, + 'b' => 2, + ]); +}); + +it('allows metadata-only runtime binding without starting runtime work', function () { + $runtime = RuntimeContext::standalone(); + + expect(LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, checkpointEvery: 1) + ->all())->toBe([1, 2, 3]); +}); + +it('rejects completed or mismatched request contexts at the binding boundary', function () { + $runtime = batchERuntime(); + $otherRuntime = batchERuntime(); + $request = RequestContext::create($runtime); + + expect(fn () => LazyCollection::from([1])->withRunwire($otherRuntime, $request)) + ->toThrow(LogicException::class, 'different runtime context'); + + $request->complete(); + + expect(fn () => LazyCollection::from([1])->withRunwire($runtime, $request)) + ->toThrow(LogicException::class, 'Completed Runwire request context'); +}); + +it('validates the Runwire checkpoint interval once at binding', function () { + $runtime = batchERuntime(); + + expect(fn () => LazyCollection::from([1])->withRunwire($runtime, checkpointEvery: 0)) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => LazyCollection::from([1])->withRunwire($runtime, checkpointEvery: 1_000_001)) + ->toThrow(InvalidArgumentException::class); +}); + +it('raises pre-cancelled request state before source factory consumption', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $request->cancel(CancellationReason::HOST_CANCELLED); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1, 2, 3]; + }, + )->withRunwire($runtime, $request, checkpointEvery: 1); + + expect(fn () => $collection->all()) + ->toThrow(CancelledException::class) + ->and($factoryCalls)->toBe(0); +}); + +it('checks cancellation before advancing the source after a checkpoint boundary', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $consumed = 0; + $seen = []; + + $collection = LazyCollection::from((function () use (&$consumed) { + foreach ([1, 2, 3, 4] as $value) { + $consumed++; + yield $value; + } + })())->withRunwire($runtime, $request, checkpointEvery: 2); + + $cursor = $collection->cursor(); + + try { + foreach ($cursor as $value) { + $seen[] = $value; + if ($value === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + } + + test()->fail('Traversal unexpectedly completed after request cancellation.'); + } catch (CancelledException) { + expect($seen)->toBe([1, 2]) + ->and($consumed)->toBe(2); + } +}); + +it('checks filtered-out upstream rows and stops before the next callback', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $consumed = 0; + $callbacks = 0; + + $collection = LazyCollection::from((function () use (&$consumed) { + foreach ([1, 2, 3, 4] as $value) { + $consumed++; + yield $value; + } + })()) + ->withRunwire($runtime, $request, checkpointEvery: 2) + ->filterLazy(function (int $value) use (&$callbacks, $request): bool { + $callbacks++; + if ($value === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return false; + }); + + expect(fn () => $collection->all()) + ->toThrow(CancelledException::class) + ->and($callbacks)->toBe(2) + ->and($consumed)->toBe(2); +}); + +it('preserves and can explicitly rebind Runwire context through derived operations', function () { + $runtime = batchERuntime(); + $first = RequestContext::create($runtime); + $second = RequestContext::create($runtime); + + $derived = LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, $first, checkpointEvery: 1) + ->filterLazy(static fn(int $value): bool => $value > 1) + ->mapLazy(static fn(int $value): int => $value * 10); + + $first->cancel(CancellationReason::HOST_CANCELLED); + + expect(fn () => $derived->all())->toThrow(CancelledException::class) + ->and($derived->withRunwire($runtime, $second, checkpointEvery: 1)->all())->toBe([ + 1 => 20, + 2 => 30, + ]); +}); + +it('keeps take zero lazy even when a cancelled request is bound', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $request->cancel(CancellationReason::HOST_CANCELLED); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1]; + }, + )->withRunwire($runtime, $request, checkpointEvery: 1); + + expect($collection->take(0)->all())->toBe([]) + ->and($factoryCalls)->toBe(0); +}); + +it('uses scope cancellation without requiring a request context', function () { + $runtime = batchERuntime(true); + $coroutines = new CoroutineRuntime(); + $turns = []; + + $result = $coroutines->run(function (CoroutineScope $scope) use ($runtime, &$turns): array { + $scope->spawn(function () use (&$turns): void { + $turns[] = 'sibling'; + }); + + return LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, scope: $scope, checkpointEvery: 1) + ->mapLazy(function (int $value) use (&$turns): int { + $turns[] = 'map-' . $value; + + return $value; + }) + ->all(); + }); + + expect($result)->toBe([1, 2, 3]) + ->and($turns[0] ?? null)->toBe('sibling'); +}); + +it('falls back without cooperative yielding when the runtime lacks the capability', function () { + $runtime = batchERuntime(false); + $coroutines = new CoroutineRuntime(); + + $result = $coroutines->run( + fn(CoroutineScope $scope): array => LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, scope: $scope, checkpointEvery: 1) + ->all(), + ); + + expect($result)->toBe([1, 2, 3]); +}); + +it('treats a closed active-scope binding as terminal before source consumption', function () { + $runtime = batchERuntime(true); + $coroutines = new CoroutineRuntime(); + $closedScope = $coroutines->run(static fn(CoroutineScope $scope): CoroutineScope => $scope); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1]; + }, + )->withRunwire($runtime, scope: $closedScope, checkpointEvery: 1); + + expect(fn () => $collection->all()) + ->toThrow(LogicException::class, 'Coroutine scope is already closed') + ->and($factoryCalls)->toBe(0); +}); + +it('honors request cancellation inside an active coroutine scope without completing host context', function () { + $runtime = batchERuntime(true); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + $consumed = 0; + + expect(fn () => $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($runtime, $request, &$consumed): array { + return LazyCollection::from((function () use (&$consumed) { + foreach ([1, 2, 3] as $value) { + $consumed++; + yield $value; + } + })()) + ->withRunwire($runtime, $request, $scope, checkpointEvery: 2) + ->mapLazy(function (int $value) use ($request): int { + if ($value === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return $value; + }) + ->all(); + }, + ))->toThrow(CancelledException::class) + ->and($consumed)->toBe(2) + ->and($request->completed())->toBeFalse(); +}); + +it('preserves source and callback exception identity under Runwire binding', function () { + $runtime = batchERuntime(); + + $source = LazyCollection::from((function () { + yield 1; + throw new DomainException('source'); + })())->withRunwire($runtime, checkpointEvery: 1); + + expect(fn () => $source->all())->toThrow(DomainException::class, 'source'); + + $callback = LazyCollection::from([1]) + ->withRunwire($runtime, checkpointEvery: 1) + ->mapLazy(static function (): never { + throw new DomainException('callback'); + }); + + expect(fn () => $callback->all())->toThrow(DomainException::class, 'callback'); +}); + +it('supports explicit intermediary forwarding of the same Runwire instances', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + + $bound = batchEForwardBinding( + LazyCollection::from(['a' => 1, 'b' => 2]), + $runtime, + $request, + checkpointEvery: 1, + ); + + expect($bound->all())->toBe(['a' => 1, 'b' => 2]) + ->and($request->completed())->toBeFalse(); +}); From 1dfc1bdb2ff8e1e5d0f3114fd9c42889313a975a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:09:48 +0600 Subject: [PATCH 083/129] docs(api): document DTO guards and Runwire binding --- docs/rule-reference.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index a97d2bb..ab14860 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -637,8 +637,10 @@ methods listed in the ``DTOTrait`` section. public function fromArray(array $values): static public function hydrate(array $values, array $mapping = [], bool $coerce = false): static public function hydrateNested(array $values, array $mapping = [], bool $coerce = false): static + public function hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static public function toArray(): array public function toArrayDeep(): array + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static LazyCollection @@ -656,4 +658,5 @@ LazyCollection public function chunkLazy(int $size, bool $preserveKeys = false): self public function take(int $limit): self public function takeUntil(callable $callback): self + public function withRunwire(RuntimeContext $runtime, ?RequestContext $request = null, ?CoroutineScope $scope = null, int $checkpointEvery = 256): self public function all(): array From 0277531ee9c7bff4bd13192805da1e78ffb603b6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:10:34 +0600 Subject: [PATCH 084/129] docs(collection): document optional Runwire lifecycle binding --- docs/collection.rst | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/collection.rst b/docs/collection.rst index f5963fd..7f63315 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -305,6 +305,53 @@ renewable sources when each traversal can create a fresh iterable. yield from fetchEvents(); }); +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 +to ``withRunwire()``: + +.. code-block:: php + + withRunwire( + $runtime, + request: $request, + checkpointEvery: 256, + ) + ->filterLazy(fn (array $row): bool => $row['active']) + ->mapLazy(fn (array $row): int => $row['id']) + ->all(); + +An active ``CoroutineScope`` may also be passed. ArrayKit calls +``yieldNow()`` only when that scope is present **and** the supplied runtime +advertises Runwire coroutine capability. Request and scope cancellation are +checked before source consumption and periodically before source advancement. +Derived lazy operations forward the same binding through upstream work, so +filtered-out rows are covered too. + +``checkpointEvery`` is an item-consumption interval from 1 through 1,000,000. +A completed request or a request belonging to another ``RuntimeContext`` is +rejected at the binding boundary. Cancellation, expired deadlines, a closed +active scope, source errors, and callback errors remain terminal exceptions. + +ArrayKit never creates or drives a Runwire runtime/event loop, completes a +request, closes a scope, or stores these bindings globally. Missing coroutine +capability disables cooperative yielding; synchronous traversal and applicable +cancellation checks remain available. ``take(0)`` stays fully lazy. + +An intermediary library should forward the exact host-owned instances instead +of reconstructing runtime metadata. + Terminal calculations: .. code-block:: php From 31c6a1ec89e093ad8c9d4a3bba10b85a7fdc6407 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:11:04 +0600 Subject: [PATCH 085/129] bench(collection): measure bound Runwire checkpoint overhead --- benchmarks/RunwireLazyCollectionBench.php | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 benchmarks/RunwireLazyCollectionBench.php diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php new file mode 100644 index 0000000..ecb61cd --- /dev/null +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -0,0 +1,74 @@ + */ + private array $data = []; + + private RequestContext $request; + + private RuntimeContext $runtime; + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + $this->runtime = RuntimeContext::standalone(); + $this->request = RequestContext::create($this->runtime); + } + + public function benchBoundRequestMaterialization(): void + { + LazyCollection::from($this->data) + ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) + ->all(); + } + + public function benchBoundRequestMapFilter(): void + { + LazyCollection::from($this->data) + ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->all(); + } + + public function benchUnboundMaterialization(): void + { + LazyCollection::from($this->data)->all(); + } + + public function benchUnboundMapFilter(): void + { + LazyCollection::from($this->data) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->all(); + } + + /** @return array */ + public function provideSizes(): array + { + return [ + '1k' => ['size' => 1000], + '10k' => ['size' => 10000], + '100k' => ['size' => 100000], + ]; + } +} From 887984cdc0cec281e6bb576bb663a3c91191907d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:12:12 +0600 Subject: [PATCH 086/129] docs(plan): sync D-E implementation and QA status --- arraykit-review-and-release-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 4108321..3a66712 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -232,8 +232,8 @@ Compare the direct binding against the existing factory-composition prototype fo | A — R01/R02/R03/R07 | Complete | Expanded regressions pass; PHP 8.4/8.5 analysis, clean install, and all stable/lowest QA jobs are green in workflow run #89. | | B — R04/R05/R06/R08 | Complete | Batch B regressions pass; PHP 8.4/8.5 analysis, stable/lowest QA, and clean install are green in workflow run #116. Benchmark/security tail jobs were cancelled by newer branch pushes, not failures. | | C — R09/R10/R11/R12 + I03 | In QA | Semantic fixes and threshold/wrapper regressions are implemented; first QA feedback was resolved and the corrected HEAD is awaiting validation. | -| D — I01/I02/I04/I05 | In progress | Replay-memory specialization, bounded DTO graph APIs, dependency evidence, and lifecycle documentation are being implemented. | -| E — Runwire 2.1.1 integration | Pending | Not started. | +| D — I01/I02/I04/I05 | In QA | Array replay specialization, bounded DTO graph APIs/tests, dependency assessment, benchmarks, and lifecycle/trust documentation are implemented. | +| E — Runwire 2.1.1 integration | In QA | Optional exact 2.1.1 dev integration, passed-instance binding, propagation, lifecycle matrix, absence smoke, docs, and bound/unbound benchmarks are implemented. | | F — integrated release acceptance | Pending | Not started. | ## Implementation sequence From 0c0214b0fda23d83bbf6e48981ab913843d21562 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:13:30 +0600 Subject: [PATCH 087/129] docs(collection): add intermediary Runwire forwarding example --- docs/collection.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/collection.rst b/docs/collection.rst index 7f63315..e6fe12c 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -352,6 +352,24 @@ cancellation checks remain available. ``take(0)`` stays fully lazy. An intermediary library should forward the exact host-owned instances instead of reconstructing runtime metadata. +.. code-block:: php + + withRunwire($runtime, $request, $scope); + } + + Terminal calculations: .. code-block:: php From b489514c1e465ac8320f7eb8d015c93bce5321a3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:13:33 +0600 Subject: [PATCH 088/129] docs(readme): note optional Runwire integration --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4907f4b..47e1d6c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ configuration or object models. | **Collection** | OOP array wrapper implementing `ArrayAccess`, `IteratorAggregate`, `Countable`, `JsonSerializable`. | | **HookedCollection** | Extends `Collection` with **on-get/on-set hooks** for real-time transformation of values. | | **Pipeline** | Functional-style pipeline for chaining operations on collections. | -| **LazyCollection** | Repeatable lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`), including one-shot generators and renewable factories. | +| **LazyCollection** | Repeatable lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`), including one-shot generators, renewable factories, and optional passed-instance Runwire cancellation/yield checkpoints. | | **BaseCollectionTrait** | Shared collection behavior. | @@ -90,6 +90,8 @@ 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. + ## Installation From dc636ecefecc6e8b27e4dfb9f2008cee426b6c8a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:13:47 +0600 Subject: [PATCH 089/129] docs(plan): record PHPBench dependency outcome --- arraykit-review-and-release-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 3a66712..5174138 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -190,6 +190,7 @@ Acceptance: empty arrays, first/last/out-of-range pages, `PHP_INT_MAX` boundarie - **I02 — DTO graph boundaries:** deep export and nested hydration have no cycle/depth/node budget. Cyclic DTO graphs can exhaust resources. Add bounded export/hydration entry points for arbitrary graphs, with explicit limits, cycle handling and clear failure behavior; keep ordinary DTO APIs compatible. Test self-cycles, mutual cycles, shared acyclic objects, deep/wide arrays, inherited properties and readonly behavior. Avoid reflection caches holding instances or request state. - **I03 — Duplicate groups:** PHPProbe reports three passing clone groups (139 lines; 1.19%): contains-all/contains-any setup in `ArrayValueSetOps`, strict unique/derived-row loops, and config append/prepend setup. Inspect each group's shared responsibility and centralize repeated logic in its existing owner. Preserve distinct thresholds, short-circuit behavior and array-versus-row semantics; do not merge unrelated code merely to lower a metric. Verify every affected caller and throughput. - **I04 — Tool dependency hygiene:** assess a compatible PHPForge/PHPBench update that removes abandoned `doctrine/annotations` when an upstream replacement is available. It is development-only, has no replacement declared, and is not a published advisory. Record the outcome in the 5.3.0 evidence; an unavailable upstream replacement remains a maintenance item under the existing non-blocking policy. Do not remove benchmark coverage, edit vendor or weaken audit policy to silence it. + - Implementation outcome: PHPForge already allows PHPBench `^1.7`; PHPBench 1.7.0 remains the current stable release and still requires `doctrine/annotations ^2.0`. ArrayKit has no direct PHPBench/runtime dependency to replace, so this remains a development-only upstream maintenance item. Benchmark coverage and audit policy remain intact. - **I05 — Trust and lifecycle docs:** explicitly document trusted PHP source/cache directories, deployment-owned writes, secret-bearing artifacts, request-scoped mutable config/hooks, shallow collection copies, retained replay caches, Runwire binding lifetimes, and cleanup/replacement in persistent workers. Cache fallback cannot make an untrusted PHP file safe to include. Update examples and migration guidance alongside the corresponding implementation. ## Runwire 2.1.1 integration for 5.3.0 From 1afa04fe86f3abd0f726ad4532bfed0a92ffebd9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:17:38 +0600 Subject: [PATCH 090/129] test(dto): move batch D DTO fixtures under PSR-4 --- tests/Fixtures/Release530BatchDAddress.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/Fixtures/Release530BatchDAddress.php diff --git a/tests/Fixtures/Release530BatchDAddress.php b/tests/Fixtures/Release530BatchDAddress.php new file mode 100644 index 0000000..fc49e46 --- /dev/null +++ b/tests/Fixtures/Release530BatchDAddress.php @@ -0,0 +1,14 @@ + Date: Mon, 5 Oct 2026 11:17:41 +0600 Subject: [PATCH 091/129] test(dto): move batch D DTO fixtures under PSR-4 --- tests/Fixtures/Release530BatchDBase.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/Fixtures/Release530BatchDBase.php diff --git a/tests/Fixtures/Release530BatchDBase.php b/tests/Fixtures/Release530BatchDBase.php new file mode 100644 index 0000000..ce90d9a --- /dev/null +++ b/tests/Fixtures/Release530BatchDBase.php @@ -0,0 +1,10 @@ + Date: Mon, 5 Oct 2026 11:17:44 +0600 Subject: [PATCH 092/129] test(dto): move batch D DTO fixtures under PSR-4 --- tests/Fixtures/Release530BatchDInherited.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/Fixtures/Release530BatchDInherited.php diff --git a/tests/Fixtures/Release530BatchDInherited.php b/tests/Fixtures/Release530BatchDInherited.php new file mode 100644 index 0000000..244fe2a --- /dev/null +++ b/tests/Fixtures/Release530BatchDInherited.php @@ -0,0 +1,14 @@ + Date: Mon, 5 Oct 2026 11:17:47 +0600 Subject: [PATCH 093/129] test(dto): move batch D DTO fixtures under PSR-4 --- tests/Fixtures/Release530BatchDReadonly.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/Fixtures/Release530BatchDReadonly.php diff --git a/tests/Fixtures/Release530BatchDReadonly.php b/tests/Fixtures/Release530BatchDReadonly.php new file mode 100644 index 0000000..6b70369 --- /dev/null +++ b/tests/Fixtures/Release530BatchDReadonly.php @@ -0,0 +1,14 @@ + Date: Mon, 5 Oct 2026 11:17:50 +0600 Subject: [PATCH 094/129] test(dto): move batch D DTO fixtures under PSR-4 --- tests/Fixtures/Release530BatchDUser.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/Fixtures/Release530BatchDUser.php diff --git a/tests/Fixtures/Release530BatchDUser.php b/tests/Fixtures/Release530BatchDUser.php new file mode 100644 index 0000000..c1a1db7 --- /dev/null +++ b/tests/Fixtures/Release530BatchDUser.php @@ -0,0 +1,16 @@ + Date: Mon, 5 Oct 2026 11:29:42 +0600 Subject: [PATCH 095/129] test(dto): use PSR-4 batch D fixtures --- tests/Feature/Release530BatchDTest.php | 42 ++++---------------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/tests/Feature/Release530BatchDTest.php b/tests/Feature/Release530BatchDTest.php index 3d79e9f..ca594ed 100644 --- a/tests/Feature/Release530BatchDTest.php +++ b/tests/Feature/Release530BatchDTest.php @@ -3,42 +3,12 @@ declare(strict_types=1); use Infocyph\ArrayKit\Collection\LazyCollection; -use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; - -class Release530BatchDAddress -{ - use DTOTrait; - - public string $city = ''; -} - -class Release530BatchDBase -{ - public string $base = 'base'; -} - -class Release530BatchDInherited extends Release530BatchDBase -{ - use DTOTrait; - - public string $name = 'child'; -} - -class Release530BatchDReadonly -{ - use DTOTrait; - - public readonly string $name; -} - -class Release530BatchDUser -{ - use DTOTrait; - - public Release530BatchDAddress $address; - - public mixed $payload = null; -} +use Infocyph\ArrayKit\Tests\Fixtures\Release530BatchDAddress; +use Infocyph\ArrayKit\Tests\Fixtures\Release530BatchDInherited; +use Infocyph\ArrayKit\Tests\Fixtures\Release530BatchDReadonly; +use Infocyph\ArrayKit\Tests\Fixtures\Release530BatchDUser; +use InvalidArgumentException; +use RuntimeException; it('replays array-backed lazy collections without changing keys or values', function () { $source = ['first' => 1, 'second' => 2]; From 750f90e8cb754e68e57e5e26e2dd803f58ff24a2 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:29:53 +0600 Subject: [PATCH 096/129] style(collection): consume optional binding parameters explicitly --- src/Collection/LazyCollection.php | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 7f345cf..c4e4bef 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -38,7 +38,11 @@ private function __construct( public static function from(iterable $source): self { if (is_array($source)) { - return new self(static fn(?RunwireLazyBinding $binding): array => $source); + return new self(static function (?RunwireLazyBinding $binding) use ($source): array { + unset($binding); + + return $source; + }); } return new self(self::replayableFactory($source)); @@ -57,7 +61,11 @@ public static function from(iterable $source): self public static function fromFactory(\Closure $factory): self { return new self( - static fn(?RunwireLazyBinding $binding): iterable => $factory(), + static function (?RunwireLazyBinding $binding) use ($factory): iterable { + unset($binding); + + return $factory(); + }, ); } @@ -188,7 +196,11 @@ public function take(int $limit): self if ($limit === 0) { return new self( - static fn(?RunwireLazyBinding $binding): array => [], + static function (?RunwireLazyBinding $binding): array { + unset($binding); + + return []; + }, $this->runwire, true, ); @@ -291,6 +303,8 @@ private static function replayableFactory(iterable $source): \Closure &$exhausted, $state, ): Generator { + unset($binding); + $position = 0; while (true) { From cff83259a3963ad6a6a7f6cfee390eb2c0fd59ac Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:30:18 +0600 Subject: [PATCH 097/129] style(bench): order collection benchmark methods --- benchmarks/CollectionBench.php | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index 5418bd6..ce1289d 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -21,12 +21,6 @@ final class CollectionBench /** @var array */ private array $data = []; - /** @param array{size:int} $params */ - public function setUp(array $params): void - { - $this->data = range(1, $params['size']); - } - public function benchArraySingleMap(): void { ArraySingle::map($this->data, static fn(int $value): int => $value * 2); @@ -42,11 +36,25 @@ public function benchLazyArrayReplayMaterialization(): void LazyCollection::from($this->data)->all(); } + public function benchLazyChunkMaterialization(): void + { + LazyCollection::fromFactory(fn(): array => $this->data) + ->chunkLazy(100) + ->all(); + } + public function benchLazyFactoryArrayMaterialization(): void { LazyCollection::fromFactory(fn(): array => $this->data)->all(); } + public function benchLazyFilterMaterialization(): void + { + LazyCollection::fromFactory(fn(): array => $this->data) + ->filterLazy(static fn(int $value): bool => ($value % 2) === 0) + ->all(); + } + public function benchLazyGeneratorReplayMaterialization(): void { $data = $this->data; @@ -57,20 +65,6 @@ public function benchLazyGeneratorReplayMaterialization(): void LazyCollection::from($source)->all(); } - public function benchLazyChunkMaterialization(): void - { - LazyCollection::fromFactory(fn(): array => $this->data) - ->chunkLazy(100) - ->all(); - } - - public function benchLazyFilterMaterialization(): void - { - LazyCollection::fromFactory(fn(): array => $this->data) - ->filterLazy(static fn(int $value): bool => ($value % 2) === 0) - ->all(); - } - public function benchLazyMapFilterTake(): void { LazyCollection::fromFactory(fn(): array => $this->data) @@ -104,4 +98,10 @@ public function provideSizes(): array '1m' => ['size' => 1000000], ]; } + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + } } From f63a3acc594d4f472ac1a7a69e8af66e5aaff91f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:30:22 +0600 Subject: [PATCH 098/129] style(bench): order Runwire benchmark methods --- benchmarks/RunwireLazyCollectionBench.php | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php index ecb61cd..ecfe6be 100644 --- a/benchmarks/RunwireLazyCollectionBench.php +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -25,12 +25,13 @@ final class RunwireLazyCollectionBench private RuntimeContext $runtime; - /** @param array{size:int} $params */ - public function setUp(array $params): void + public function benchBoundRequestMapFilter(): void { - $this->data = range(1, $params['size']); - $this->runtime = RuntimeContext::standalone(); - $this->request = RequestContext::create($this->runtime); + LazyCollection::from($this->data) + ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->all(); } public function benchBoundRequestMaterialization(): void @@ -40,10 +41,9 @@ public function benchBoundRequestMaterialization(): void ->all(); } - public function benchBoundRequestMapFilter(): void + public function benchUnboundMapFilter(): void { LazyCollection::from($this->data) - ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) ->mapLazy(static fn(int $value): int => $value * 2) ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) ->all(); @@ -54,14 +54,6 @@ public function benchUnboundMaterialization(): void LazyCollection::from($this->data)->all(); } - public function benchUnboundMapFilter(): void - { - LazyCollection::from($this->data) - ->mapLazy(static fn(int $value): int => $value * 2) - ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) - ->all(); - } - /** @return array */ public function provideSizes(): array { @@ -71,4 +63,12 @@ public function provideSizes(): array '100k' => ['size' => 100000], ]; } + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + $this->runtime = RuntimeContext::standalone(); + $this->request = RequestContext::create($this->runtime); + } } From 248a6e72bef60b00b6e09624aa4d6cc63525edf5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:30:57 +0600 Subject: [PATCH 099/129] test(collection): assert active-scope cancellation at callback boundary --- tests/Feature/Release530BatchETest.php | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php index 6c5dab6..f72c1f0 100644 --- a/tests/Feature/Release530BatchETest.php +++ b/tests/Feature/Release530BatchETest.php @@ -284,19 +284,15 @@ function () use (&$factoryCalls): array { $runtime = batchERuntime(true); $request = RequestContext::create($runtime); $coroutines = new CoroutineRuntime(); - $consumed = 0; + $callbackValues = []; expect(fn () => $coroutines->runRequest( $request, - function (CoroutineScope $scope) use ($runtime, $request, &$consumed): array { - return LazyCollection::from((function () use (&$consumed) { - foreach ([1, 2, 3] as $value) { - $consumed++; - yield $value; - } - })()) + function (CoroutineScope $scope) use ($runtime, $request, &$callbackValues): array { + return LazyCollection::from([1, 2, 3]) ->withRunwire($runtime, $request, $scope, checkpointEvery: 2) - ->mapLazy(function (int $value) use ($request): int { + ->mapLazy(function (int $value) use ($request, &$callbackValues): int { + $callbackValues[] = $value; if ($value === 2) { $request->cancel(CancellationReason::HOST_CANCELLED); } @@ -306,7 +302,8 @@ function (CoroutineScope $scope) use ($runtime, $request, &$consumed): array { ->all(); }, ))->toThrow(CancelledException::class) - ->and($consumed)->toBe(2) + ->and($callbackValues)->toBe([1, 2]) + ->and($request->cancelled())->toBeTrue() ->and($request->completed())->toBeFalse(); }); From bb529cd6afac72eeec32b679c093f25d4e1005bb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:35:32 +0600 Subject: [PATCH 100/129] style(bench): place setup before benchmark subjects --- benchmarks/CollectionBench.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index ce1289d..7ad3f2e 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -21,6 +21,13 @@ final class CollectionBench /** @var array */ private array $data = []; + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + } + + public function benchArraySingleMap(): void { ArraySingle::map($this->data, static fn(int $value): int => $value * 2); @@ -99,9 +106,4 @@ public function provideSizes(): array ]; } - /** @param array{size:int} $params */ - public function setUp(array $params): void - { - $this->data = range(1, $params['size']); - } } From ef3df233831db8fcc0ca5c4ad5cbcba308a27890 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:35:35 +0600 Subject: [PATCH 101/129] style(bench): place Runwire setup before subjects --- benchmarks/RunwireLazyCollectionBench.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php index ecfe6be..c60a87b 100644 --- a/benchmarks/RunwireLazyCollectionBench.php +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -25,6 +25,15 @@ final class RunwireLazyCollectionBench private RuntimeContext $runtime; + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + $this->runtime = RuntimeContext::standalone(); + $this->request = RequestContext::create($this->runtime); + } + + public function benchBoundRequestMapFilter(): void { LazyCollection::from($this->data) @@ -64,11 +73,4 @@ public function provideSizes(): array ]; } - /** @param array{size:int} $params */ - public function setUp(array $params): void - { - $this->data = range(1, $params['size']); - $this->runtime = RuntimeContext::standalone(); - $this->request = RequestContext::create($this->runtime); - } } From 2919ba2ec94dad0cb8d9d7c8568b25cb68befa4b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:37:08 +0600 Subject: [PATCH 102/129] test(collection): isolate ArrayKit Runwire scope ownership --- tests/Feature/Release530BatchETest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php index f72c1f0..b9c3b24 100644 --- a/tests/Feature/Release530BatchETest.php +++ b/tests/Feature/Release530BatchETest.php @@ -280,14 +280,13 @@ function () use (&$factoryCalls): array { ->and($factoryCalls)->toBe(0); }); -it('honors request cancellation inside an active coroutine scope without completing host context', function () { +it('honors request cancellation while an active coroutine scope is bound without completing host context', function () { $runtime = batchERuntime(true); $request = RequestContext::create($runtime); $coroutines = new CoroutineRuntime(); $callbackValues = []; - expect(fn () => $coroutines->runRequest( - $request, + expect(fn () => $coroutines->run( function (CoroutineScope $scope) use ($runtime, $request, &$callbackValues): array { return LazyCollection::from([1, 2, 3]) ->withRunwire($runtime, $request, $scope, checkpointEvery: 2) From 7be527fab2325ed63da8ff6e4e14aa055972eaee Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:39:00 +0600 Subject: [PATCH 103/129] docs(release): add 5.3 upgrade guidance --- docs/migration.rst | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/migration.rst b/docs/migration.rst index 0c42aa6..205f921 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -3,6 +3,39 @@ Migration and Compatibility This page highlights behavior and API additions that may affect usage patterns. +5.3 Upgrade +----------- + +ArrayKit 5.3 is a correctness and runtime-hardening release. Existing ordinary +array, collection, config, DTO, and lazy-collection entry points remain +available; the bounded DTO and Runwire APIs are additive. + +Before deploying 5.3: + +1. Rebuild ``LazyFileConfig`` generated namespace caches. Do not carry the old + 5.2 ``__flat.php`` acceleration artifact forward as internal metadata. +2. Replace/restart workers according to the application's normal deployment and + OPcache policy after publishing new generated PHP cache files. +3. If nested DTO graphs cross an external-data boundary, prefer + ``hydrateNestedGuarded()`` / ``toArrayDeepGuarded()`` with limits appropriate + to that boundary. +4. Runwire integration is optional. Applications that use it should forward the + exact host-owned ``RuntimeContext`` and optional ``RequestContext`` / + ``CoroutineScope`` to ``LazyCollection::withRunwire()``. ArrayKit does not + create or drive a runtime, event loop, request lifecycle, or coroutine scope. + +Notable 5.3 behavior corrections include strict membership equivalence at +lookup thresholds, wildcard path-presence detection independent of leaf +truthiness, true whole-string SQL-like matching, overflow-safe pagination, +coherent config read-memo invalidation, source-authoritative namespace cache +warming, failure-safe generated-cache publication, and repeatable terminal +errors for one-shot lazy streams. + +Generated-cache metadata now lives in ``.arraykit-flat.php`` inside an immutable +generation selected by ``.arraykit-generation``. The caller namespace +``__flat`` is therefore no longer ambiguous with ArrayKit's internal flat +index. + Recent Additions ---------------- From 8ff1804b0c83b1ea3b58a2d919add6a4c2e209f4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:39:04 +0600 Subject: [PATCH 104/129] docs(release): consolidate 5.3 candidate notes --- docs/release-5.3.0.rst | 110 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/release-5.3.0.rst diff --git a/docs/release-5.3.0.rst b/docs/release-5.3.0.rst new file mode 100644 index 0000000..259024f --- /dev/null +++ b/docs/release-5.3.0.rst @@ -0,0 +1,110 @@ +ArrayKit 5.3.0 +============== + +Status: release candidate guidance. Tagging and publishing remain separate +actions after the release-acceptance gates are satisfied. + +Highlights +---------- + +- Correctness hardening for bounded recursive traversal, strict membership, + wildcard path presence, SQL-like matching, pagination, config memoization, + generated cache publication, and one-shot lazy-stream failures. +- Immutable ``LazyFileConfig`` cache generations with atomic activation and an + internal ``.arraykit-flat.php`` index that no longer reserves ``__flat``. +- Additive bounded DTO graph entry points: + ``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 + ``LazyCollection::withRunwire()``. The host passes its existing + ``RuntimeContext`` and optional request/scope objects; ArrayKit does not own + Runwire lifecycle management. +- Expanded regression, boundary, benchmark, lifecycle, and optional-integration + coverage under the PHPForge quality gates. + +Correctness Changes +------------------- + +Strict membership +~~~~~~~~~~~~~~~~~ + +Threshold-optimized membership now preserves native PHP strict-comparison +semantics rather than relying on fingerprints for values that cannot safely be +represented by the fast lookup. + +Wildcard presence +~~~~~~~~~~~~~~~~~ + +``DotNotation::matches()`` now answers path existence independently from the +resolved leaf value. Existing ``null``, ``false``, ``0``, empty strings, and +empty arrays are present values. + +SQL-like matching +~~~~~~~~~~~~~~~~~ + +``ArrayMulti::whereLike()`` is anchored to the true beginning/end of the +subject, supports multiline wildcard consumption, and surfaces PCRE execution +failures instead of silently treating them as no match. + +Pagination +~~~~~~~~~~ + +``ArraySingle::paginate()`` validates reachability before calculating an +offset, avoiding integer overflow for valid but extremely large page numbers. + +Configuration and Cache Lifecycle +--------------------------------- + +Resolved read memoization is invalidated when configuration/cache sources +change. Generated namespace warm-up reads the authoritative source unless the +caller explicitly supplied or mutated that namespace in memory. + +Namespace caches are built as immutable generations and activated only after a +successful build. A failed build leaves the previous valid generation active. +Unsupported/cyclic values are rejected before replacing a valid compiled +artifact. + +Upgrade deployments should rebuild 5.2 namespace caches. The former internal +``__flat.php`` metadata must not be reused as the 5.3 flat index; +``.arraykit-flat.php`` is used inside the active generation instead. Retire old +generation directories only after workers that may still reference them have +been replaced. Apply the application's normal OPcache invalidation/restart +policy to generated PHP artifacts. + +DTO Graph Guards +---------------- + +Use ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()`` for graphs that can +be large, recursive, or influenced by external input. They enforce one shared +depth/node budget per call and reject active-path object/array cycles while +allowing shared acyclic objects. Existing ``hydrateNested()`` and +``toArrayDeep()`` remain available for trusted, already-bounded graphs. + +Optional Runwire Integration +---------------------------- + +Runwire is not a production dependency. ArrayKit 5.3 tests the optional +integration against ``infocyph/runwire`` 2.1.1. + +``LazyCollection::withRunwire()`` accepts the host's exact ``RuntimeContext`` +and optional ``RequestContext`` / ``CoroutineScope``. Cancellation is checked +at the traversal boundary and at the configured item cadence. Cooperative +``yieldNow()`` calls are made only when an active scope is passed and the +runtime advertises Runwire coroutine capability. + +Bindings propagate through derived lazy operations and can be explicitly +rebound for a new request. ArrayKit never starts/stops a runtime or event loop, +completes a request, closes a scope, or stores request bindings globally. + +Compatibility +------------- + +The release remains PHP 8.4+ and keeps Runwire optional. Existing synchronous +LazyCollection use continues to work without Runwire installed. Public API +additions are additive; corrected edge cases listed above may change results +where 5.2 behavior was demonstrably inconsistent with the documented/native +contract. + +See :doc:`migration`, :doc:`lazy-config`, :doc:`collection`, +:doc:`traits-and-helpers`, and :doc:`lifecycle` for operational details. From 33a0768b222ab54163e091872e291096b1bce480 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:39:07 +0600 Subject: [PATCH 105/129] docs(release): link 5.3 candidate notes --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 90a4c4a..892dde3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,7 @@ Contents lifecycle traits-and-helpers migration + release-5.3.0 rule-reference The feature pages above are guide-style usage docs. From a5e7236d79b7b66649a5cc874c6566abbc2b3fca Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:40:17 +0600 Subject: [PATCH 106/129] docs(plan): sync tracker through final acceptance --- arraykit-review-and-release-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 5174138..1d80109 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -2,7 +2,7 @@ Date: 2026-10-04 (Asia/Dhaka) -Status: implementation in progress; Batch A (R01/R02/R03/R07) is complete and Batch B (R04/R05/R06/R08) is in progress. +Status: implementation complete through Batch E; Batches C-D-E are in final QA and Batch F release acceptance is in progress. Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. @@ -235,7 +235,7 @@ Compare the direct binding against the existing factory-composition prototype fo | C — R09/R10/R11/R12 + I03 | In QA | Semantic fixes and threshold/wrapper regressions are implemented; first QA feedback was resolved and the corrected HEAD is awaiting validation. | | D — I01/I02/I04/I05 | In QA | Array replay specialization, bounded DTO graph APIs/tests, dependency assessment, benchmarks, and lifecycle/trust documentation are implemented. | | E — Runwire 2.1.1 integration | In QA | Optional exact 2.1.1 dev integration, passed-instance binding, propagation, lifecycle matrix, absence smoke, docs, and bound/unbound benchmarks are implemented. | -| F — integrated release acceptance | Pending | Not started. | +| F — integrated release acceptance | In progress | Final PHPForge/compatibility validation is running; 5.3 migration and candidate release notes are consolidated. Production-equivalent baseline comparison, external consumer/host-driver evidence, and soak remain acceptance evidence rather than implementation claims. | ## Implementation sequence From 9dc2ab5e8898b045d68a7442371e8927978dda19 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:42:04 +0600 Subject: [PATCH 107/129] test(collection): observe cancellation state after traversal --- tests/Feature/Release530BatchETest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php index b9c3b24..a9362e7 100644 --- a/tests/Feature/Release530BatchETest.php +++ b/tests/Feature/Release530BatchETest.php @@ -300,8 +300,9 @@ function (CoroutineScope $scope) use ($runtime, $request, &$callbackValues): arr }) ->all(); }, - ))->toThrow(CancelledException::class) - ->and($callbackValues)->toBe([1, 2]) + ))->toThrow(CancelledException::class); + + expect($callbackValues)->toBe([1, 2]) ->and($request->cancelled())->toBeTrue() ->and($request->completed())->toBeFalse(); }); From e2982abbc99452c6f0638faed9079040ee72fa19 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:42:07 +0600 Subject: [PATCH 108/129] style(bench): normalize benchmark element spacing --- benchmarks/CollectionBench.php | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index 7ad3f2e..83331c4 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -27,7 +27,6 @@ public function setUp(array $params): void $this->data = range(1, $params['size']); } - public function benchArraySingleMap(): void { ArraySingle::map($this->data, static fn(int $value): int => $value * 2); From bd962bb10a769facf9be3f399a2b3d29eb384939 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:42:11 +0600 Subject: [PATCH 109/129] style(bench): normalize Runwire benchmark spacing --- benchmarks/RunwireLazyCollectionBench.php | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php index c60a87b..6054292 100644 --- a/benchmarks/RunwireLazyCollectionBench.php +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -33,7 +33,6 @@ public function setUp(array $params): void $this->request = RequestContext::create($this->runtime); } - public function benchBoundRequestMapFilter(): void { LazyCollection::from($this->data) From ca5f39a09efb07e59f717e3a6962ed116179744d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:45:02 +0600 Subject: [PATCH 110/129] test(collection): exercise concurrent Runwire cancellation at checkpoint --- tests/Feature/Release530BatchETest.php | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php index a9362e7..bbab168 100644 --- a/tests/Feature/Release530BatchETest.php +++ b/tests/Feature/Release530BatchETest.php @@ -284,29 +284,32 @@ function () use (&$factoryCalls): array { $runtime = batchERuntime(true); $request = RequestContext::create($runtime); $coroutines = new CoroutineRuntime(); - $callbackValues = []; + $state = new class { + public int $factoryCalls = 0; + }; expect(fn () => $coroutines->run( - function (CoroutineScope $scope) use ($runtime, $request, &$callbackValues): array { - return LazyCollection::from([1, 2, 3]) - ->withRunwire($runtime, $request, $scope, checkpointEvery: 2) - ->mapLazy(function (int $value) use ($request, &$callbackValues): int { - $callbackValues[] = $value; - if ($value === 2) { - $request->cancel(CancellationReason::HOST_CANCELLED); - } - - return $value; - }) + function (CoroutineScope $scope) use ($runtime, $request, $state): array { + $scope->spawn(function () use ($request): void { + $request->cancel(CancellationReason::HOST_CANCELLED); + }); + + return LazyCollection::fromFactory( + function () use ($state): array { + $state->factoryCalls++; + + return [1, 2, 3]; + }, + ) + ->withRunwire($runtime, $request, $scope, checkpointEvery: 1) ->all(); }, ))->toThrow(CancelledException::class); - expect($callbackValues)->toBe([1, 2]) + expect($state->factoryCalls)->toBe(0) ->and($request->cancelled())->toBeTrue() ->and($request->completed())->toBeFalse(); }); - it('preserves source and callback exception identity under Runwire binding', function () { $runtime = batchERuntime(); From 2ff88293454249da8adeaa9a1d1ed203af5f741d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:45:06 +0600 Subject: [PATCH 111/129] style(bench): normalize class closing separation --- benchmarks/CollectionBench.php | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index 83331c4..0e9908d 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -104,5 +104,4 @@ public function provideSizes(): array '1m' => ['size' => 1000000], ]; } - } From 5a05c1154ed848daa2bf2f9de21b07a30c4df2f1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:45:10 +0600 Subject: [PATCH 112/129] style(bench): normalize Runwire class closing separation --- benchmarks/RunwireLazyCollectionBench.php | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php index 6054292..22f5e31 100644 --- a/benchmarks/RunwireLazyCollectionBench.php +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -71,5 +71,4 @@ public function provideSizes(): array '100k' => ['size' => 100000], ]; } - } From 0ad182f2d0f31471cabe4b4f9e47e65d122151bd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 11:49:03 +0600 Subject: [PATCH 113/129] docs(plan): close batches C through E --- arraykit-review-and-release-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 1d80109..0ad7e00 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -2,7 +2,7 @@ Date: 2026-10-04 (Asia/Dhaka) -Status: implementation complete through Batch E; Batches C-D-E are in final QA and Batch F release acceptance is in progress. +Status: implementation complete through Batch E; Batches A-E are complete and Batch F release acceptance is in progress. Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. @@ -232,10 +232,10 @@ Compare the direct binding against the existing factory-composition prototype fo | --- | --- | --- | | A — R01/R02/R03/R07 | Complete | Expanded regressions pass; PHP 8.4/8.5 analysis, clean install, and all stable/lowest QA jobs are green in workflow run #89. | | B — R04/R05/R06/R08 | Complete | Batch B regressions pass; PHP 8.4/8.5 analysis, stable/lowest QA, and clean install are green in workflow run #116. Benchmark/security tail jobs were cancelled by newer branch pushes, not failures. | -| C — R09/R10/R11/R12 + I03 | In QA | Semantic fixes and threshold/wrapper regressions are implemented; first QA feedback was resolved and the corrected HEAD is awaiting validation. | -| D — I01/I02/I04/I05 | In QA | Array replay specialization, bounded DTO graph APIs/tests, dependency assessment, benchmarks, and lifecycle/trust documentation are implemented. | -| E — Runwire 2.1.1 integration | In QA | Optional exact 2.1.1 dev integration, passed-instance binding, propagation, lifecycle matrix, absence smoke, docs, and bound/unbound benchmarks are implemented. | -| F — integrated release acceptance | In progress | Final PHPForge/compatibility validation is running; 5.3 migration and candidate release notes are consolidated. Production-equivalent baseline comparison, external consumer/host-driver evidence, and soak remain acceptance evidence rather than implementation claims. | +| C — R09/R10/R11/R12 + I03 | Complete | Threshold/native-semantics, wildcard presence, SQL-like matching, overflow pagination, and wrapper regressions pass across PHP 8.4/8.5 stable/lowest QA in workflow run #165. | +| D — I01/I02/I04/I05 | Complete | Array replay specialization and benchmarks, bounded DTO graph APIs/regressions, lifecycle/trust docs, and the dev-only PHPBench/Doctrine upstream maintenance outcome pass the PHPForge QA matrix in workflow run #165. | +| E — Runwire 2.1.1 integration | Complete | Optional exact Runwire 2.1.1 binding, absence/unbound fallback, lifecycle/cancellation/capability matrix, derived propagation/rebinding, intermediary forwarding, docs, and bound/unbound benchmark subjects pass the PHPForge QA matrix in workflow run #165. | +| F — integrated release acceptance | In progress | Repository-side QA/analysis/clean-install gates are green on run #165 and 5.3 migration/release notes are consolidated. Remaining acceptance evidence: stable production-equivalent baseline comparison, representative external consumer/host-driver validation, and persistent-worker soak/worker-replacement results. | ## Implementation sequence From 4f91c89bd7a1479e03ac4bea8c9b9510a604ddaf Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:44:04 +0600 Subject: [PATCH 114/129] test(release): add host and persistent-worker acceptance --- tests/Feature/Release530BatchFTest.php | 133 +++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/Feature/Release530BatchFTest.php diff --git a/tests/Feature/Release530BatchFTest.php b/tests/Feature/Release530BatchFTest.php new file mode 100644 index 0000000..d3b9333 --- /dev/null +++ b/tests/Feature/Release530BatchFTest.php @@ -0,0 +1,133 @@ +resolve( + $driver, + $environment, + new RuntimeOptions(driver: $driver), + ); + + return RuntimeContext::fromCapabilities( + $capabilities, + mode: 'acceptance', + workerSlot: 0, + generation: 1, + ); +} + +it('keeps lazy traversal semantics across supported Runwire host capability sets', function (RuntimeDriver $driver) { + $runtime = batchFRuntime($driver); + $request = RequestContext::create($runtime); + + $result = LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, $request, checkpointEvery: 1) + ->mapLazy(static fn(int $value): int => $value * 2) + ->all(); + + expect($result)->toBe([2, 4, 6]) + ->and($request->completed())->toBeFalse(); + + $request->cancel(CancellationReason::HOST_CANCELLED); + + expect(fn () => LazyCollection::from([1]) + ->withRunwire($runtime, $request, checkpointEvery: 1) + ->all())->toThrow(CancelledException::class) + ->and($request->completed())->toBeFalse(); +})->with([ + RuntimeDriver::FPM, + RuntimeDriver::FRANKENPHP, + RuntimeDriver::ROADRUNNER, + RuntimeDriver::SWOOLE, +]); + +it('does not retain request-scoped config or cancellation state across repeated worker cycles', function () { + $runtime = batchFRuntime(RuntimeDriver::ROADRUNNER); + + for ($cycle = 0; $cycle < 250; $cycle++) { + $tenant = 'tenant-' . $cycle; + $config = new Config(); + $config->set('request.tenant', $tenant); + $config->snapshot(); + + expect($config->get('request.tenant'))->toBe($tenant); + + $config->set('request.tenant', 'mutated-' . $cycle); + expect($config->restore())->toBeTrue() + ->and($config->get('request.tenant'))->toBe($tenant); + + $request = RequestContext::create($runtime); + $collection = LazyCollection::fromFactory( + static fn(): array => [ + ['tenant' => $tenant, 'value' => 1], + ['tenant' => $tenant, 'value' => 2], + ['tenant' => $tenant, 'value' => 3], + ], + )->withRunwire($runtime, $request, checkpointEvery: 1); + + if (($cycle % 11) === 0) { + $seen = []; + + try { + $collection + ->mapLazy(function (array $row) use (&$seen, $request): array { + $seen[] = $row['tenant']; + if (count($seen) === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return $row; + }) + ->all(); + + test()->fail('Cancelled worker cycle unexpectedly completed.'); + } catch (CancelledException) { + expect($seen)->toBe([$tenant, $tenant]); + } + } else { + $rows = $collection->all(); + + expect($rows)->toHaveCount(3) + ->and(array_unique(array_column($rows, 'tenant')))->toBe([$tenant]); + } + + expect($request->completed())->toBeFalse(); + + unset($collection, $config, $request); + if (($cycle % 25) === 0) { + gc_collect_cycles(); + } + } + + $fresh = new Config(); + expect($fresh->get('request.tenant', 'missing'))->toBe('missing'); + + $freshRequest = RequestContext::create($runtime); + expect(LazyCollection::from(['fresh']) + ->withRunwire($runtime, $freshRequest, checkpointEvery: 1) + ->all())->toBe(['fresh']) + ->and($freshRequest->cancelled())->toBeFalse(); +}); From 7564f997fbe329f696139cbe0fc1373559091e9d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:45:07 +0600 Subject: [PATCH 115/129] test(release): add persistent-worker soak workload --- .../Release530PersistentWorkerSoak.php | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/Support/Release530PersistentWorkerSoak.php diff --git a/tests/Support/Release530PersistentWorkerSoak.php b/tests/Support/Release530PersistentWorkerSoak.php new file mode 100644 index 0000000..f2acf9d --- /dev/null +++ b/tests/Support/Release530PersistentWorkerSoak.php @@ -0,0 +1,243 @@ +sourceDirectory = sys_get_temp_dir() . '/arraykit-530-soak-source-' . $suffix; + $this->cacheDirectory = sys_get_temp_dir() . '/arraykit-530-soak-cache-' . $suffix; + + if (!mkdir($this->sourceDirectory, 0777, true) || !mkdir($this->cacheDirectory, 0777, true)) { + throw new RuntimeException('Unable to create ArrayKit worker-soak directories.'); + } + + $this->writeSource(1); + $this->runtime = $this->runtime(); + } + + public function run(): void + { + $this->installSignalHandlers(); + + try { + $cycle = 0; + + while ($this->running) { + $cycle++; + $this->runCycle($cycle); + + if (($cycle % 100) === 0) { + gc_collect_cycles(); + usleep(1_000); + } + } + } finally { + $this->removeDirectory($this->sourceDirectory); + $this->removeDirectory($this->cacheDirectory); + } + } + + private function installSignalHandlers(): void + { + if (!function_exists('pcntl_async_signals') || !function_exists('pcntl_signal')) { + return; + } + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, function (): void { + $this->running = false; + }); + pcntl_signal(SIGINT, function (): void { + $this->running = false; + }); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + } elseif (is_file($path) || is_link($path)) { + unlink($path); + } + } + + rmdir($directory); + } + + private function runCycle(int $cycle): void + { + $tenant = 'tenant-' . $cycle; + + $config = new LayeredLazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + namespaces: ['app'], + ); + $config->set('app.tenant', $tenant); + $config->snapshot(); + $config->set('app.tenant', 'mutated-' . $cycle); + + if (!$config->restore() || $config->get('app.tenant') !== $tenant) { + throw new RuntimeException('Layered config state leaked during worker soak.'); + } + + if (($cycle % 50) === 0) { + $this->refreshGeneratedCache($cycle); + } + + $request = RequestContext::create($this->runtime); + $collection = LazyCollection::fromFactory( + static fn(): array => [ + ['tenant' => $tenant, 'value' => 1], + ['tenant' => $tenant, 'value' => 2], + ['tenant' => $tenant, 'value' => 3], + ], + )->withRunwire($this->runtime, $request, checkpointEvery: 1); + + if (($cycle % 13) === 0) { + try { + $collection + ->mapLazy(function (array $row) use ($request): array { + if ($row['value'] === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return $row; + }) + ->all(); + + throw new RuntimeException('Cancelled worker cycle unexpectedly completed.'); + } catch (CancelledException) { + } + } else { + $rows = $collection->all(); + if (count($rows) !== 3 || array_unique(array_column($rows, 'tenant')) !== [$tenant]) { + throw new RuntimeException('Lazy collection state leaked across worker cycles.'); + } + } + + if ($request->completed()) { + throw new RuntimeException('ArrayKit completed a host-owned Runwire request.'); + } + + unset($collection, $config, $request); + } + + private function refreshGeneratedCache(int $cycle): void + { + $version = intdiv($cycle, 50) + 1; + $this->writeSource($version); + + $config = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + $config->warmNamespaceCache('app'); + + $fresh = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + + if ($fresh->get('app.version') !== $version) { + throw new RuntimeException('Generated cache refresh returned stale data during worker soak.'); + } + + if (($cycle % 100) !== 0) { + return; + } + + $config->set('app.unsupported', new class {}); + try { + $config->warmNamespaceCache('app'); + throw new RuntimeException('Unsupported generated cache payload was unexpectedly published.'); + } catch (Throwable $error) { + if ($error instanceof RuntimeException && $error->getMessage() === 'Unsupported generated cache payload was unexpectedly published.') { + throw $error; + } + } + + $afterFailure = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + + if ($afterFailure->get('app.version') !== $version) { + throw new RuntimeException('Failed cache rebuild replaced the last valid generation.'); + } + } + + private function runtime(): RuntimeContext + { + $environment = new RuntimeEnvironment( + sapi: 'cli', + hostedDrivers: [RuntimeDriver::ROADRUNNER], + availableDrivers: [RuntimeDriver::ROADRUNNER], + opcacheAvailable: true, + opcacheEnabled: true, + opcacheCliEnabled: true, + ); + + $capabilities = (new RuntimeCapabilityResolver())->resolve( + RuntimeDriver::ROADRUNNER, + $environment, + new RuntimeOptions(driver: RuntimeDriver::ROADRUNNER), + ); + + return RuntimeContext::fromCapabilities( + $capabilities, + mode: 'worker-soak', + workerSlot: 0, + generation: 1, + ); + } + + private function writeSource(int $version): void + { + $source = sprintf( + " 'source', 'version' => %d];\n", + $version, + ); + + if (file_put_contents($this->sourceDirectory . '/app.php', $source) === false) { + throw new RuntimeException('Unable to update worker-soak source config.'); + } + } +} From 886b497484d6003089ca3f595b1250f03e38fb02 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:45:10 +0600 Subject: [PATCH 116/129] test(release): add worker soak entrypoint --- tests/Support/release530-persistent-worker.php | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/Support/release530-persistent-worker.php diff --git a/tests/Support/release530-persistent-worker.php b/tests/Support/release530-persistent-worker.php new file mode 100644 index 0000000..d406519 --- /dev/null +++ b/tests/Support/release530-persistent-worker.php @@ -0,0 +1,9 @@ +run(); From 82b9f6f9ea73a083001ffdcabf7347eca6ce89f0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:47:01 +0600 Subject: [PATCH 117/129] bench(release): add representative workload worker --- benchmarks/release-workload.php | 273 ++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 benchmarks/release-workload.php diff --git a/benchmarks/release-workload.php b/benchmarks/release-workload.php new file mode 100644 index 0000000..3f64061 --- /dev/null +++ b/benchmarks/release-workload.php @@ -0,0 +1,273 @@ + \n"); + exit(2); +} + +require rtrim($root, DIRECTORY_SEPARATOR) . '/vendor/autoload.php'; + +$operations = max(1, (int) $operations); +$warmup = max(0, (int) $warmup); +$temporaryDirectories = []; + +$operation = match ($workload) { + 'array-query' => releaseArrayQueryWorkload(), + 'dot-config' => releaseDotConfigWorkload(), + 'lazy-array' => releaseLazyArrayWorkload(), + 'lazy-file-cold' => releaseLazyFileColdWorkload($temporaryDirectories), + 'lazy-file-generated' => releaseLazyFileGeneratedWorkload($temporaryDirectories), + default => throw new RuntimeException('Unknown release benchmark workload: ' . $workload), +}; + +for ($index = 0; $index < $warmup; $index++) { + $operation(); +} + +$usageBefore = getrusage(); +$memoryBefore = memory_get_usage(true); +$startedAt = hrtime(true); +$latencies = []; +$successes = 0; +$failures = 0; + +for ($index = 0; $index < $operations; $index++) { + $operationStartedAt = hrtime(true); + + try { + $operation(); + $successes++; + } catch (\Throwable) { + $failures++; + } + + $latencies[] = (hrtime(true) - $operationStartedAt) / 1_000_000; +} + +$elapsedSeconds = max((hrtime(true) - $startedAt) / 1_000_000_000, 0.000001); +$usageAfter = getrusage(); +$cpuSeconds = releaseCpuSeconds($usageAfter) - releaseCpuSeconds($usageBefore); +$memoryAfter = memory_get_usage(true); + +foreach ($temporaryDirectories as $directory) { + releaseRemoveDirectory($directory); +} + +echo json_encode([ + 'attempted' => $operations, + 'successful' => $successes, + 'failed' => $failures, + 'elapsed_seconds' => $elapsedSeconds, + 'latencies_ms' => $latencies, + 'cpu_seconds' => max(0.0, $cpuSeconds), + 'cpu_percent' => max(0.0, ($cpuSeconds / $elapsedSeconds) * 100), + 'memory_initial_mb' => $memoryBefore / 1_048_576, + 'memory_final_mb' => $memoryAfter / 1_048_576, + 'memory_peak_mb' => memory_get_peak_usage(true) / 1_048_576, +], JSON_THROW_ON_ERROR), PHP_EOL; + +/** + * @return \Closure(): void + */ +function releaseArrayQueryWorkload(): \Closure +{ + $rows = []; + for ($index = 1; $index <= 320; $index++) { + $rows[] = [ + 'id' => $index, + 'name' => 'user-' . $index, + 'group' => $index % 7, + ]; + } + + $needles = range(1, 256); + + return static function () use ($rows, $needles): void { + $matched = ArrayMulti::whereIn($rows, 'id', $needles, true); + $like = ArrayMulti::whereLike($rows, 'name', 'user-%', true); + $page = ArraySingle::paginate($rows, 4, 50); + + if (count($matched) !== 256 || count($like) !== 320 || count($page) !== 50) { + throw new RuntimeException('Array/query release workload returned invalid data.'); + } + }; +} + +function releaseCpuSeconds(array $usage): float +{ + $user = ((int) ($usage['ru_utime.tv_sec'] ?? 0)) + + (((int) ($usage['ru_utime.tv_usec'] ?? 0)) / 1_000_000); + $system = ((int) ($usage['ru_stime.tv_sec'] ?? 0)) + + (((int) ($usage['ru_stime.tv_usec'] ?? 0)) / 1_000_000); + + return $user + $system; +} + +/** + * @return \Closure(): void + */ +function releaseDotConfigWorkload(): \Closure +{ + $rows = []; + for ($index = 1; $index <= 64; $index++) { + $rows[] = ['id' => $index, 'profile' => ['active' => true]]; + } + + $data = ['rows' => $rows]; + $config = new Config(); + $config->loadArray([ + 'app' => [ + 'name' => 'arraykit', + 'nested' => ['value' => 42], + ], + ]); + + return static function () use ($config, $data): void { + $ids = DotNotation::getSafe( + $data, + 'rows.*.id', + maxDepth: 8, + maxNodes: 1_000, + throwOnTooDeep: true, + ); + $flat = ArrayMulti::flattenGuarded( + [['a' => 1], ['b' => 2], ['c' => 3]], + maxDepth: 8, + maxNodes: 32, + throwOnTooDeep: true, + ); + + if ( + count($ids) !== 64 + || $flat !== [1, 2, 3] + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Dot/config release workload returned invalid data.'); + } + }; +} + +/** + * @return \Closure(): void + */ +function releaseLazyArrayWorkload(): \Closure +{ + $values = range(1, 256); + + return static function () use ($values): void { + $result = LazyCollection::from($values) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->take(64) + ->all(); + + if (count($result) !== 64) { + throw new RuntimeException('Lazy array release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return \Closure(): void + */ +function releaseLazyFileColdWorkload(array &$temporaryDirectories): \Closure +{ + [$source] = releaseLazyFileDirectories($temporaryDirectories); + + return static function () use ($source): void { + $config = new LazyFileConfig($source); + + if ( + $config->get('app.name') !== 'arraykit' + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Cold lazy-file release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return \Closure(): void + */ +function releaseLazyFileGeneratedWorkload(array &$temporaryDirectories): \Closure +{ + [$source, $cache] = releaseLazyFileDirectories($temporaryDirectories); + + (new LazyFileConfig($source, namespaceCacheDirectory: $cache)) + ->warmNamespaceCache('app'); + + return static function () use ($cache, $source): void { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + if ( + $config->get('app.name') !== 'arraykit' + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Generated lazy-file release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return array{0:string,1:string} + */ +function releaseLazyFileDirectories(array &$temporaryDirectories): array +{ + $suffix = bin2hex(random_bytes(5)); + $source = sys_get_temp_dir() . '/arraykit-release-source-' . $suffix; + $cache = sys_get_temp_dir() . '/arraykit-release-cache-' . $suffix; + + if (!mkdir($source, 0777, true) || !mkdir($cache, 0777, true)) { + throw new RuntimeException('Unable to create release benchmark directories.'); + } + + $payload = " 'arraykit', 'nested' => ['value' => 42]];\n"; + if (file_put_contents($source . '/app.php', $payload) === false) { + throw new RuntimeException('Unable to write release benchmark config.'); + } + + $temporaryDirectories[] = $source; + $temporaryDirectories[] = $cache; + + return [$source, $cache]; +} + +function releaseRemoveDirectory(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + releaseRemoveDirectory($path); + } elseif (is_file($path) || is_link($path)) { + unlink($path); + } + } + + rmdir($directory); +} From 8520f127d9f80f1833d3a44e3d882565b6aa756c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:49:00 +0600 Subject: [PATCH 118/129] bench(release): generate same-run baseline and candidate results --- benchmarks/release-representative.php | 461 ++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 benchmarks/release-representative.php diff --git a/benchmarks/release-representative.php b/benchmarks/release-representative.php new file mode 100644 index 0000000..ccd87d6 --- /dev/null +++ b/benchmarks/release-representative.php @@ -0,0 +1,461 @@ + $workloadSpecs */ +$workloadSpecs = [ + ['name' => 'array-query', 'operations' => 100, 'warmup' => 20], + ['name' => 'dot-config', '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], +]; + +$root = dirname(__DIR__); +$worker = __DIR__ . '/release-workload.php'; +$buildDirectory = $root . '/build'; +$baselineDirectory = sys_get_temp_dir() . '/arraykit-release-baseline-' . getmypid(); + +if (!is_dir($buildDirectory) && !mkdir($buildDirectory, 0777, true)) { + throw new RuntimeException('Unable to create release benchmark build directory.'); +} + +releasePrepareBaseline($root, $baselineDirectory); + +try { + $environment = releaseEnvironment(); + $baselineSha = trim(releaseProcess(['git', 'rev-parse', RELEASE_BASELINE_TAG . '^{commit}'], $root)); + $candidateSha = trim(releaseProcess(['git', 'rev-parse', 'HEAD'], $root)); + + $baseline = releaseBenchmarkDocument( + $baselineDirectory, + $worker, + RELEASE_BASELINE_TAG . '@' . $baselineSha, + $environment, + $workloadSpecs, + ); + $candidate = releaseBenchmarkDocument( + $root, + $worker, + 'candidate@' . $candidateSha, + $environment, + $workloadSpecs, + ); + + releaseWriteJson($buildDirectory . '/release-baseline.json', $baseline); + releaseWriteJson($buildDirectory . '/release-candidate.json', $candidate); + + fwrite(STDOUT, sprintf( + "Representative release benchmark generated for %s and %s.\n", + $baseline['environment']['release'], + $candidate['environment']['release'], + )); +} finally { + releaseRemoveBaseline($root, $baselineDirectory); +} + +/** + * @param array $environment + * @param list $specs + * @return array + */ +function releaseBenchmarkDocument( + string $root, + string $worker, + string $release, + array $environment, + array $specs, +): array { + $workloads = []; + + foreach ($specs as $spec) { + foreach ([1, 2, 4] as $concurrency) { + $workloads[] = releaseBenchmarkWorkload( + $root, + $worker, + $spec, + $concurrency, + ); + } + } + + return [ + 'schema_version' => 1, + 'generated_at' => gmdate(DATE_ATOM), + 'environment' => [ + ...$environment, + 'release' => $release, + ], + 'workloads' => $workloads, + ]; +} + +/** + * @param array{name:string,operations:int,warmup:int} $spec + * @return array + */ +function releaseBenchmarkWorkload( + string $root, + string $worker, + array $spec, + int $concurrency, +): array { + $trials = []; + + for ($trial = 0; $trial < RELEASE_REPETITIONS; $trial++) { + $trials[] = releaseBenchmarkTrial( + $root, + $worker, + $spec['name'], + $spec['operations'], + $spec['warmup'], + $concurrency, + ); + } + + $rpms = array_column($trials, 'successful_rpm'); + $latencies = []; + $attempted = 0; + $successful = 0; + $failed = 0; + $cpuAverages = []; + $cpuPeaks = []; + $memoryAverages = []; + $memoryPeaks = []; + $memoryGrowth = []; + + foreach ($trials as $trial) { + $attempted += $trial['attempted']; + $successful += $trial['successful']; + $failed += $trial['failed']; + $latencies = [...$latencies, ...$trial['latencies_ms']]; + $cpuAverages[] = $trial['cpu_average_percent']; + $cpuPeaks[] = $trial['cpu_peak_percent']; + $memoryAverages[] = $trial['memory_average_mb']; + $memoryPeaks[] = $trial['memory_peak_mb']; + $memoryGrowth[] = $trial['memory_growth_mb']; + } + + $medianRpm = releasePercentile($rpms, 50); + $spread = $medianRpm > 0 + ? ((max($rpms) - min($rpms)) / $medianRpm) * 100 + : 100.0; + + return [ + 'name' => $spec['name'] . '-c' . $concurrency, + 'type' => 'component', + 'metadata' => [ + 'suite' => 'arraykit-5.3-release', + 'dataset' => 'deterministic-v1', + 'operations_per_worker' => $spec['operations'], + 'valid_output_only' => true, + ], + 'repetitions' => RELEASE_REPETITIONS, + 'warmup_operations' => $spec['warmup'], + 'duration_seconds' => 0.0, + 'concurrency' => $concurrency, + 'result' => [ + 'attempted_operations' => $attempted, + 'successful_operations' => $successful, + 'failed_operations' => $failed, + 'timeouts' => 0, + 'successful_rpm' => $medianRpm, + 'error_rate' => $attempted > 0 ? $failed / $attempted : 0.0, + 'latency_ms' => [ + 'minimum' => $latencies === [] ? null : min($latencies), + 'average' => $latencies === [] ? null : array_sum($latencies) / count($latencies), + 'p50' => releasePercentile($latencies, 50), + 'p95' => releasePercentile($latencies, 95), + 'p99' => releasePercentile($latencies, 99), + 'maximum' => $latencies === [] ? null : max($latencies), + ], + 'cpu' => [ + 'average_percent' => releaseAverage($cpuAverages), + 'peak_percent' => $cpuPeaks === [] ? null : max($cpuPeaks), + ], + 'memory' => [ + 'average_mb' => releaseAverage($memoryAverages), + 'peak_mb' => $memoryPeaks === [] ? null : max($memoryPeaks), + 'growth_mb' => $memoryGrowth === [] ? null : max($memoryGrowth), + ], + 'stability' => [ + 'status' => $failed === 0 && $spread <= RELEASE_STABILITY_SPREAD_PERCENT + ? 'stable' + : 'unstable', + 'spread_percent' => max(0.0, $spread), + ], + ], + ]; +} + +/** + * @return array{ + * attempted:int, + * successful:int, + * failed:int, + * successful_rpm:float, + * latencies_ms:list, + * cpu_average_percent:float, + * cpu_peak_percent:float, + * memory_average_mb:float, + * memory_peak_mb:float, + * memory_growth_mb:float + * } + */ +function releaseBenchmarkTrial( + string $root, + string $worker, + string $workload, + int $operations, + int $warmup, + int $concurrency, +): array { + $processes = []; + $startedAt = hrtime(true); + + for ($workerIndex = 0; $workerIndex < $concurrency; $workerIndex++) { + $process = new Process([ + PHP_BINARY, + '-d', + 'opcache.enable_cli=1', + '-d', + 'opcache.jit=0', + $worker, + $root, + $workload, + (string) $operations, + (string) $warmup, + ]); + $process->setTimeout(120); + $process->start(); + $processes[] = $process; + } + + $results = []; + + foreach ($processes as $process) { + $exitCode = $process->wait(); + if ($exitCode !== 0) { + throw new RuntimeException( + 'Release benchmark worker failed: ' . trim($process->getErrorOutput()), + ); + } + + $decoded = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new RuntimeException('Release benchmark worker returned invalid JSON.'); + } + + $results[] = $decoded; + } + + $elapsedSeconds = max((hrtime(true) - $startedAt) / 1_000_000_000, 0.000001); + $attempted = 0; + $successful = 0; + $failed = 0; + $latencies = []; + $cpuSeconds = 0.0; + $cpuPeaks = []; + $memoryInitial = []; + $memoryFinal = []; + $memoryPeak = []; + + foreach ($results as $result) { + $attempted += (int) ($result['attempted'] ?? 0); + $successful += (int) ($result['successful'] ?? 0); + $failed += (int) ($result['failed'] ?? 0); + $latencies = [ + ...$latencies, + ...array_map('floatval', is_array($result['latencies_ms'] ?? null) ? $result['latencies_ms'] : []), + ]; + $cpuSeconds += (float) ($result['cpu_seconds'] ?? 0.0); + $cpuPeaks[] = (float) ($result['cpu_percent'] ?? 0.0); + $memoryInitial[] = (float) ($result['memory_initial_mb'] ?? 0.0); + $memoryFinal[] = (float) ($result['memory_final_mb'] ?? 0.0); + $memoryPeak[] = (float) ($result['memory_peak_mb'] ?? 0.0); + } + + $averageInitial = releaseAverage($memoryInitial) ?? 0.0; + $averageFinal = releaseAverage($memoryFinal) ?? 0.0; + + return [ + 'attempted' => $attempted, + 'successful' => $successful, + 'failed' => $failed, + 'successful_rpm' => ($successful / $elapsedSeconds) * 60, + 'latencies_ms' => $latencies, + 'cpu_average_percent' => ($cpuSeconds / $elapsedSeconds) * 100, + 'cpu_peak_percent' => $cpuPeaks === [] ? 0.0 : max($cpuPeaks), + 'memory_average_mb' => $averageFinal, + 'memory_peak_mb' => $memoryPeak === [] ? 0.0 : max($memoryPeak), + 'memory_growth_mb' => max(0.0, $averageFinal - $averageInitial), + ]; +} + +/** + * @return array + */ +function releaseEnvironment(): array +{ + $extensions = get_loaded_extensions(); + sort($extensions); + + $cpuModel = 'unknown'; + $cpuInfo = is_readable('/proc/cpuinfo') ? file_get_contents('/proc/cpuinfo') : false; + if (is_string($cpuInfo) && preg_match('/^model name\s*:\s*(.+)$/m', $cpuInfo, $matches) === 1) { + $cpuModel = trim($matches[1]); + } + + $operatingSystem = php_uname('s') . ' ' . php_uname('r'); + $runner = getenv('RUNNER_NAME'); + $runner = is_string($runner) && $runner !== '' ? $runner : php_uname('n'); + $fingerprintPayload = implode('|', [ + PHP_VERSION, + PHP_SAPI, + $operatingSystem, + $cpuModel, + $runner, + implode(',', $extensions), + ]); + + return [ + 'stable' => getenv('ARRAYKIT_BENCHMARK_STABLE') === '1', + 'fingerprint' => hash('sha256', $fingerprintPayload), + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'operating_system' => $operatingSystem, + 'cpu_model' => $cpuModel, + 'memory_limit' => (string) ini_get('memory_limit'), + 'opcache' => 'cli-enabled', + 'jit' => false, + 'xdebug' => extension_loaded('xdebug'), + 'extensions' => $extensions, + 'runner' => $runner, + ]; +} + +function releaseAverage(array $values): ?float +{ + return $values === [] ? null : array_sum($values) / count($values); +} + +function releasePercentile(array $values, int $percentile): ?float +{ + if ($values === []) { + return null; + } + + sort($values, SORT_NUMERIC); + $index = (int) ceil(($percentile / 100) * count($values)) - 1; + $index = max(0, min(count($values) - 1, $index)); + + return (float) $values[$index]; +} + +function releasePrepareBaseline(string $root, string $baselineDirectory): void +{ + releaseRemoveBaseline($root, $baselineDirectory); + + $verify = new Process(['git', 'rev-parse', '--verify', RELEASE_BASELINE_TAG . '^{commit}'], $root); + $verify->run(); + + if (!$verify->isSuccessful()) { + releaseProcess([ + 'git', + 'fetch', + '--depth=1', + 'origin', + 'refs/tags/' . RELEASE_BASELINE_TAG . ':refs/tags/' . RELEASE_BASELINE_TAG, + ], $root); + } + + releaseProcess( + ['git', 'worktree', 'add', '--detach', '--force', $baselineDirectory, RELEASE_BASELINE_TAG], + $root, + ); + releaseProcess([ + 'composer', + 'install', + '--no-dev', + '--no-interaction', + '--prefer-dist', + '--no-progress', + '--classmap-authoritative', + ], $baselineDirectory, 240); +} + +function releaseProcess(array $command, string $workingDirectory, int $timeout = 120): string +{ + $process = new Process($command, $workingDirectory, ['XDEBUG_MODE' => 'off']); + $process->setTimeout($timeout); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf( + "Command failed: %s\n%s", + implode(' ', $command), + trim($process->getErrorOutput()), + )); + } + + return $process->getOutput(); +} + +function releaseRemoveBaseline(string $root, string $baselineDirectory): void +{ + if (!is_dir($baselineDirectory)) { + return; + } + + $remove = new Process( + ['git', 'worktree', 'remove', '--force', $baselineDirectory], + $root, + ); + $remove->setTimeout(60); + $remove->run(); + + if (is_dir($baselineDirectory)) { + releaseRemoveDirectory($baselineDirectory); + } +} + +function releaseRemoveDirectory(string $directory): void +{ + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + releaseRemoveDirectory($path); + } elseif (is_file($path) || is_link($path)) { + unlink($path); + } + } + + rmdir($directory); +} + +/** + * @param array $document + */ +function releaseWriteJson(string $path, array $document): void +{ + $encoded = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + if (file_put_contents($path, $encoded . PHP_EOL) === false) { + throw new RuntimeException('Unable to write release benchmark result: ' . $path); + } +} From 5ece0518ddc293d3f2b4cdef2fa107b31f5590b8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:49:24 +0600 Subject: [PATCH 119/129] build(release): expose representative benchmark script --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 7dfddea..931e3b8 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,9 @@ "Infocyph\\ArrayKit\\Tests\\": "tests/" } }, + "scripts": { + "benchmark:representative": "@php benchmarks/release-representative.php" + }, "config": { "allow-plugins": { "ergebnis/composer-normalize": true, From da56999dd5f97ee2199fa843535c63fb76dbec27 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:49:28 +0600 Subject: [PATCH 120/129] ci(release): add representative benchmark and worker soak gates --- .github/workflows/security-standards.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index e7839d3..3c606dd 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -14,8 +14,52 @@ jobs: with: integration_services: '[]' service_topologies: '{}' + benchmark_composer_script: "benchmark:representative" + benchmark_result_file: "build/release-candidate.json" + benchmark_baseline_file: "build/release-baseline.json" + benchmark_max_regression_percent: 2 + benchmark_stable_environment: false permissions: security-events: write actions: read contents: read + + + release-acceptance: + name: "Release Acceptance" + needs: phpforge + runs-on: ubuntu-latest + env: + XDEBUG_MODE: off + steps: + - uses: actions/checkout@v7 + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer:v2 + extensions: opcache + coverage: none + - name: Install acceptance dependencies + run: composer install --no-interaction --prefer-dist --no-progress + - name: Run ArrayKit release acceptance tests + run: php -d opcache.enable_cli=1 vendor/bin/pest tests/Feature/Release530BatchFTest.php + - name: Run persistent-worker soak + run: | + mkdir -p build + composer ic:soak:worker \ + --duration=20 \ + --warmup=3 \ + --sample-interval=1 \ + --max-growth-mb=16 \ + --report=build/release-worker-soak.json \ + -- php -d opcache.enable_cli=1 tests/Support/release530-persistent-worker.php + - name: Upload worker-soak evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-worker-soak + path: build/release-worker-soak.json + if-no-files-found: error + retention-days: 61 From 6303d1d648081084e8fd12a36c23c11779c70c27 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:52:43 +0600 Subject: [PATCH 121/129] style(composer): normalize representative benchmark script --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 931e3b8..07bcb13 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,6 @@ "Infocyph\\ArrayKit\\Tests\\": "tests/" } }, - "scripts": { - "benchmark:representative": "@php benchmarks/release-representative.php" - }, "config": { "allow-plugins": { "ergebnis/composer-normalize": true, @@ -56,5 +53,8 @@ "classmap-authoritative": true, "optimize-autoloader": true, "sort-packages": true + }, + "scripts": { + "benchmark:representative": "@php benchmarks/release-representative.php" } } From b5e62da14f1669ce97a558778b9834ac71681148 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:52:47 +0600 Subject: [PATCH 122/129] style(bench): satisfy CLI function policy --- benchmarks/release-workload.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/benchmarks/release-workload.php b/benchmarks/release-workload.php index 3f64061..4afc3a4 100644 --- a/benchmarks/release-workload.php +++ b/benchmarks/release-workload.php @@ -16,8 +16,9 @@ unset($script); if (!is_string($root) || !is_string($workload) || !is_numeric($operations) || !is_numeric($warmup)) { - fwrite(STDERR, "Usage: release-workload.php \n"); - exit(2); + throw new RuntimeException( + 'Usage: release-workload.php ', + ); } require rtrim($root, DIRECTORY_SEPARATOR) . '/vendor/autoload.php'; @@ -68,7 +69,7 @@ releaseRemoveDirectory($directory); } -echo json_encode([ +fwrite(STDOUT, json_encode([ 'attempted' => $operations, 'successful' => $successes, 'failed' => $failures, @@ -79,7 +80,7 @@ 'memory_initial_mb' => $memoryBefore / 1_048_576, 'memory_final_mb' => $memoryAfter / 1_048_576, 'memory_peak_mb' => memory_get_peak_usage(true) / 1_048_576, -], JSON_THROW_ON_ERROR), PHP_EOL; +], JSON_THROW_ON_ERROR) . PHP_EOL); /** * @return \Closure(): void From a5b7aba1c54254f332b37f37580632574de11982 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:55:20 +0600 Subject: [PATCH 123/129] ci(release): use PHPForge Pest path for acceptance --- .github/workflows/security-standards.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 3c606dd..0dcf74e 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -40,11 +40,12 @@ jobs: php-version: "8.5" tools: composer:v2 extensions: opcache + ini-values: opcache.enable_cli=1 coverage: none - name: Install acceptance dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Run ArrayKit release acceptance tests - run: php -d opcache.enable_cli=1 vendor/bin/pest tests/Feature/Release530BatchFTest.php + run: composer ic:test:pest - name: Run persistent-worker soak run: | mkdir -p build @@ -56,7 +57,7 @@ jobs: --report=build/release-worker-soak.json \ -- php -d opcache.enable_cli=1 tests/Support/release530-persistent-worker.php - name: Upload worker-soak evidence - if: always() + if: always() && hashFiles('build/release-worker-soak.json') != '' uses: actions/upload-artifact@v7 with: name: release-worker-soak From 04cf58dd0f4e14406e8a25f3017952226fb8ccf3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 13:58:15 +0600 Subject: [PATCH 124/129] ci(release): mirror PHPForge Pest invocation --- .github/workflows/security-standards.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 0dcf74e..e6d463d 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -45,7 +45,11 @@ jobs: - name: Install acceptance dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Run ArrayKit release acceptance tests - run: composer ic:test:pest + run: >- + php vendor/bin/pest + --configuration vendor/infocyph/phpforge/resources/pest.xml + --bootstrap vendor/autoload.php + tests/Feature/Release530BatchFTest.php - name: Run persistent-worker soak run: | mkdir -p build From 3e0e770240d3e6ab787a3d74bbfd446952ee5f9a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 14:04:13 +0600 Subject: [PATCH 125/129] docs(plan): record completed ArrayKit release acceptance --- arraykit-review-and-release-plan.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 0ad7e00..258bf8d 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -2,7 +2,7 @@ Date: 2026-10-04 (Asia/Dhaka) -Status: implementation complete through Batch E; Batches A-E are complete and Batch F release acceptance is in progress. +Status: ArrayKit implementation and repository-owned acceptance are complete through Batch F; tagging remains held only for the stable-runner performance budget and downstream Foundation acceptance, intentionally deferred to the next phase. Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. @@ -235,7 +235,7 @@ Compare the direct binding against the existing factory-composition prototype fo | C — R09/R10/R11/R12 + I03 | Complete | Threshold/native-semantics, wildcard presence, SQL-like matching, overflow pagination, and wrapper regressions pass across PHP 8.4/8.5 stable/lowest QA in workflow run #165. | | D — I01/I02/I04/I05 | Complete | Array replay specialization and benchmarks, bounded DTO graph APIs/regressions, lifecycle/trust docs, and the dev-only PHPBench/Doctrine upstream maintenance outcome pass the PHPForge QA matrix in workflow run #165. | | E — Runwire 2.1.1 integration | Complete | Optional exact Runwire 2.1.1 binding, absence/unbound fallback, lifecycle/cancellation/capability matrix, derived propagation/rebinding, intermediary forwarding, docs, and bound/unbound benchmark subjects pass the PHPForge QA matrix in workflow run #165. | -| F — integrated release acceptance | In progress | Repository-side QA/analysis/clean-install gates are green on run #165 and 5.3 migration/release notes are consolidated. Remaining acceptance evidence: stable production-equivalent baseline comparison, representative external consumer/host-driver validation, and persistent-worker soak/worker-replacement results. | +| F — integrated release acceptance | ArrayKit complete; external gates pending | Run #175 is green on final ArrayKit-owned QA: PHP 8.4/8.5 analysis, stable/lowest QA, clean install, same-run 5.2.0/candidate representative benchmark generation/validation, Runwire host-capability acceptance, and persistent-worker soak. Batch F tests: 5 passed / 1,496 assertions. Worker soak: 23.00s, 20 samples, 75.86 MiB initial/peak, 0.00 MiB growth. The GitHub-hosted runner correctly leaves the <=2% stable-environment comparison skipped; Foundation acceptance is deferred by decision until ArrayKit is finished. | ## Implementation sequence @@ -252,20 +252,24 @@ Do not run source-mutating tooling during this review-only stage. During impleme ## Release acceptance gates -- [ ] R01-R12 regressions fail on 5.2.0 and pass on the candidate; original valid-input contracts and existing tests remain intact. -- [ ] I01-I05 have implementation/measurement/documentation evidence or the explicitly allowed upstream maintenance outcome; bounded DTO APIs and replay behavior are verified without weakening existing contracts. -- [ ] Full PHPForge flow passes with current rules and configured scopes; audit warning is recorded with its development-only origin. -- [ ] PHP 8.4 and 8.5 stable/lowest dependency CI and clean `--no-dev` installation pass on the final committed revision. Run compatibility/deprecation checks against the next intended PHP target and identify unavailable target evidence explicitly. -- [ ] Generated config fixtures are validated before activation; old-cache rebuild instructions and OPcache/worker restart behavior are verified. No secret values appear in failures, logs or benchmark results. +- [x] R01-R12 regressions fail on 5.2.0 and pass on the candidate; original valid-input contracts and existing tests remain intact. +- [x] I01-I05 have implementation/measurement/documentation evidence or the explicitly allowed upstream maintenance outcome; bounded DTO APIs and replay behavior are verified without weakening existing contracts. +- [x] Full PHPForge flow passes with current rules and configured scopes; audit warning is recorded with its development-only origin. +- [x] PHP 8.4 and 8.5 stable/lowest dependency CI and clean `--no-dev` installation pass on the final committed revision. Run compatibility/deprecation checks against the next intended PHP target and identify unavailable target evidence explicitly. +- [x] Generated config fixtures are validated before activation; old-cache rebuild instructions and OPcache/worker restart behavior are verified. No secret values appear in failures, logs or benchmark results. - [ ] Direct consumer smoke plus a representative intermediary consumer (for example Foundation's layered-config usage) pass for cold reads, runtime writes, snapshot/restore, cache refresh and persistent execution. Do not claim a consumer test from source inspection alone. + - Deferred intentionally until the next Foundation phase. ArrayKit's direct host-capability and persistent-worker tests are complete; this checkbox remains open until a real downstream consumer run passes. - [ ] Capture a baseline and candidate on the same stable production-equivalent runner: PHP/extensions, no-dev optimized Composer mode, enabled production OPcache, OS/hardware, datasets, traffic mix, concurrency and source SHAs recorded. - [ ] Use at least three warmed steady-state trials at multiple concurrency levels; measure cold startup separately. Cover repeated config reads, first namespace materialization, generated exact/structural reads, array/set/query operations at threshold boundaries, bounded adversarial traversal, and lazy streaming within a representative host request/task. - [ ] Compare median **validated successful RPM** with a maximum 2% regression budget; reject invalid/partial/error responses from the numerator. Record p50/p95/p99, error/timeout rates, peak/steady memory, CPU, queue growth and relevant cache/lifecycle metadata. Predeclare workload-specific latency/memory limits from baseline and host capacity. Changes serving different correctness contracts require valid-output baselines, not timing of the existing broken behavior. -- [ ] Persistent-worker soak has bounded memory, state reset and no cross-request/tenant data leakage; include cache-miss/failure and worker replacement. Keep mutable config/hooks and replay streams within their intended lifetime. -- [ ] Runwire instance binding and derived-operation propagation pass the complete acceptance matrix, including absence, unavailable capabilities and direct/intermediary forwarding; the host retains ownership of workers, event loops and scope completion. + - Same-run 5.2.0/candidate documents are generated and validated on GitHub Actions with three warmed trials at concurrency 1/2/4. The <=2% comparison is intentionally not asserted there because the hosted runner is not a stable benchmark environment. +- [x] Persistent-worker soak has bounded memory, state reset and no cross-request/tenant data leakage; include cache-miss/failure and worker replacement. Keep mutable config/hooks and replay streams within their intended lifetime. +- [x] Runwire instance binding and derived-operation propagation pass the complete ArrayKit-owned acceptance matrix, including absence, unavailable capabilities, FPM/FrankenPHP/RoadRunner/Swoole capability contexts and direct forwarding; the host retains ownership of workers, event loops and scope completion. Intermediary-library forwarding remains part of the deferred Foundation phase. - [ ] Separately report ordinary unbound regression and bound fairness/cancellation results, including a consumer that forwards instances through another library. Do not extrapolate microbenchmarks into host RPM. - [ ] Configure PHPForge's existing representative benchmark result/baseline inputs and validate/compare their machine-readable contracts; do not invent a parallel workflow or treat the current skipped comparisons as passed. + - Inputs are now configured and both result contracts validate in CI. The comparator runs but reports `skipped` on GitHub-hosted runners by design; rerun with `ARRAYKIT_BENCHMARK_STABLE=1` only on a controlled stable runner. - [ ] Record the final **5.3.0** candidate SHA and successful CI URL; review consolidated release notes and migration guidance covering fixes, additive APIs, Runwire optional usage and generated-cache rebuilds. Tagging and publishing remain separate actions after acceptance. + - Latest fully successful ArrayKit-owned acceptance before this tracker-only sync: `04cf58dd0f4e14406e8a25f3017952226fb8ccf3`, workflow run #175 (`37280759990`). This tracker sync becomes the next candidate SHA and must retain green CI before release. ## Reproduction index From 5c1b2c65cb224cad8f24db19ba5cc29a0e908b9c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 14:39:12 +0600 Subject: [PATCH 126/129] docs(plan): record final ArrayKit acceptance evidence --- arraykit-review-and-release-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md index 258bf8d..e8dd5a8 100644 --- a/arraykit-review-and-release-plan.md +++ b/arraykit-review-and-release-plan.md @@ -235,7 +235,7 @@ Compare the direct binding against the existing factory-composition prototype fo | C — R09/R10/R11/R12 + I03 | Complete | Threshold/native-semantics, wildcard presence, SQL-like matching, overflow pagination, and wrapper regressions pass across PHP 8.4/8.5 stable/lowest QA in workflow run #165. | | D — I01/I02/I04/I05 | Complete | Array replay specialization and benchmarks, bounded DTO graph APIs/regressions, lifecycle/trust docs, and the dev-only PHPBench/Doctrine upstream maintenance outcome pass the PHPForge QA matrix in workflow run #165. | | E — Runwire 2.1.1 integration | Complete | Optional exact Runwire 2.1.1 binding, absence/unbound fallback, lifecycle/cancellation/capability matrix, derived propagation/rebinding, intermediary forwarding, docs, and bound/unbound benchmark subjects pass the PHPForge QA matrix in workflow run #165. | -| F — integrated release acceptance | ArrayKit complete; external gates pending | Run #175 is green on final ArrayKit-owned QA: PHP 8.4/8.5 analysis, stable/lowest QA, clean install, same-run 5.2.0/candidate representative benchmark generation/validation, Runwire host-capability acceptance, and persistent-worker soak. Batch F tests: 5 passed / 1,496 assertions. Worker soak: 23.00s, 20 samples, 75.86 MiB initial/peak, 0.00 MiB growth. The GitHub-hosted runner correctly leaves the <=2% stable-environment comparison skipped; Foundation acceptance is deferred by decision until ArrayKit is finished. | +| F — integrated release acceptance | ArrayKit complete; external gates deferred | Final run #176 is green: PHP 8.4/8.5 analysis, stable/lowest QA, clean install, same-run 5.2.0/candidate representative benchmark generation/contract validation, Runwire FPM/FrankenPHP/RoadRunner/Swoole capability acceptance, and persistent-worker soak. Batch F tests: 5 passed / 1,496 assertions. Worker soak: 23.01s, 20 samples, 75.91 MiB initial/peak, 0.00 MiB growth. The GitHub-hosted runner correctly leaves the <=2% stable-environment comparison skipped; downstream Foundation acceptance and stable-runner performance gating are intentionally deferred to the next phase. | ## Implementation sequence From 5958774da675f4ad2b3cc14ffe5b6b22c6f93807 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 16:25:02 +0600 Subject: [PATCH 127/129] :wrench: build(ci): increase security soak worker duration and warmup intervals - Increase soak worker duration to 300 seconds and warmup cycles to 10 in security-standards workflow :construction_worker: --- .github/workflows/security-standards.yml | 4 +- arraykit-review-and-release-plan.md | 330 ------------------ docs/array-helpers.rst | 1 + docs/collection.rst | 14 +- docs/facade.rst | 1 + docs/lazy-config.rst | 5 +- docs/lifecycle.rst | 14 +- docs/release-5.3.0.rst | 26 ++ docs/rule-reference.rst | 2 +- docs/traits-and-helpers.rst | 12 +- .../Concerns/ArrayMultiQuerySortTrait.php | 11 + .../Concerns/DotNotationPublicApiTrait.php | 8 +- src/Array/DotNotation.php | 19 +- src/Collection/LazyCollection.php | 8 +- src/Collection/RunwireLazyBinding.php | 20 +- .../Concerns/LazyFileConfigCacheTrait.php | 51 ++- src/Config/LazyFileConfig.php | 11 +- src/DTO/Concerns/DTOTrait.php | 4 +- src/DTO/DTOGraphGuard.php | 116 ++++-- src/Facade/ModuleProxy.php | 19 +- .../Release530ReviewRegressionTest.php | 261 ++++++++++++++ .../Release530PersistentWorkerSoak.php | 40 ++- 22 files changed, 576 insertions(+), 401 deletions(-) delete mode 100644 arraykit-review-and-release-plan.md create mode 100644 tests/Feature/Release530ReviewRegressionTest.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index e6d463d..fdd4b1b 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -54,8 +54,8 @@ jobs: run: | mkdir -p build composer ic:soak:worker \ - --duration=20 \ - --warmup=3 \ + --duration=300 \ + --warmup=10 \ --sample-interval=1 \ --max-growth-mb=16 \ --report=build/release-worker-soak.json \ diff --git a/arraykit-review-and-release-plan.md b/arraykit-review-and-release-plan.md deleted file mode 100644 index e8dd5a8..0000000 --- a/arraykit-review-and-release-plan.md +++ /dev/null @@ -1,330 +0,0 @@ -# ArrayKit 5.3.0 implementation and release plan - -Date: 2026-10-04 (Asia/Dhaka) - -Status: ArrayKit implementation and repository-owned acceptance are complete through Batch F; tagging remains held only for the stable-runner performance budget and downstream Foundation acceptance, intentionally deferred to the next phase. - -Reviewed revision: `fdeff013d2892383761aa8ddf2515acfc429d7fe`. -Published baseline: **5.2.0**, source revision `053440b61071a17332b18879b12026f54a0ad144`. -The reviewed source, tests, benchmarks and Composer manifest are unchanged from that tag; the subsequent change removed the root CaptainHook configuration. - -This plan follows the installed [PHPForge engineering principles](vendor/infocyph/phpforge/resources/engineering-principles.md) and [agent workflow](vendor/infocyph/phpforge/resources/AGENTS.md). It distinguishes defect corrections from additive enhancements, preserves public contracts, assigns changes to existing owners, and requires correctness, compatibility and representative performance evidence before tagging. - -## Decision - -Target **5.3.0 directly**, as requested. Deliver the **12 confirmed fixes**, including five P1 findings, the scoped improvements below, and additive Runwire 2.1.1 instance integration through one implementation sequence and one release candidate. There is no intermediate patch release or separate later Runwire release track. - -Runwire integration is part of the 5.3.0 delivery scope. Its use remains optional for consumers: passed instances enable relevant capabilities, and unbound consumers retain the normal path. Performance and lifecycle evidence are release gates for the integration, not a reason to silently drop it from the plan. - -Keep PHP 8.4 support. Do not change existing loose/strict comparison defaults, list-versus-map merge semantics, helper opt-in behavior, or established mutation signatures as incidental cleanup. If a proposed fix actually removes a supported contract, revise the version and migration decision before implementing it. - -The highest-priority security-related finding is ineffective resource limiting in APIs explicitly advertised for untrusted/deep data. Exploitability depends on a host passing attacker-controlled data or paths into those APIs. This review did not establish a standalone remote-code-execution exploit or authentication bypass. PHP config files and generated PHP caches are executable, trusted deployment inputs; corruption fallback is not a sandbox. - -## Consolidated 5.3.0 scope - -| Workstream | Release commitment | -| --- | --- | -| Correctness and security boundaries | Resolve R01-R12 and cover native APIs, facades and collection/pipeline wrappers. | -| Configuration lifecycle | Coherent lazy/layered mutations, snapshots, memo invalidation, authoritative rebuilds and validated generated artifacts, with cache migration guidance. | -| Lazy collections | Preserve terminal source failures, measure and reduce avoidable replay allocations, and add explicit passed-instance Runwire binding with bounded cancellation/yield checkpoints. | -| DTO graph handling | Add compatible bounded graph entry points with cycle/depth/node handling; retain ordinary DTO contracts. | -| Ownership and structural improvements | Consolidate the reported repeated logic in its existing owners and document request/worker/cache/stream ownership. | -| Dependencies and tooling | Keep Runwire optional at runtime, verify its exact supported version in integration tests, assess development dependency hygiene, and retain all PHPForge detectors. | -| Release evidence | One final 5.3.0 SHA, complete compatibility/consumer/runtime checks, measured performance and soak evidence, CI, release notes and acceptance decision. | - -Speculative APIs, unrelated rewrites and unsupported asynchronous filesystem claims remain outside this scope. The development-only abandoned dependency remains a recorded upstream maintenance item when no compatible replacement is available; it does not become a new blocker contrary to the established PHPForge audit policy. - -## Verified evidence - -| Check | Result and boundary | -| --- | --- | -| Review coverage | All 35 production PHP files; array/set/query operations, dot paths, collections/pipeline/lazy iteration, config/layering/caches, dotenv/environment, DTOs, hooks, facade/helpers; relevant tests, benchmarks, Composer and CI configuration. Graphify used for navigation; findings checked in source and runtime probes. | -| PHPForge doctor/config resolution | Healthy; bundled tool configurations active. Cognitive budgets: class 80, function 12, dependency tree 120; PHPStan level max. | -| Local detailed quality suite | `composer ic:tests:details` passed on PHP 8.5.4: 298 tests, 6,710 assertions; syntax, references, duplicate/comment checks, formatting, architecture, PHPStan, Psalm security analysis and Rector dry run passed. | -| Final local full suite | `composer ic:tests` passed on the same source revision. | -| Manifest and constraints | `composer validate --strict`, `composer ic:release:constraints`, and `composer ic:skipper` passed. | -| Dependency audit | Live `composer audit --locked --format=json`: zero advisories; one abandoned development dependency, `doctrine/annotations`, required by PHPBench 1.7.0. Raw Composer exits 1 for abandonment. `composer ic:release:audit` passes and reports abandonment as non-blocking. There are no third-party runtime package dependencies to audit under `--no-dev`. | -| Local benchmark smoke | `composer ic:bench:quick` passed: 99 subjects, zero failures/errors, zero performance assertions. PHP 8.5.4, Xdebug absent, CLI OPcache disabled. This is execution evidence, not production throughput or a regression comparison. | -| Exact revision CI | [Run 37180322867](https://github.com/infocyph/ArrayKit/actions/runs/37180322867) succeeded for the reviewed SHA: PHP 8.4/8.5 analysis, prefer-stable/prefer-lowest QA, benchmarks, clean install and security report. Benchmark-result validation and the regression comparison were skipped because representative result/baseline inputs were empty. | -| Release metadata | Live [ArrayKit package metadata](https://repo.packagist.org/p2/infocyph/arraykit.json) confirms 5.2.0. [Runwire metadata](https://repo.packagist.org/p2/infocyph/runwire.json) confirms 2.1.1 at `745b1c2bd7caa56aa5abf6d742c1aa8318fec494`; the matching local tag was inspected. | -| Independent probes | Reproductions below found failures outside the baseline suite. An additional 228 scalar/array membership cases across optimization thresholds matched native `in_array()`; native loose object-to-number comparisons produced expected PHP notices. | - -Local PHP 8.4 and the next PHP upgrade target were not available for new probes. Existing CI covers baseline 8.4/8.5 behavior, not the new repro cases or future fixes. No representative host RPM comparison, persistent-worker soak, or downstream consumer acceptance was performed. No source fixes, dependency changes, release or tag were made during this review. - -## Required findings - -Priority P1 means correct before the next release; P2 means a confirmed defect with a narrower trigger, also included in the 5.3.0 scope. These are engineering priorities, not CVSS ratings. - -### R01 — P1: traversal limits do not bound the advertised work - -Owners: `src/Array/ArrayMulti.php`, `src/Array/Concerns/ArrayMultiQuerySortTrait.php`, `src/Array/DotNotationPathOps.php`, `src/Array/Concerns/DotNotationPublicApiTrait.php`. - -- `flattenGuarded(range(1, 10000), maxNodes: 2, throwOnTooDeep: true)` returns all 10,000 values. `depthGuarded()` and `sortRecursiveGuarded()` similarly count recursive array calls rather than every inspected element; flat arrays escape the budget. -- `getSafe(['rows' => range(1, 10000)], 'rows.*', maxNodes: 2, throwOnTooDeep: true)` returns 10,000 values. Terminal wildcard fan-out consumes no additional node budget. -- Non-throwing `rows.*.id` traversal with a budget of 3 still returns 10,000 entries and invokes the default closure 9,999 times after exhaustion. -- A 12-level singleton array traversed through wildcard-only segments does not throw with `maxDepth: 2`; wildcard recursion does not advance/check depth consistently. - -Fix the actual traversal owners. Define one documented node/depth accounting policy, count examined children and wildcard fan-out, and stop work globally when the budget is exhausted. Bound sort/comparison work and default-callback execution as well as recursion. Apply the budget consistently to multiple requested paths; document whether it is shared per call. Retain established non-throwing depth behavior where possible, with explicit bounded results on exhaustion. Do not silently disable guards for a fast path. - -Acceptance: flat/wide/deep and cyclic reference arrays; terminal/repeated wildcards; multiple keys; exact budget boundaries; zero/negative limit contract; throw/non-throw modes; default-call counts; guarded-versus-normal equivalence below the limit. Demonstrate that processed elements and output allocations stop growing with input width after the budget is exhausted. - -### R02 — P1: layered config inherits incompatible state operations - -Owners: `src/Config/LayeredLazyFileConfig.php` and `src/Config/Concerns/BaseConfigTrait.php`. - -- `set('app.name', 'caller')` before the first read is overwritten by source materialization; the read returns `source`. -- A cold `get()` returns `[]`, while `all()` returns the configured namespaces. -- Snapshot before first read, read, restore, then read returns the default: namespace-materialization flags no longer match restored items. -- Cold `exportCache()` returns success and writes an empty configuration even when known namespaces exist. - -Give `LayeredLazyFileConfig` explicit ownership of materialization plus mutations and snapshots. Materialize affected namespaces before partial writes, preserve runtime writes at their intended precedence, and define complete replacement/reload semantics. Keep materialization flags, known namespaces and snapshot state consistent. Full retrieval/export must materialize the known namespace set, or explicitly reject unsupported operations before changing state. Preserve fallback < source < constructor overrides and atomic list replacement. Review inherited fill/forget/merge/overlay/loadArray/loadFile/reload/replace/set(null)/append/prepend/hooks/readonly behavior, not only `set()`. - -Acceptance: every inherited operation before and after first read; fallback/source/override overlaps; nulls and lists; unknown namespaces; snapshot/restore/replacement; cold/warm exports; readonly mode; direct and intermediary consumer use. - -### R03 — P1: facade calls lose reference mutations - -Owners: `src/Facade/ModuleProxy.php`, `src/ArrayKit.php`, `docs/facade.rst`. - -`ArrayKit::dot()->set($data, 'app.name', 'new')` returns success while `$data` remains unchanged. `ArrayKit::helper()->forget($data, 'remove')` also leaves the caller's array unchanged. Magic `__call()` receives a value argument array and cannot recover the original caller references. The facade documentation promises preservation of the native module API. - -Add explicit reference-preserving mutator entry points at the existing facade boundary, with target-appropriate signatures and dispatch. Cover dot set/fill/forget/rename/move/offsetSet/offsetUnset and helper forget. Preserve existing proxy return types and cached-proxy identity. Avoid a new generic reflection layer on each call or a success response for an unsupported mutation. Native static methods remain the correctness reference. - -Acceptance: caller array actually changes; return values match native calls; named arguments, overwrite/fill flags, literal/escaped paths and nested paths; invalid/missing/private methods still fail normally. - -### R04 — P1: memoization can return stale mutable or cache-derived values - -Owners: `src/Config/Concerns/BaseConfigTrait.php`, `src/Config/Concerns/LazyFileConfigCacheTrait.php`, `src/Config/LazyFileConfig.php`. - -Two confirmed triggers: - -1. Read `app.name` through an object stored in `Config`, mutate that object externally, then read again: the enabled-by-default memo returns the old scalar. -2. Resolve a leaf through `__flat.php`, then call `namespaceCache(null)`: the next read still returns the old cache leaf and the source namespace remains unloaded. Changing the cache source resets the flat index but not the resolved-value memo. - -Memoize only values whose immutability can be established under the actual input contract. Avoid caching paths through externally mutable objects/references; do not prohibit supported mixed values merely to simplify the cache. Clear relevant memo entries when changing cache sources, invalidating/rebuilding flat artifacts, or materializing a previously flat-only namespace. Define which loaded runtime values remain authoritative so cache invalidation cannot discard intentional caller writes. - -Acceptance: object mutation and PHP array references; read-cache enabled/disabled equivalence; cache directory A -> B -> null; selective/full flush; namespace structural reads after scalar reads; cached null and misses; loaded caller mutations remain intact. - -### R05 — P2: the valid `__flat` namespace collides with an internal artifact - -Owners: `src/Config/LazyFileConfig.php`, `src/Config/Concerns/LazyFileConfigCacheTrait.php`. - -`__flat` passes namespace validation. Warming it writes its namespace data to `__flat.php`, then overwrites that file with the generated flat index. A fresh `get('__flat.name')` returns the default rather than the source value. - -Move the internal flat artifact to a filename/layout outside the accepted namespace-file space. Keep valid caller namespaces usable. Treat generated cache layout as versioned/disposable metadata; document rebuilding old artifacts during upgrade and test legacy-reader behavior deliberately. Do not simply narrow the accepted namespace regex and call it compatibility-preserving. - -Acceptance: `__flat` source namespace; all accepted filename forms; alternate extensions; selective/full cache flush; unrelated directory entries; cold/warm structural and exact reads. - -### R06 — P2: cache warm-up can perpetuate stale generated configuration - -Owner: `src/Config/Concerns/LazyFileConfigCacheTrait.php`; documentation: `docs/lazy-config.rst`. - -Warm a source containing `Environment::ref()`, change the process value, create a new `LazyFileConfig`, and warm again: the new warmer reads the previous generated namespace and republishes the old value. Documentation says rerunning warm-up can update changed environment values. - -Define an authoritative rebuild path that reads the source, resolves current environment references and generates consistent artifacts without first importing stale generated values. Preserve the documented ability to intentionally cache caller-provided in-memory overrides. Make that precedence explicit. Clear all affected memos. Use atomic publication and a deployment-owned immutable directory/generation for multi-file rebuilds; writer locking alone does not make unlocked readers see a coherent generation. - -Acceptance: changed environment/source on a fresh warmer; same-instance behavior; in-memory overrides; cache-only deployments; failed writes leave the last valid generation usable; concurrent publishers/readers; OPcache/restart policy. Update docs to recommend build/deployment warm-up rather than normal request-time regeneration. - -### R07 — P1: replayable lazy sources swallow failure on subsequent traversals - -Owner: `src/Collection/LazyCollection.php`. - -A one-shot generator yields one item then throws `RuntimeException`. First `all()` throws; second `all()` silently succeeds with only the cached first item. The source failure is lost after the generator closes, allowing incomplete data to be presented as a successful result. This matters for failed database cursors and host cancellation as well as ordinary generator errors. - -Persist terminal source failure at its stream position and rethrow it on every traversal that reaches that boundary. Replaying a successfully consumed prefix remains valid. Handle failure during initialization, `valid()`, `current()` and `next()` consistently; release the exhausted source when practical. Preserve lazy consumption and interleaved-cursor behavior. - -Acceptance: failure before first yield and after several yields; repeated full and prefix reads; interleaved cursors; consumer callback errors versus source errors; cancellation exceptions; factory-backed renewable sources retain their independent semantics. - -### R08 — P2: compiled config export can report success for unusable PHP - -Owner: `src/Config/Concerns/BaseConfigTrait.php`. - -Exporting config with an anonymous object returns true but the generated cache raises `ParseError` when included. Named objects without a usable `__set_state()` can likewise produce non-loadable values; resources are not safely round-tripped by generic `var_export()`. - -Define and enforce the cacheable value contract before publication: scalar/null/arrays, recursively resolved closures/EnvReference, and explicitly supported exportable values such as enums where verified. Reject unsupported objects/resources and cycles with a clear exception, or deliberately support a safe reconstruction contract. Keep an existing valid cache intact on rejection. Check full file-write completion and generated syntax without executing arbitrary source as a validation shortcut. Do not add request-time lint processes; generation is a build/admin operation. - -Acceptance: valid scalar/null/enum and nested data; anonymous/named objects; resources; cyclic arrays/closures; failure preserves old artifact; warm whole-config and namespace exports round-trip identically. - -### R09 — P2: row membership optimization changes strict resource equality - -Owner: `src/Array/Concerns/ArrayMultiQuerySortTrait.php`; reusable engine: `src/Array/ArrayValueSetOps.php`. - -For two distinct closed resources, native `in_array(..., true)` returns false. With a 256-entry value set containing the first resource, `whereIn()` incorrectly accepts the second, `whereNotIn()` removes it, and `firstWhereIn()` reports a match. The row lookup path handles NaN but does not share the set engine's closed-resource fallback; both resources receive the same non-identity fingerprint. - -Reuse the existing equality owner's eligibility/fallback logic rather than extending an independent partial checklist. Keep exact PHP comparison semantics on both sides of the optimization threshold. - -Acceptance: threshold-minus-one/threshold/threshold-plus-one; distinct and identical live/closed resources; resources nested in arrays; NaN; object identity; null/false/zero; direct row helpers and Pipeline/Collection composition. - -### R10 — P2: wildcard presence treats an existing empty array as missing - -Owners: `src/Array/Concerns/DotNotationPublicApiTrait.php`, `src/Array/DotNotationPathOps.php`. - -`DotNotation::matches(['rows' => [['value' => []]]], 'rows.*.value')` returns false despite the value existing. The result checker recursively inspects value arrays as though they were only wildcard result containers, losing the distinction between an existing empty-array leaf and no match. - -Resolve wildcard presence through path traversal with an explicit missing marker, distinguishing result structure from leaf values. Preserve any-match semantics and null presence. Do not replace presence checks with truthiness. - -Acceptance: empty array/null/false/zero/empty-string leaves, missing leaves, empty parent lists, multiple wildcards, escaped selectors and literal wildcard keys. - -### R11 — P2: SQL-like exact patterns accept a trailing newline - -Owner: `src/Array/Concerns/ArrayMultiQuerySortTrait.php::whereLike()`. - -`whereLike([['name' => "admin\n"]], 'name', 'admin')` returns the row. PCRE `$` accepts the position before a final newline, violating whole-value literal matching. The `%` and `_` conversions also need an explicit newline/byte/Unicode contract. - -Use true whole-string anchoring, document the intended wildcard character semantics, and test them. Keep `preg_quote()` protection. Exercise adversarial wildcard patterns and handle PCRE failure distinctly from an ordinary no-match; choose a bounded matcher if measurements show pathological backtracking. Do not silently broaden matching or alter case defaults. - -Acceptance: exact patterns with terminal/internal newlines; `%`/`_`; empty strings; regex metacharacters; case modes; configured PCRE limit exhaustion; wrapper equivalence. - -### R12 — P2: valid large pagination inputs overflow before slicing - -Owner: `src/Array/ArraySingle.php::paginate()`; wrapper: `src/Collection/Pipeline.php`. - -`paginate([1, 2], PHP_INT_MAX, 2)` raises `TypeError` because `(page - 1) * perPage` becomes a float. Both inputs satisfy the published positive-integer preconditions. - -Check whether the page is beyond the array's possible range before multiplying, using overflow-safe integer arithmetic. Return an empty page for a valid out-of-range request. Keep existing invalid-argument exceptions and key preservation. - -Acceptance: empty arrays, first/last/out-of-range pages, `PHP_INT_MAX` boundaries, large per-page values, key preservation and Pipeline delegation. - -## 5.3.0 improvements and engineering debt - -- **I01 — Replay memory:** `LazyCollection::from()` memoizes all consumed entries, including array-backed sources; retained one-shot streams can grow without bound. `fromFactory()` already provides a renewable path without that replay memo. Measure array-backed versus generator/factory traversal and specialize array input when this removes allocations without changing replay, keys, laziness or failure semantics. Document bounded consumption and lifetime expectations. A new non-replayable API requires demonstrated need beyond the existing factory API; do not silently discard one-shot replay semantics. -- **I02 — DTO graph boundaries:** deep export and nested hydration have no cycle/depth/node budget. Cyclic DTO graphs can exhaust resources. Add bounded export/hydration entry points for arbitrary graphs, with explicit limits, cycle handling and clear failure behavior; keep ordinary DTO APIs compatible. Test self-cycles, mutual cycles, shared acyclic objects, deep/wide arrays, inherited properties and readonly behavior. Avoid reflection caches holding instances or request state. -- **I03 — Duplicate groups:** PHPProbe reports three passing clone groups (139 lines; 1.19%): contains-all/contains-any setup in `ArrayValueSetOps`, strict unique/derived-row loops, and config append/prepend setup. Inspect each group's shared responsibility and centralize repeated logic in its existing owner. Preserve distinct thresholds, short-circuit behavior and array-versus-row semantics; do not merge unrelated code merely to lower a metric. Verify every affected caller and throughput. -- **I04 — Tool dependency hygiene:** assess a compatible PHPForge/PHPBench update that removes abandoned `doctrine/annotations` when an upstream replacement is available. It is development-only, has no replacement declared, and is not a published advisory. Record the outcome in the 5.3.0 evidence; an unavailable upstream replacement remains a maintenance item under the existing non-blocking policy. Do not remove benchmark coverage, edit vendor or weaken audit policy to silence it. - - Implementation outcome: PHPForge already allows PHPBench `^1.7`; PHPBench 1.7.0 remains the current stable release and still requires `doctrine/annotations ^2.0`. ArrayKit has no direct PHPBench/runtime dependency to replace, so this remains a development-only upstream maintenance item. Benchmark coverage and audit policy remain intact. -- **I05 — Trust and lifecycle docs:** explicitly document trusted PHP source/cache directories, deployment-owned writes, secret-bearing artifacts, request-scoped mutable config/hooks, shallow collection copies, retained replay caches, Runwire binding lifetimes, and cleanup/replacement in persistent workers. Cache fallback cannot make an untrusted PHP file safe to include. Update examples and migration guidance alongside the corresponding implementation. - -## Runwire 2.1.1 integration for 5.3.0 - -Exact upstream sources: [RuntimeContext](https://github.com/infocyph/Runwire/blob/2.1.1/src/RuntimeContext.php), [RequestContext](https://github.com/infocyph/Runwire/blob/2.1.1/src/RequestContext.php), [CoroutineScope](https://github.com/infocyph/Runwire/blob/2.1.1/src/Coroutine/CoroutineScope.php), [CoroutineRuntime](https://github.com/infocyph/Runwire/blob/2.1.1/src/Coroutine/CoroutineRuntime.php). - -`RuntimeContext` is metadata/capabilities, not an event loop or asynchronous filesystem service. `RequestContext` supplies cancellation/deadlines and single-use lifecycle state. `CoroutineScope` supplies the active scheduler scope, task cancellation and `yieldNow()`. - -| ArrayKit work | Potential benefit | Recommendation | -| --- | --- | --- | -| Long factory-backed lazy traversal | Request/task cancellation and bounded cooperative yielding improve fairness and stop obsolete work. | Implement consumer-opt-in instance integration and verify under representative concurrent host load. | -| Short config reads and array helpers | Capability inspection/checkpoint overhead can exceed the work. | Preserve the direct common path. | -| Dotenv parsing, config `include`, filesystem cache writes | Context flags do not make these native operations non-blocking. | Perform during bootstrap/build/admin stages; no asynchronous-I/O claim. | -| Cache warm-up | Cancellation between namespaces may help administrative tasks. | Optional; do not yield while holding the exclusive filesystem lock or expose partial generations. | -| Parallel array callbacks | Would change order, callback side effects, ownership and failure behavior. | No automatic worker/task spawning. | - -The audit prototype used the exact 2.1.1 source with existing `LazyCollection::fromFactory()`. The host created and drove `CoroutineRuntime::runRequest()`; an intermediary forwarded the same context/request/scope instances into the factory. It produced `[2,4,6,8,10]`, let a peer task progress at checkpoints, propagated cancellation, rejected completed requests, and produced ordinary results without binding/coroutine capability. This proves API feasibility only; no speedup, full runtime-driver compatibility or production safety certification is claimed. - -### Required integration contract - -1. Accept the host's passed `RuntimeContext`, optional active `RequestContext`, and optional active `CoroutineScope` on the relevant operation/instance. A scope may be used for background tasks without a request context. The framework or an intermediate library forwards the same concrete instances; ArrayKit does not reconstruct them or discover a global runtime. -2. Favor an immutable operation/stream wrapper or explicit per-operation parameters. Do not attach a request token or scope to a shared cached facade proxy or worker-global config instance. A new operation must not inherit an earlier request's binding accidentally. -3. Honor both request and task cancellation/deadlines; reject a completed request or incompatible runtime binding. Missing capability is a normal fallback; cancellation, expired deadlines and invalid lifecycle state are terminal conditions, not reasons to restart through the normal path. -4. Enable cooperative yielding only when a passed active scope exists and the runtime advertises the relevant coroutine capability. Flags alone cannot authorize scheduler calls. Make checkpoint cadence bounded and configurable once at the boundary, after measuring it. -5. Check upstream consumption, including rows rejected by filters, and check again after resumption before invoking the next callback. Preserve ordering, keys, exceptions, backpressure and `take(0)` laziness. R07 must prevent a cancelled replay stream from later appearing successfully truncated. -6. The host owns loop driving, workers, listener/process creation, scope lifetime, cancellation sources and request completion. ArrayKit must not call `Runtime::run()`, `CoroutineRuntime::run()`/`runRequest()`/`attachRequest()`, `RequestContext::complete()`, or close the passed scope. It must not create a runtime simply because Runwire is installed. -7. Use the existing synchronous path when Runwire is absent or no relevant binding/capability is passed. No extra runtime work at Composer include time. Ship direct binding with a stable optional `suggest` relationship and isolated development integration tests against **2.1.1**, with a clean production install that has no Runwire package. Runwire requires PHP 8.4+ on 64-bit PHP; keep the ordinary ArrayKit path's existing platform support. -8. Publish direct framework -> ArrayKit and framework -> intermediary -> ArrayKit examples plus cleanup/fallback semantics. Do not introduce an adapter hierarchy or a new execution-context DTO when the upstream objects already express the boundary. - -Acceptance matrix: Runwire absent; installed/unbound; metadata-only; bound request without scope; task scope without request; valid concurrent scope; missing coroutine capability; host-native loop ownership; pre-cancelled/expired/cancel-during-traversal; mismatched runtime; completed request; closed scope; failing source/callback; two interleaved requests; nested intermediary forwarding; early iterator abandonment; worker replacement. Verify no leaked callbacks, request tokens, scope references or replay state. Actual host-driver tests remain necessary; the native probe does not certify Swoole/OpenSwoole/RoadRunner/FrankenPHP integration. - -Implement the binding at `LazyCollection`'s existing iteration owner, with an additive immutable instance method that accepts the upstream context/request/scope objects and returns a bound collection. Forward the binding through derived lazy operations so checkpoints cover upstream consumption, including filtered-out items. Finalize method signatures and checkpoint defaults against the existing generics and measured workloads before freezing the 5.3.0 API. Keep static array helpers and cached facade proxies free of request bindings. - -Compare the direct binding against the existing factory-composition prototype for correctness, consumer complexity, fairness, cancellation and overhead. If a candidate design fails a gate, revise it within the 5.3.0 scope; do not split the release or claim acceptance from prototype feasibility alone. - -## Implementation tracker - -| Batch | Status | Evidence | -| --- | --- | --- | -| A — R01/R02/R03/R07 | Complete | Expanded regressions pass; PHP 8.4/8.5 analysis, clean install, and all stable/lowest QA jobs are green in workflow run #89. | -| B — R04/R05/R06/R08 | Complete | Batch B regressions pass; PHP 8.4/8.5 analysis, stable/lowest QA, and clean install are green in workflow run #116. Benchmark/security tail jobs were cancelled by newer branch pushes, not failures. | -| C — R09/R10/R11/R12 + I03 | Complete | Threshold/native-semantics, wildcard presence, SQL-like matching, overflow pagination, and wrapper regressions pass across PHP 8.4/8.5 stable/lowest QA in workflow run #165. | -| D — I01/I02/I04/I05 | Complete | Array replay specialization and benchmarks, bounded DTO graph APIs/regressions, lifecycle/trust docs, and the dev-only PHPBench/Doctrine upstream maintenance outcome pass the PHPForge QA matrix in workflow run #165. | -| E — Runwire 2.1.1 integration | Complete | Optional exact Runwire 2.1.1 binding, absence/unbound fallback, lifecycle/cancellation/capability matrix, derived propagation/rebinding, intermediary forwarding, docs, and bound/unbound benchmark subjects pass the PHPForge QA matrix in workflow run #165. | -| F — integrated release acceptance | ArrayKit complete; external gates deferred | Final run #176 is green: PHP 8.4/8.5 analysis, stable/lowest QA, clean install, same-run 5.2.0/candidate representative benchmark generation/contract validation, Runwire FPM/FrankenPHP/RoadRunner/Swoole capability acceptance, and persistent-worker soak. Batch F tests: 5 passed / 1,496 assertions. Worker soak: 23.01s, 20 samples, 75.91 MiB initial/peak, 0.00 MiB growth. The GitHub-hosted runner correctly leaves the <=2% stable-environment comparison skipped; downstream Foundation acceptance and stable-runner performance gating are intentionally deferred to the next phase. | - -## Implementation sequence - -| Batch | Scope | Completion evidence | -| --- | --- | --- | -| A | Add failing regression cases for R01/R02/R03/R07, then fix those existing owners. | Guard budgets actually stop work; config state is coherent; reference calls mutate caller data; stream errors remain errors. | -| B | R04/R05/R06/R08 cache/memo/export corrections; update config and deployment docs together. | Cache transitions/refresh/round-trip correctness, valid artifact publication and concurrency/failure tests. | -| C | R09/R10/R11/R12 semantic boundary corrections and I03 duplicate consolidation. | Native comparison equivalence, presence semantics, whole-string matching and overflow-safe pagination; all wrappers covered. | -| D | I01 replay-memory measurement/improvement, I02 bounded DTO graph entry points, I04 dependency assessment, and I05 ownership/migration docs. | Compatible APIs, bounded graph behavior, measured allocation evidence, all relevant call sites and explicit maintenance outcomes. | -| E | Additive Runwire instance binding and propagation through lazy operations; direct/intermediary consumers and fallback coverage. | The full instance-forwarding/lifecycle matrix passes; bound/unbound performance and host ownership are verified. | -| F | Integrated quality/compatibility/consumer/performance/soak acceptance, then the single 5.3.0 release candidate. | Every applicable release gate below passes on the same final committed SHA. | - -Do not run source-mutating tooling during this review-only stage. During implementation use PHPForge's routine flow: doctor/config checks; focused failing tests; smallest owner changes; sequential `composer ic:process`; detailed suite; final `composer ic:tests` or `composer ic:release:guard`. Review automatic changes and keep vendor untouched. Never raise thresholds, suppress findings, expand baselines, remove assertions, or skip required detectors to obtain a pass. - -## Release acceptance gates - -- [x] R01-R12 regressions fail on 5.2.0 and pass on the candidate; original valid-input contracts and existing tests remain intact. -- [x] I01-I05 have implementation/measurement/documentation evidence or the explicitly allowed upstream maintenance outcome; bounded DTO APIs and replay behavior are verified without weakening existing contracts. -- [x] Full PHPForge flow passes with current rules and configured scopes; audit warning is recorded with its development-only origin. -- [x] PHP 8.4 and 8.5 stable/lowest dependency CI and clean `--no-dev` installation pass on the final committed revision. Run compatibility/deprecation checks against the next intended PHP target and identify unavailable target evidence explicitly. -- [x] Generated config fixtures are validated before activation; old-cache rebuild instructions and OPcache/worker restart behavior are verified. No secret values appear in failures, logs or benchmark results. -- [ ] Direct consumer smoke plus a representative intermediary consumer (for example Foundation's layered-config usage) pass for cold reads, runtime writes, snapshot/restore, cache refresh and persistent execution. Do not claim a consumer test from source inspection alone. - - Deferred intentionally until the next Foundation phase. ArrayKit's direct host-capability and persistent-worker tests are complete; this checkbox remains open until a real downstream consumer run passes. -- [ ] Capture a baseline and candidate on the same stable production-equivalent runner: PHP/extensions, no-dev optimized Composer mode, enabled production OPcache, OS/hardware, datasets, traffic mix, concurrency and source SHAs recorded. -- [ ] Use at least three warmed steady-state trials at multiple concurrency levels; measure cold startup separately. Cover repeated config reads, first namespace materialization, generated exact/structural reads, array/set/query operations at threshold boundaries, bounded adversarial traversal, and lazy streaming within a representative host request/task. -- [ ] Compare median **validated successful RPM** with a maximum 2% regression budget; reject invalid/partial/error responses from the numerator. Record p50/p95/p99, error/timeout rates, peak/steady memory, CPU, queue growth and relevant cache/lifecycle metadata. Predeclare workload-specific latency/memory limits from baseline and host capacity. Changes serving different correctness contracts require valid-output baselines, not timing of the existing broken behavior. - - Same-run 5.2.0/candidate documents are generated and validated on GitHub Actions with three warmed trials at concurrency 1/2/4. The <=2% comparison is intentionally not asserted there because the hosted runner is not a stable benchmark environment. -- [x] Persistent-worker soak has bounded memory, state reset and no cross-request/tenant data leakage; include cache-miss/failure and worker replacement. Keep mutable config/hooks and replay streams within their intended lifetime. -- [x] Runwire instance binding and derived-operation propagation pass the complete ArrayKit-owned acceptance matrix, including absence, unavailable capabilities, FPM/FrankenPHP/RoadRunner/Swoole capability contexts and direct forwarding; the host retains ownership of workers, event loops and scope completion. Intermediary-library forwarding remains part of the deferred Foundation phase. -- [ ] Separately report ordinary unbound regression and bound fairness/cancellation results, including a consumer that forwards instances through another library. Do not extrapolate microbenchmarks into host RPM. -- [ ] Configure PHPForge's existing representative benchmark result/baseline inputs and validate/compare their machine-readable contracts; do not invent a parallel workflow or treat the current skipped comparisons as passed. - - Inputs are now configured and both result contracts validate in CI. The comparator runs but reports `skipped` on GitHub-hosted runners by design; rerun with `ARRAYKIT_BENCHMARK_STABLE=1` only on a controlled stable runner. -- [ ] Record the final **5.3.0** candidate SHA and successful CI URL; review consolidated release notes and migration guidance covering fixes, additive APIs, Runwire optional usage and generated-cache rebuilds. Tagging and publishing remain separate actions after acceptance. - - Latest fully successful ArrayKit-owned acceptance before this tracker-only sync: `04cf58dd0f4e14406e8a25f3017952226fb8ccf3`, workflow run #175 (`37280759990`). This tracker sync becomes the next candidate SHA and must retain green CI before release. - -## Reproduction index - -Temporary PHP probes and an extracted copy of the exact Runwire 2.1.1 source were used, then removed after review. The triggers above and examples below preserve the reproductions. Raw local check logs remain at `/tmp/arraykit-quality-review.log`, `/tmp/arraykit-final-quality-review.log`, and `/tmp/arraykit-benchmark-review.log`; these are temporary evidence, not release artifacts. - -Example: traversal, facade, lazy failure and presence. - -```php - range(1, 10000)], 'rows.*', maxNodes: 2, throwOnTooDeep: true))); - -// R03: 5.2.0 leaves the original value unchanged. -$data = ['app' => ['name' => 'old']]; -ArrayKit::dot()->set($data, 'app.name', 'new'); -var_dump($data); - -// R07: 5.2.0 throws on the first traversal, returns [1] on the second. -$stream = LazyCollection::from((function () { - yield 1; - throw new RuntimeException('source failure'); -})()); -foreach ([1, 2] as $attempt) { - try { - var_dump($stream->all()); - } catch (RuntimeException $error) { - echo $error->getMessage(), PHP_EOL; - } -} - -// R10: 5.2.0 returns false for a present empty array. -var_dump(DotNotation::matches(['rows' => [['value' => []]]], 'rows.*.value')); -``` - -Example: layered mutation and snapshot state. Supply a temporary source directory containing `app.php` that returns `['name' => 'source']`. - -```php -set('app.name', 'caller'); -var_dump($config->get('app.name')); // 5.2.0: 'source' - -$config = new LayeredLazyFileConfig($directory, namespaces: ['app']); -$config->snapshot(); -$config->get('app.name'); -$config->restore(); -var_dump($config->get('app.name', 'missing')); // 5.2.0: 'missing' -``` diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index 0746772..fa02073 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -358,6 +358,7 @@ 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. - ``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 e6fe12c..d46acce 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -273,8 +273,10 @@ LazyCollection -------------- Use ``LazyCollection`` for generator-backed transformations over large iterables. -Array sources passed to ``from()`` are replayed directly without allocating a -per-entry replay memo. One-shot iterators and generators replay values already +Array sources without top-level PHP references are replayed directly without +allocating a per-entry replay memo. Reference-bearing arrays use the same +consumed-value memo as one-shot iterators: unread references remain live, while +values already consumed are replayed. One-shot iterators and generators replay values already read without eagerly materializing the remaining source; their replay cache grows with the portion consumed. Use ``fromFactory()`` for long-lived or unbounded renewable sources when each traversal can create a fresh iterable. @@ -341,12 +343,16 @@ filtered-out rows are covered too. ``checkpointEvery`` is an item-consumption interval from 1 through 1,000,000. A completed request or a request belonging to another ``RuntimeContext`` is -rejected at the binding boundary. Cancellation, expired deadlines, a closed +rejected at the binding boundary. Completion after binding is checked again +before consumption and at checkpoints, including after cooperative resumption. +Cancellation, expired deadlines, a closed active scope, source errors, and callback errors remain terminal exceptions. ArrayKit never creates or drives a Runwire runtime/event loop, completes a request, closes a scope, or stores these bindings globally. Missing coroutine -capability disables cooperative yielding; synchronous traversal and applicable +capability disables cooperative yielding; a public, read-only task-local lookup +checks the scope's lifecycle without creating work or modifying task-local state. +Synchronous traversal and applicable cancellation checks remain available. ``take(0)`` stays fully lazy. An intermediary library should forward the exact host-owned instances instead diff --git a/docs/facade.rst b/docs/facade.rst index de2d75f..0b57485 100644 --- a/docs/facade.rst +++ b/docs/facade.rst @@ -55,6 +55,7 @@ Behavior Notes - ``env()`` reads the current runtime environment. - ``dotenv()`` exposes the ``.env`` file parser. - Proxy calls map directly to target static methods. +- Reference mutations accept native named arguments: ``dot()->forget(target: $data, keys: 'path')`` and ``helper()->forget(array: $data, keys: 'key')``. Dot callers may also use the proxy's existing ``array:`` argument; supplying both aliases is rejected. - Calling a missing method via proxy throws ``BadMethodCallException``. Related Guides diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 69bed38..70ce487 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -73,10 +73,11 @@ Cache behavior: - ``warmNamespaceCache()`` builds a complete hidden generation and atomically switches ``.arraykit-generation`` only after publication succeeds. - Each generation contains one file per cached namespace plus ``.arraykit-flat.php`` for exact scalar/null leaves. - Exact-key scalar reads may use the flat index without materializing the namespace. -- Structural, wildcard, and namespace reads use the active immutable namespace file. +- 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. Existing readers may continue using the previous immutable 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. +- 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/docs/lifecycle.rst b/docs/lifecycle.rst index 7addbb3..f3a1770 100644 --- a/docs/lifecycle.rst +++ b/docs/lifecycle.rst @@ -21,8 +21,9 @@ Ownership Model normal request handling. ``LazyCollection`` - Array sources are replayed directly from the captured array and do not - allocate a per-entry replay memo. One-shot iterators/generators retain the + Array sources without top-level PHP references are replayed directly from + the captured array and do not allocate a per-entry replay memo. Reference + arrays and one-shot iterators/generators retain the consumed prefix for repeatable traversal and preserve a terminal source failure at its stream boundary. That replay state lives as long as the collection. Use ``fromFactory()`` when a fresh source can be created per @@ -65,9 +66,12 @@ new build fails. The flat leaf index is internal metadata named On upgrade to 5.3, rebuild generated lazy-config artifacts instead of copying old ``__flat.php`` metadata forward. Old generation directories are disposable -after they are no longer active. If OPcache is used for generated PHP cache -files, deployment tooling remains responsible for its normal invalidation or -restart policy. +after all readers pinned to them have finished, including readers in other +workers. If OPcache is used for generated PHP cache files, deployment tooling +remains responsible for its normal invalidation or restart policy. PHP also +retains names of included files for the process lifetime; frequent publication +of unique generations requires a bounded deployment cadence and host-managed +worker replacement. Persistent Worker Guidance -------------------------- diff --git a/docs/release-5.3.0.rst b/docs/release-5.3.0.rst index 259024f..2dc9c78 100644 --- a/docs/release-5.3.0.rst +++ b/docs/release-5.3.0.rst @@ -59,6 +59,12 @@ Configuration and Cache Lifecycle Resolved read memoization is invalidated when configuration/cache sources change. Generated namespace warm-up reads the authoritative source unless the caller explicitly supplied or mutated that namespace in memory. +Partial merges retain the source/cache origin of untouched namespaces. + +Each reader pins one cache generation for exact and structural lookups. +``namespaceCache()`` explicitly refreshes that selection while preserving +intentional runtime overrides. Writers independently use the latest published +generation when copying namespaces that are not being rebuilt. Namespace caches are built as immutable generations and activated only after a successful build. A failed build leaves the previous valid generation active. @@ -80,6 +86,10 @@ be large, recursive, or influenced by external input. They enforce one shared depth/node budget per call and reject active-path object/array cycles while allowing shared acyclic objects. Existing ``hydrateNested()`` and ``toArrayDeep()`` remain available for trusted, already-bounded graphs. +Guarded export walks and produces its output in one bounded pass. Custom +``toArray()`` / ``toArrayDeep()`` implementations are rejected before invocation +by the guarded API; ordinary export continues to support them. Application +property getters and callbacks remain trusted application code. Optional Runwire Integration ---------------------------- @@ -92,6 +102,8 @@ and optional ``RequestContext`` / ``CoroutineScope``. Cancellation is checked at the traversal boundary and at the configured item cadence. Cooperative ``yieldNow()`` calls are made only when an active scope is passed and the runtime advertises Runwire coroutine capability. +Completed requests and closed scopes are rejected at traversal checkpoints, +including after cooperative resumption and when yielding is unavailable. Bindings propagate through derived lazy operations and can be explicitly rebound for a new request. ArrayKit never starts/stops a runtime or event loop, @@ -108,3 +120,17 @@ contract. See :doc:`migration`, :doc:`lazy-config`, :doc:`collection`, :doc:`traits-and-helpers`, and :doc:`lifecycle` for operational details. + +Final Review Corrections +------------------------ + +- Missing safe dot lookups consume the shared node budget. Throwing multi-key + lookup rejects unfinished work while accepting an exactly completed budget. +- Non-throwing guarded sort preserves ancestor ordering after traversal is + cut short, avoiding recursive comparisons of unvisited children. +- Runwire lifecycle, partial config origin tracking, generation consistency, + and guarded DTO output enforce the boundaries described above. +- Facade ``forget()`` accepts native named reference arguments: + ``target:`` for the dot module and ``array:`` for the helper module. +- Reference-bearing lazy arrays preserve memoized consumed scalar values; + unconsumed entries remain lazy. diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index ab14860..e75b299 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -151,7 +151,7 @@ Facade ModuleProxy public function __call(string $method, array $arguments): mixed public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool public function fill(array &$array, array|string $keys, mixed $value = null): void - public function forget(array &$array, array|string|int|null $keys): void + public function forget(?array &$array = null, array|string|int|null $keys = null, ?array &$target = null): void public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool public function move(array &$array, string $from, string $to, bool $overwrite = true): bool public function offsetSet(array &$array, string $key, mixed $value): void diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index f15f90a..59157d7 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -64,11 +64,19 @@ Bounded DTO Graphs ~~~~~~~~~~~~~~~~~~ Use the guarded entry points when nested DTO or array graphs can be large, -recursive, or influenced by external input. The complete graph is validated -before hydration or deep export. ``maxDepth`` and ``maxNodes`` are shared +recursive, or influenced by external input. Hydration validates the input graph +before mutation. Deep export builds the actual output in one bounded traversal +of standard ``DTOTrait`` public properties. ``maxDepth`` and ``maxNodes`` are shared across the whole call; both must be positive. Cyclic array references, cyclic public object references, or a limit breach raise ``RuntimeException``. +Guarded export rejects custom ``toArray()`` / ``toArrayDeep()`` implementations +with ``InvalidArgumentException`` before invoking them, because their arbitrary +work and generated output cannot be bounded by the public-property budget. +Property getters are read once during export; their own execution must be +trusted and bounded by the host. Custom serializers remain supported by the +ordinary ``toArrayDeep()`` API. + Shared acyclic objects are valid and may appear in more than one branch. The ordinary ``hydrateNested()`` and ``toArrayDeep()`` contracts are unchanged and remain the lower-overhead choice for trusted, already-bounded graphs. diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index e4af091..b876f4c 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -485,6 +485,7 @@ public static function sortRecursiveGuarded( bool $throwOnTooDeep = false, ): array { $visitedNodes = 0; + $complete = true; return self::sortRecursiveWithGuards( $array, @@ -495,6 +496,7 @@ public static function sortRecursiveGuarded( $maxDepth, $maxNodes, $throwOnTooDeep, + $complete, ); } @@ -1238,6 +1240,7 @@ private static function sortRecursiveWithGuards( int $maxDepth, int $maxNodes, bool $throwOnTooDeep, + bool &$complete, ): array { if (!self::reserveSortNodes( $array, @@ -1247,6 +1250,8 @@ private static function sortRecursiveWithGuards( $maxNodes, $throwOnTooDeep, )) { + $complete = false; + return $array; } @@ -1264,7 +1269,13 @@ private static function sortRecursiveWithGuards( $maxDepth, $maxNodes, $throwOnTooDeep, + $complete, ); + if (!$complete) { + unset($value); + + return $array; + } } unset($value); diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index c8796df..1f6b536 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -166,6 +166,10 @@ public static function getSafe( if (is_array($keys)) { $results = []; foreach ($keys as $k) { + if (!self::canResolveSafeKey($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + $resolvedKey = (string) $k; $results[$resolvedKey] = self::getValueSafe( $array, @@ -176,10 +180,6 @@ public static function getSafe( $throwOnTooDeep, $visitedNodes, ); - - if ($maxNodes > 0 && $visitedNodes >= $maxNodes) { - break; - } } return $results; diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index bcd9df8..03d3081 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -10,6 +10,19 @@ class DotNotation { use DotNotationPublicApiTrait; + private static function canResolveSafeKey(int $visitedNodes, int $maxNodes, bool $throwOnTooDeep): bool + { + if ($maxNodes <= 0 || $visitedNodes < $maxNodes) { + return true; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return false; + } + private static function escapePathSegment(string $segment): string { return DotNotationPathOps::escapePathSegment($segment); @@ -103,7 +116,7 @@ private static function getValueSafe( bool $throwOnTooDeep, int &$visitedNodes, ): mixed { - if (self::isDirectKey($key) && is_array($target) && ArraySingle::exists($target, $key)) { + if (self::isDirectKey($key)) { $visitedNodes++; if ($maxNodes > 0 && $visitedNodes > $maxNodes) { if ($throwOnTooDeep) { @@ -113,7 +126,9 @@ private static function getValueSafe( return self::value($default); } - return $target[$key]; + return is_array($target) && ArraySingle::exists($target, $key) + ? $target[$key] + : self::value($default); } $keyPath = (string) $key; diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index c4e4bef..b3b1b25 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -37,7 +37,7 @@ private function __construct( */ public static function from(iterable $source): self { - if (is_array($source)) { + if (is_array($source) && !self::hasReferencedEntries($source)) { return new self(static function (?RunwireLazyBinding $binding) use ($source): array { unset($binding); @@ -273,6 +273,12 @@ private static function fromTraversable(Traversable $source): self return new self(self::replayableFactory($source)); } + /** @param array $source */ + private static function hasReferencedEntries(array $source): bool + { + return array_any(array_keys($source), fn($key) => \ReflectionReference::fromArrayElement($source, $key) !== null); + } + /** * Adapt any iterable into a repeatable lazy source without eagerly * materializing it. Values already consumed from a one-shot iterator are diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php index f8dcd23..19219ed 100644 --- a/src/Collection/RunwireLazyBinding.php +++ b/src/Collection/RunwireLazyBinding.php @@ -6,6 +6,7 @@ use Infocyph\Runwire\CancellationToken; use Infocyph\Runwire\Coroutine\CoroutineScope; +use Infocyph\Runwire\Coroutine\TaskLocal; use Infocyph\Runwire\RequestContext; use Infocyph\Runwire\Runtime\Enum\RuntimeCapability; use Infocyph\Runwire\RuntimeContext; @@ -21,11 +22,13 @@ private ?CancellationToken $scopeCancellation; + private ?TaskLocal $scopeCheckKey; + private bool $yieldEnabled; public function __construct( public RuntimeContext $runtime, - ?RequestContext $request = null, + private ?RequestContext $request = null, public ?CoroutineScope $scope = null, public int $checkpointEvery = 256, ) { @@ -47,12 +50,14 @@ public function __construct( $this->requestCancellation = $request?->cancellation; $this->scopeCancellation = $scope?->cancellation(); + $this->scopeCheckKey = $scope === null ? null : new TaskLocal(); $this->yieldEnabled = $scope !== null && $runtime->supports(RuntimeCapability::RUNWIRE_COROUTINES); } public function checkpoint(): void { + $this->assertActive(); $this->requestCancellation?->throwIfCancelled(); $this->scopeCancellation?->throwIfCancelled(); @@ -60,7 +65,20 @@ public function checkpoint(): void $this->scope?->yieldNow(); } + $this->assertActive(); $this->requestCancellation?->throwIfCancelled(); $this->scopeCancellation?->throwIfCancelled(); } + + private function assertActive(): void + { + if ($this->request?->completed()) { + throw new LogicException('Completed Runwire request context cannot be traversed.'); + } + + if ($this->scope !== null && $this->scopeCheckKey !== null) { + // Runwire 2.1.1 guards this read without yielding or changing task-local state. + $this->scope->hasLocal($this->scopeCheckKey); + } + } } diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 2c4e039..cf0b54b 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -29,6 +29,10 @@ trait LazyFileConfigCacheTrait protected ?string $namespaceCacheDirectory = null; + private bool $namespaceCachePinned = false; + + private ?string $pinnedNamespaceCacheDirectory = null; + /** * @param string|array|null $namespaces */ @@ -108,16 +112,7 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static protected function cachedNamespacePath(string $namespace): ?string { - $directory = $this->activeNamespaceCacheDirectory(); - if ($directory === null) { - return null; - } - - if ($directory === $this->namespaceCacheDirectory && $namespace === '__flat') { - return null; - } - - return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + return $this->namespaceCachePath($namespace, $this->readerNamespaceCacheDirectory()); } /** @@ -174,7 +169,7 @@ protected function discoverNamespaces(): array protected function flatLeafIndexPath(): ?string { - $directory = $this->activeNamespaceCacheDirectory(); + $directory = $this->readerNamespaceCacheDirectory(); if ($directory === null) { return null; } @@ -193,6 +188,8 @@ protected function flatLeafValue(string $path): mixed protected function invalidateGeneratedNamespaceState(): void { + $this->namespaceCachePinned = false; + $this->pinnedNamespaceCacheDirectory = null; foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { if ($origin === 'cache') { unset($this->items[$namespace]); @@ -273,7 +270,7 @@ protected function namespaceCacheWarmValue(string $namespace): array return $value; } - $cachedFile = $this->resolveCachedNamespaceFile($namespace); + $cachedFile = $this->warmNamespaceCacheFile($namespace); if ($cachedFile !== null) { $value = include $cachedFile; if (!is_array($value)) { @@ -498,6 +495,19 @@ private function namespaceCacheEntries(string $directory, bool $legacy): array return $resolved; } + private function namespaceCachePath(string $namespace, ?string $directory): ?string + { + if ($directory === null) { + return null; + } + + if ($directory === $this->namespaceCacheDirectory && $namespace === '__flat') { + return null; + } + + return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + } + /** * @param string[]|null $namespaces */ @@ -555,6 +565,16 @@ private function publishWarmGeneration(array $namespaces): void } } + private function readerNamespaceCacheDirectory(): ?string + { + if (!$this->namespaceCachePinned) { + $this->pinnedNamespaceCacheDirectory = $this->activeNamespaceCacheDirectory(); + $this->namespaceCachePinned = true; + } + + return $this->pinnedNamespaceCacheDirectory; + } + private function removeGenerationDirectory(string $directory): void { foreach (scandir($directory) ?: [] as $entry) { @@ -571,6 +591,13 @@ private function removeGenerationDirectory(string $directory): void rmdir($directory); } + private function warmNamespaceCacheFile(string $namespace): ?string + { + $path = $this->namespaceCachePath($namespace, $this->activeNamespaceCacheDirectory()); + + return $path !== null && is_file($path) && is_readable($path) ? $path : null; + } + private function withNamespaceCacheLock(\Closure $operation): static { $directory = $this->namespaceCacheDirectory; diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index b6a1efc..c9115ae 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -198,7 +198,16 @@ public function loadFile(string $path): bool */ public function merge(array $items): bool { - return $this->syncLoadedNamespacesAfter(parent::merge($items)); + $changed = parent::merge($items); + if ($changed) { + foreach (array_keys($items) as $namespace) { + if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { + $this->markNamespaceRuntime($namespace); + } + } + } + + return $changed; } /** diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php index 312576a..dc9f415 100644 --- a/src/DTO/Concerns/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -148,9 +148,7 @@ public function toArrayDeep(): array */ public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array { - DTOGraphGuard::assertWithinLimits($this, $maxDepth, $maxNodes); - - return $this->toArrayDeep(); + return DTOGraphGuard::export($this, $maxDepth, $maxNodes); } private function assignProperty(string $property, mixed $value, bool $coerce): void diff --git a/src/DTO/DTOGraphGuard.php b/src/DTO/DTOGraphGuard.php index ab78f03..ad33e87 100644 --- a/src/DTO/DTOGraphGuard.php +++ b/src/DTO/DTOGraphGuard.php @@ -4,7 +4,9 @@ namespace Infocyph\ArrayKit\DTO; +use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; use InvalidArgumentException; +use ReflectionMethod; use ReflectionObject; use ReflectionProperty; use ReflectionReference; @@ -15,6 +17,57 @@ final class DTOGraphGuard { public static function assertWithinLimits(mixed $value, int $maxDepth, int $maxNodes): void + { + self::traverse($value, $maxDepth, $maxNodes, false); + } + + /** @return array */ + public static function export(object $value, int $maxDepth, int $maxNodes): array + { + $result = self::traverse($value, $maxDepth, $maxNodes, true); + if (!is_array($result)) { + throw new InvalidArgumentException('Guarded export requires the standard DTO exporter.'); + } + + return $result; + } + + private static function assertNodeWithinLimits( + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + ): void { + if ($depth > $maxDepth) { + throw new RuntimeException('DTO graph traversal exceeded max depth.'); + } + + $visitedNodes++; + if ($visitedNodes > $maxNodes) { + throw new RuntimeException('DTO graph traversal exceeded max node count.'); + } + } + + private static function isStandardExporter(object $value): bool + { + if (!is_callable([$value, 'toArrayDeep']) && !is_callable([$value, 'toArray'])) { + return false; + } + + foreach (['toArray', 'toArrayDeep'] as $method) { + if ( + !method_exists($value, $method) + || new ReflectionMethod($value, $method)->getFileName() + !== new ReflectionMethod(DTOTrait::class, $method)->getFileName() + ) { + throw new InvalidArgumentException('Guarded DTO export does not execute custom exporters.'); + } + } + + return true; + } + + private static function traverse(mixed $value, int $maxDepth, int $maxNodes, bool $export): mixed { if ($maxDepth < 1) { throw new InvalidArgumentException('DTO graph max depth must be at least 1.'); @@ -28,7 +81,7 @@ public static function assertWithinLimits(mixed $value, int $maxDepth, int $maxN $activeReferences = []; $visitedNodes = 0; - self::walk( + return self::walk( $value, 1, $visitedNodes, @@ -36,25 +89,10 @@ public static function assertWithinLimits(mixed $value, int $maxDepth, int $maxN $maxNodes, $activeObjects, $activeReferences, + $export, ); } - private static function assertNodeWithinLimits( - int $depth, - int &$visitedNodes, - int $maxDepth, - int $maxNodes, - ): void { - if ($depth > $maxDepth) { - throw new RuntimeException('DTO graph traversal exceeded max depth.'); - } - - $visitedNodes++; - if ($visitedNodes > $maxNodes) { - throw new RuntimeException('DTO graph traversal exceeded max node count.'); - } - } - /** * @param array $activeObjects * @param array $activeReferences @@ -67,11 +105,12 @@ private static function walk( int $maxNodes, array &$activeObjects, array &$activeReferences, - ): void { + bool $export, + ): mixed { self::assertNodeWithinLimits($depth, $visitedNodes, $maxDepth, $maxNodes); if (is_array($value)) { - self::walkArray( + return self::walkArray( $value, $depth, $visitedNodes, @@ -79,13 +118,12 @@ private static function walk( $maxNodes, $activeObjects, $activeReferences, + $export, ); - - return; } if (is_object($value) && !$value instanceof UnitEnum) { - self::walkObject( + return self::walkObject( $value, $depth, $visitedNodes, @@ -93,8 +131,11 @@ private static function walk( $maxNodes, $activeObjects, $activeReferences, + $export, ); } + + return $value; } /** @@ -110,11 +151,13 @@ private static function walkArray( int $maxNodes, array &$activeObjects, array &$activeReferences, - ): void { + bool $export, + ): mixed { + $result = []; foreach ($value as $key => $entry) { $reference = ReflectionReference::fromArrayElement($value, $key); if ($reference === null) { - self::walk( + $resolved = self::walk( $entry, $depth + 1, $visitedNodes, @@ -122,7 +165,11 @@ private static function walkArray( $maxNodes, $activeObjects, $activeReferences, + $export, ); + if ($export) { + $result[$key] = $resolved; + } continue; } @@ -135,7 +182,7 @@ private static function walkArray( $activeReferences[$referenceId] = true; try { - self::walk( + $resolved = self::walk( $entry, $depth + 1, $visitedNodes, @@ -143,11 +190,17 @@ private static function walkArray( $maxNodes, $activeObjects, $activeReferences, + $export, ); + if ($export) { + $result[$key] = $resolved; + } } finally { unset($activeReferences[$referenceId]); } } + + return $export ? $result : $value; } /** @@ -162,12 +215,15 @@ private static function walkObject( int $maxNodes, array &$activeObjects, array &$activeReferences, - ): void { + bool $export, + ): mixed { $objectId = spl_object_id($value); if (isset($activeObjects[$objectId])) { throw new RuntimeException('DTO graph contains a cyclic object reference.'); } + $exportObject = $export && self::isStandardExporter($value); + $result = []; $activeObjects[$objectId] = true; try { @@ -176,7 +232,7 @@ private static function walkObject( continue; } - self::walk( + $resolved = self::walk( $property->getValue($value), $depth + 1, $visitedNodes, @@ -184,10 +240,16 @@ private static function walkObject( $maxNodes, $activeObjects, $activeReferences, + $exportObject, ); + if ($exportObject) { + $result[$property->getName()] = $resolved; + } } } finally { unset($activeObjects[$objectId]); } + + return $exportObject ? $result : $value; } } diff --git a/src/Facade/ModuleProxy.php b/src/Facade/ModuleProxy.php index 2a29378..01ffec4 100644 --- a/src/Facade/ModuleProxy.php +++ b/src/Facade/ModuleProxy.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\Facade; use BadMethodCallException; +use Infocyph\ArrayKit\Array\DotNotation; use UnexpectedValueException; final readonly class ModuleProxy @@ -34,11 +35,25 @@ public function fill(array &$array, array|string $keys, mixed $value = null): vo } /** - * @param array $array + * @param array|null $array + * @param-out array $array * @param array|int|string|null $keys + * @param array|null $target Native dot-module named argument */ - public function forget(array &$array, array|string|int|null $keys): void + public function forget(?array &$array = null, array|string|int|null $keys = null, ?array &$target = null): void { + if ($target !== null) { + if ($this->targetClass !== DotNotation::class || $array !== null) { + throw new \InvalidArgumentException('The target argument requires the dot module and no array argument.'); + } + + $array = &$target; + } + + if ($array === null) { + throw new \ArgumentCountError('An array or dot target is required for forget().'); + } + $this->invoke('forget', [&$array, $keys]); } diff --git a/tests/Feature/Release530ReviewRegressionTest.php b/tests/Feature/Release530ReviewRegressionTest.php new file mode 100644 index 0000000..17f2ac5 --- /dev/null +++ b/tests/Feature/Release530ReviewRegressionTest.php @@ -0,0 +1,261 @@ +reviewDirectory = sys_get_temp_dir() . '/arraykit-review-fix-' . bin2hex(random_bytes(6)); + mkdir($this->reviewDirectory); + mkdir($this->reviewDirectory . '/source'); + mkdir($this->reviewDirectory . '/cache'); +}); + +afterEach(function () { + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->reviewDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($entries as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($this->reviewDirectory); +}); + +function review530Source(string $directory, int $version): void +{ + foreach (['app', 'db'] as $namespace) { + file_put_contents($directory . '/source/' . $namespace . '.php', " {$version}];\n"); + } +} + +function review530Config(string $directory): LazyFileConfig +{ + return new LazyFileConfig($directory . '/source', namespaceCacheDirectory: $directory . '/cache'); +} + +it('bounds missing direct lookups and default callbacks under the shared safe budget', function () { + $keys = array_map(static fn(int $index): string => 'missing-' . $index, range(1, 10000)); + $calls = 0; + $default = function () use (&$calls): string { + $calls++; + + return 'default'; + }; + expect(DotNotation::getSafe([], $keys, $default, maxNodes: 2))->toHaveCount(2) + ->and($calls)->toBe(2); + $calls = 0; + expect(fn () => DotNotation::getSafe([], $keys, $default, maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and($calls)->toBe(2); +}); + +it('rejects unfinished throwing lookups and accepts an exactly complete budget', function () { + expect(fn () => DotNotation::getSafe(['a' => 1, 'b' => 2], ['a', 'b'], maxNodes: 1, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(DotNotation::getSafe(['a' => 1, 'b' => 2], ['a', 'b'], maxNodes: 2, throwOnTooDeep: true)) + ->toBe(['a' => 1, 'b' => 2]); +}); + +it('does not compare cyclic children after guarded sort traversal is cut short', function (int $maxDepth, int $maxNodes) { + $left = []; + $left['self'] = &$left; + $left['tail'] = 2; + $right = []; + $right['self'] = &$right; + $right['tail'] = 1; + $result = ArrayMulti::sortRecursiveGuarded([$left, $right], maxDepth: $maxDepth, maxNodes: $maxNodes); + expect($result)->toHaveCount(2) + ->and($result[0]['tail'])->toBe(2) + ->and($result[1]['tail'])->toBe(1); +})->with([[1, 100], [256, 2]]); + +it('leaves wide unvisited children in order rather than comparing them outside the sort budget', function () { + $left = array_fill(0, 100000, 1); + $right = $left; + $right[99999] = 2; + $result = ArrayMulti::sortRecursiveGuarded([$right, $left], maxDepth: 1, maxNodes: 2); + expect($result[0][99999])->toBe(2) + ->and($result[1][99999])->toBe(1) + ->and(ArrayMulti::sortRecursiveGuarded([[3, 1], [2, 0]], maxNodes: 6)) + ->toBe(ArrayMulti::sortRecursive([[3, 1], [2, 0]])); +}); + +it('rejects host request completion after binding before calling the source', function () { + $runtime = RuntimeContext::standalone(); + $request = RequestContext::create($runtime); + $calls = 0; + $collection = LazyCollection::fromFactory(function () use (&$calls): array { + $calls++; + + return [1, 2]; + })->withRunwire($runtime, $request, checkpointEvery: 1); + $request->complete(); + expect(fn () => $collection->all())->toThrow(LogicException::class) + ->and($collection->take(0)->all())->toBe([]) + ->and($calls)->toBe(0); +}); + +it('stops at a checkpoint when the host completes a request during iteration', function () { + $runtime = RuntimeContext::standalone(); + $request = RequestContext::create($runtime); + $seen = []; + $collection = LazyCollection::from([1, 2, 3])->withRunwire($runtime, $request, checkpointEvery: 1) + ->mapLazy(function (int $value) use ($request, &$seen): int { + $seen[] = $value; + $request->complete(); + + return $value; + }); + expect(fn () => $collection->all())->toThrow(LogicException::class) + ->and($seen)->toBe([1]); +}); + +it('rejects a closed scope even when coroutine capability is unavailable', function () { + $scope = new CoroutineRuntime()->run(static fn(CoroutineScope $scope): CoroutineScope => $scope); + expect(fn () => LazyCollection::from([1, 2])->withRunwire(RuntimeContext::standalone(), scope: $scope)->all()) + ->toThrow(LogicException::class, 'Coroutine scope is already closed'); +}); + +it('rechecks request completion after a cooperative yield before consuming the source', function () { + $runtime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities(RuntimeDriver::NATIVE, supportsRunwireCoroutines: true), + 'yield-check', + ); + $request = RequestContext::create($runtime); + $calls = 0; + new CoroutineRuntime()->run(function (CoroutineScope $scope) use ($runtime, $request, &$calls): void { + $scope->spawn(static fn() => $request->complete()); + $collection = LazyCollection::fromFactory(function () use (&$calls): array { + $calls++; + + return [1]; + })->withRunwire($runtime, $request, $scope, checkpointEvery: 1); + expect(fn () => $collection->all())->toThrow(LogicException::class, 'Completed Runwire request'); + }); + expect($calls)->toBe(0)->and($request->completed())->toBeTrue(); +}); + +it('keeps untouched generated namespaces refreshable after partial config merges', function (string $operation) { + review530Source($this->reviewDirectory, 1); + review530Config($this->reviewDirectory)->warmNamespaceCache(['app', 'db']); + $config = review530Config($this->reviewDirectory); + expect($config->get('app'))->toBe(['version' => 1]); + if ($operation === 'mergeEnvFile') { + $envFile = $this->reviewDirectory . '/partial.env'; + file_put_contents($envFile, "UNRELATED_REVIEW_VALUE=yes\n"); + $config->mergeEnvFile($envFile); + } else { + $config->$operation(['other' => ['value' => true]]); + } + review530Source($this->reviewDirectory, 2); + $config->warmNamespaceCache('app'); + expect(review530Config($this->reviewDirectory)->get('app.version'))->toBe(2); +})->with(['merge', 'overlay', 'mergeEnvFile']); + +it('preserves a deliberate namespace override while refreshing untouched cached namespaces', function () { + review530Source($this->reviewDirectory, 1); + review530Config($this->reviewDirectory)->warmNamespaceCache(['app', 'db']); + $config = review530Config($this->reviewDirectory); + $config->get('db'); + $config->merge(['app' => ['version' => 99]]); + review530Source($this->reviewDirectory, 2); + $config->warmNamespaceCache(['app', 'db']); + $fresh = review530Config($this->reviewDirectory); + expect($fresh->get('app.version'))->toBe(99)->and($fresh->get('db.version'))->toBe(2); +}); + +it('pins exact and structural reads to one generation until explicit cache refresh', function () { + review530Source($this->reviewDirectory, 1); + $writer = review530Config($this->reviewDirectory); + $writer->warmNamespaceCache(['app', 'db']); + $reader = review530Config($this->reviewDirectory); + expect($reader->get('app.version'))->toBe(1); + review530Source($this->reviewDirectory, 2); + $writer->warmNamespaceCache(['app', 'db']); + expect($reader->get('db'))->toBe(['version' => 1]) + ->and($reader->get('app.version'))->toBe(1) + ->and(review530Config($this->reviewDirectory)->get('db'))->toBe(['version' => 2]); + $reader->namespaceCache($this->reviewDirectory . '/cache'); + expect($reader->get('app.version'))->toBe(2) + ->and($reader->get('db'))->toBe(['version' => 2]); +}); + +it('copies the current published generation when a pinned reader later becomes a writer', function () { + review530Source($this->reviewDirectory, 1); + $writer = review530Config($this->reviewDirectory); + $writer->warmNamespaceCache(['app', 'db']); + $writer->get('app'); + review530Source($this->reviewDirectory, 2); + review530Config($this->reviewDirectory)->warmNamespaceCache('db'); + $writer->warmNamespaceCache('app'); + $fresh = review530Config($this->reviewDirectory); + expect($fresh->get('app.version'))->toBe(2)->and($fresh->get('db.version'))->toBe(2); +}); + +it('rejects custom DTO exporters before executing their unbounded callbacks', function () { + $dto = new class extends DTO { + public mixed $payload; + }; + $exporter = new class { + private int $calls = 0; + + public function calls(): int { return $this->calls; } + + public function toArray(): array { + $this->calls++; + + return range(1, 10000); + } + }; + $dto->payload = $exporter; + expect(fn () => $dto->toArrayDeepGuarded(maxNodes: 2)) + ->toThrow(InvalidArgumentException::class, 'custom exporters') + ->and($exporter->calls())->toBe(0) + ->and($dto->toArrayDeep()['payload'])->toHaveCount(10000); +}); + +it('exports DTO properties only once under the actual output budget', function () { + $dto = new class extends DTO { + public int $reads = 0; + + public array $payload { + get => ++$this->reads === 1 ? [1] : range(1, 10000); + } + }; + expect($dto->toArrayDeepGuarded(maxNodes: 4)['payload'])->toBe([1]) + ->and($dto->reads)->toBe(1); +}); + +it('preserves native named arguments for both facade reference mutations', function () { + $dot = ['remove' => true, 'keep' => true]; + $helper = $dot; + ArrayKit::dot()->forget(target: $dot, keys: 'remove'); + ArrayKit::helper()->forget(array: $helper, keys: 'remove'); + expect($dot)->toBe(['keep' => true])->and($helper)->toBe($dot) + ->and(fn () => ArrayKit::dot()->forget())->toThrow(ArgumentCountError::class); +}); + +it('memoizes consumed referenced scalar entries while keeping unread entries lazy', function () { + $first = 1; + $second = 2; + $lazy = LazyCollection::from(['first' => &$first, 'second' => &$second]); + $first = 3; + expect($lazy->take(1)->all())->toBe(['first' => 3]); + $first = 4; + $second = 5; + expect($lazy->all())->toBe(['first' => 3, 'second' => 5]); + $second = 6; + expect($lazy->all())->toBe(['first' => 3, 'second' => 5]); +}); diff --git a/tests/Support/Release530PersistentWorkerSoak.php b/tests/Support/Release530PersistentWorkerSoak.php index f2acf9d..fa76f4c 100644 --- a/tests/Support/Release530PersistentWorkerSoak.php +++ b/tests/Support/Release530PersistentWorkerSoak.php @@ -22,6 +22,10 @@ final class Release530PersistentWorkerSoak { private bool $running = true; + private int $nextCacheRefreshNanoseconds = 0; + + private int $cacheRefreshes = 0; + private readonly string $cacheDirectory; private readonly RuntimeContext $runtime; @@ -94,7 +98,11 @@ private function removeDirectory(string $directory): void if (is_dir($path)) { $this->removeDirectory($path); } elseif (is_file($path) || is_link($path)) { + if (function_exists('opcache_invalidate')) { + opcache_invalidate($path, true); + } unlink($path); + clearstatcache(true, $path); } } @@ -118,8 +126,10 @@ private function runCycle(int $cycle): void throw new RuntimeException('Layered config state leaked during worker soak.'); } - if (($cycle % 50) === 0) { + // Cache publication belongs to deployment, not every few requests. + if (hrtime(true) >= $this->nextCacheRefreshNanoseconds) { $this->refreshGeneratedCache($cycle); + $this->nextCacheRefreshNanoseconds = hrtime(true) + 20_000_000_000; } $request = RequestContext::create($this->runtime); @@ -157,7 +167,28 @@ private function runCycle(int $cycle): void throw new RuntimeException('ArrayKit completed a host-owned Runwire request.'); } + $request->complete(); + unset($collection, $config, $request); + + if (($cycle % 100) === 0) { + $this->retireInactiveGenerations(); + } + } + + private function retireInactiveGenerations(): void + { + $pointer = $this->cacheDirectory . '/.arraykit-generation'; + if (!is_file($pointer)) { + return; + } + + $active = trim((string) file_get_contents($pointer)); + foreach (glob($this->cacheDirectory . '/.arraykit-gen-*', GLOB_ONLYDIR) ?: [] as $directory) { + if (basename($directory) !== $active) { + $this->removeDirectory($directory); + } + } } private function refreshGeneratedCache(int $cycle): void @@ -180,7 +211,8 @@ private function refreshGeneratedCache(int $cycle): void throw new RuntimeException('Generated cache refresh returned stale data during worker soak.'); } - if (($cycle % 100) !== 0) { + $this->cacheRefreshes++; + if (($this->cacheRefreshes % 2) !== 0) { return; } @@ -239,5 +271,9 @@ private function writeSource(int $version): void if (file_put_contents($this->sourceDirectory . '/app.php', $source) === false) { throw new RuntimeException('Unable to update worker-soak source config.'); } + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($this->sourceDirectory . '/app.php', true); + } } } From 5fbfeaa4f4b5d542cea109323c560da33f227052 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 16:34:41 +0600 Subject: [PATCH 128/129] :rocket: ci(workflow): update security standards workflow triggers - Add tag triggers to the push event in the security standards workflow :rocket: - Introduce a release job for handling tag pushes with write permissions :sparkles: - Conditionally execute phpforge and release-acceptance jobs to skip tag pushes :construction_worker: --- .github/workflows/security-standards.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index fdd4b1b..5eecba0 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,11 +5,21 @@ on: - cron: "0 0 * * 0" push: branches: [ "main", "master" ] + tags: [ "v*", "[0-9]*" ] pull_request: branches: [ "main", "master", "develop", "development" ] jobs: + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: infocyph/phpforge/.github/workflows/release.yml@main + permissions: + contents: write + secrets: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + phpforge: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') uses: infocyph/phpforge/.github/workflows/security-standards.yml@main with: integration_services: '[]' @@ -24,9 +34,8 @@ jobs: actions: read contents: read - - release-acceptance: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') name: "Release Acceptance" needs: phpforge runs-on: ubuntu-latest From 9cda2c06402cd1236dac98591c17683a7ea32e4d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 5 Oct 2026 16:43:42 +0600 Subject: [PATCH 129/129] :memo: docs(release): update release documentation for version 5.3.0 - Remove release candidate guidance status notes from release documentation :memo: --- docs/release-5.3.0.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/release-5.3.0.rst b/docs/release-5.3.0.rst index 2dc9c78..a3a60ce 100644 --- a/docs/release-5.3.0.rst +++ b/docs/release-5.3.0.rst @@ -1,9 +1,6 @@ ArrayKit 5.3.0 ============== -Status: release candidate guidance. Tagging and publishing remain separate -actions after the release-acceptance gates are satisfied. - Highlights ----------