From 4a137fef34ddb823866152e41ad11b7cf43bdb43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 10 Aug 2026 14:08:25 +0600 Subject: [PATCH 1/3] updated doc+fixing code issues --- .gitignore | 1 + benchmarks/ArrayMultiBench.php | 143 ++++++++++++++++ benchmarks/ArraySingleBench.php | 158 +++++++++++++++++ benchmarks/ArrayValueSetBench.php | 120 +++++++++++++ benchmarks/CollectionBench.php | 87 ++++++++++ benchmarks/ConfigBench.php | 117 +++++++++++++ benchmarks/DotNotationBench.php | 92 ++++++++++ benchmarks/EnvParserBench.php | 60 +++++++ benchmarks/LazyFileConfigBench.php | 122 +++++++++++++ composer.json | 2 +- docs/array-helpers.rst | 15 ++ docs/collection.rst | 7 + docs/config.rst | 5 +- docs/dot-notation.rst | 4 +- docs/migration.rst | 14 ++ docs/rule-reference.rst | 10 +- src/Array/ArraySingle.php | 24 +-- src/Array/ArrayValueSetOps.php | 63 ++++--- src/Array/BaseArrayHelper.php | 9 +- .../Concerns/ArrayMultiQuerySortTrait.php | 97 +++++++---- src/Array/DotNotation.php | 149 ++++++++-------- src/Array/DotNotationPathOps.php | 27 ++- .../Concerns/BaseCollectionTrait.php | 57 ------ src/Config/Concerns/BaseConfigTrait.php | 9 + src/DTO/Concerns/DTOTrait.php | 3 +- tests/Feature/ApiSignatureTest.php | 162 ++++++++++++++++++ tests/Feature/ArrayMultiTest.php | 75 +++++++- tests/Feature/ArraySingleTest.php | 17 ++ tests/Feature/BaseArrayHelperTest.php | 13 ++ tests/Feature/ConfigTest.php | 56 ++++++ tests/Feature/DotNotationTest.php | 27 +++ 31 files changed, 1515 insertions(+), 230 deletions(-) create mode 100644 benchmarks/ArrayMultiBench.php create mode 100644 benchmarks/ArraySingleBench.php create mode 100644 benchmarks/ArrayValueSetBench.php create mode 100644 benchmarks/CollectionBench.php create mode 100644 benchmarks/ConfigBench.php create mode 100644 benchmarks/DotNotationBench.php create mode 100644 benchmarks/EnvParserBench.php create mode 100644 benchmarks/LazyFileConfigBench.php create mode 100644 tests/Feature/ApiSignatureTest.php diff --git a/.gitignore b/.gitignore index c151694..0201dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ test.php var vendor .codex +plan.md diff --git a/benchmarks/ArrayMultiBench.php b/benchmarks/ArrayMultiBench.php new file mode 100644 index 0000000..92ae302 --- /dev/null +++ b/benchmarks/ArrayMultiBench.php @@ -0,0 +1,143 @@ + */ + private array $needles = []; + + /** @var array */ + private array $rows = []; + + /** @param array{size:int, needles:int} $params */ + public function setUp(array $params): void + { + $this->rows = []; + for ($index = 0; $index < $params['size']; $index++) { + $this->rows[] = [ + 'id' => $index, + 'group' => 'group-' . ($index % 100), + 'score' => $index % 1000, + 'text' => 'row-' . $index, + 'derived' => [ + 'id' => $index % max(1, intdiv($params['size'], 2)), + 'payload' => str_repeat('x', 96), + ], + ]; + } + + $this->needles = range(0, $params['needles'] - 1); + } + + public function benchCountBy(): void + { + ArrayMulti::countBy($this->rows, 'group'); + } + + public function benchDuplicatesByStrict(): void + { + ArrayMulti::duplicatesBy($this->rows, 'derived', true); + } + + public function benchFlatten(): void + { + ArrayMulti::flatten($this->rows); + } + + public function benchGroupBy(): void + { + ArrayMulti::groupBy($this->rows, 'group'); + } + + public function benchKeyBy(): void + { + ArrayMulti::keyBy($this->rows, 'id'); + } + + public function benchKeyByNative(): void + { + $result = []; + foreach ($this->rows as $row) { + $result[$row['id']] = $row; + } + } + + public function benchPluck(): void + { + ArrayMulti::pluck($this->rows, 'score', 'id'); + } + + public function benchSortBy(): void + { + ArrayMulti::sortBy($this->rows, 'score', true, SORT_NUMERIC); + } + + public function benchSortByMany(): void + { + ArrayMulti::sortByMany($this->rows, [ + ['group', 'asc', SORT_STRING], + ['score', 'desc', SORT_NUMERIC], + ]); + } + + public function benchSum(): void + { + ArrayMulti::sum($this->rows, 'score'); + } + + public function benchUniqueByStrict(): void + { + ArrayMulti::uniqueBy($this->rows, 'derived', true); + } + + public function benchWhere(): void + { + ArrayMulti::where($this->rows, 'score', '>=', 500); + } + + public function benchWhereIn(): void + { + ArrayMulti::whereIn($this->rows, 'score', $this->needles, true); + } + + public function benchWhereInNative(): void + { + $result = []; + foreach ($this->rows as $key => $row) { + if (in_array($row['score'], $this->needles, true)) { + $result[$key] = $row; + } + } + } + + public function benchWhereNotIn(): void + { + ArrayMulti::whereNotIn($this->rows, 'score', $this->needles, true); + } + + /** @return array */ + public function provideWorkloads(): array + { + return [ + '100x2' => ['size' => 100, 'needles' => 2], + '1kx10' => ['size' => 1000, 'needles' => 10], + '10kx2' => ['size' => 10000, 'needles' => 2], + '10kx10' => ['size' => 10000, 'needles' => 10], + '10kx100' => ['size' => 10000, 'needles' => 100], + '100kx100' => ['size' => 100000, 'needles' => 100], + ]; + } +} diff --git a/benchmarks/ArraySingleBench.php b/benchmarks/ArraySingleBench.php new file mode 100644 index 0000000..dd17467 --- /dev/null +++ b/benchmarks/ArraySingleBench.php @@ -0,0 +1,158 @@ + */ + private array $data = []; + + /** @var array */ + private array $keyed = []; + + /** @var array */ + private array $needles = []; + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $uniqueValues = max(1, intdiv($params['size'], 2)); + $this->data = []; + $this->keyed = []; + + for ($index = 0; $index < $params['size']; $index++) { + $value = $index % $uniqueValues; + $this->data[] = $value; + $this->keyed['key-' . $index] = $value; + } + + $this->needles = array_slice($this->data, 0, min(100, $params['size'])); + } + + public function benchContains(array $params): void + { + ArraySingle::contains($this->data, $params['size'] - 1, true); + } + + public function benchContainsAll(): void + { + ArraySingle::containsAll($this->data, $this->needles, true); + } + + public function benchContainsAny(array $params): void + { + ArraySingle::containsAny($this->data, [$params['size'], $this->needles[0]], true); + } + + public function benchContainsNative(array $params): void + { + in_array($params['size'] - 1, $this->data, true); + } + + public function benchDiff(): void + { + ArraySingle::diff($this->data, $this->needles, true); + } + + public function benchDuplicates(): void + { + ArraySingle::duplicates($this->data); + } + + public function benchIntersect(): void + { + ArraySingle::intersect($this->data, $this->needles, true); + } + + public function benchMap(): void + { + ArraySingle::map($this->data, static fn(int $value): int => $value + 1); + } + + public function benchMapArrayMap(): void + { + array_map(static fn(int $value): int => $value + 1, $this->data); + } + + public function benchMapForeach(): void + { + $result = []; + foreach ($this->data as $key => $value) { + $result[$key] = $value + 1; + } + } + + public function benchMedian(): void + { + ArraySingle::median($this->data); + } + + public function benchMode(): void + { + ArraySingle::mode($this->data); + } + + public function benchNth(): void + { + ArraySingle::nth($this->data, 10, 3); + } + + public function benchPartition(): void + { + ArraySingle::partition($this->keyed, static fn(int $value): bool => ($value % 2) === 0); + } + + public function benchSame(): void + { + ArraySingle::same($this->data, $this->data, true); + } + + public function benchSeededShuffle(): void + { + ArraySingle::shuffle($this->data, 12345); + } + + public function benchSum(): void + { + ArraySingle::sum($this->data); + } + + public function benchUnique(): void + { + ArraySingle::unique($this->data, true); + } + + public function benchUnseededShuffle(): void + { + ArraySingle::shuffle($this->data); + } + + public function benchWhere(): void + { + ArraySingle::where($this->keyed, static fn(int $value): bool => ($value % 2) === 0); + } + + /** @return array */ + public function provideSizes(): array + { + return [ + '10' => ['size' => 10], + '100' => ['size' => 100], + '1k' => ['size' => 1000], + '10k' => ['size' => 10000], + '100k' => ['size' => 100000], + ]; + } +} diff --git a/benchmarks/ArrayValueSetBench.php b/benchmarks/ArrayValueSetBench.php new file mode 100644 index 0000000..83ffd8b --- /dev/null +++ b/benchmarks/ArrayValueSetBench.php @@ -0,0 +1,120 @@ + */ + private array $values = []; + + /** @param array{bytes:int, distribution:string} $params */ + public function setUp(array $params): void + { + $this->values = []; + $uniqueValues = $params['distribution'] === 'duplicate-heavy' ? 20 : 900; + + for ($index = 0; $index < 1000; $index++) { + $id = $index % $uniqueValues; + $this->values[] = [ + 'id' => $id, + 'payload' => str_repeat(chr(65 + ($id % 26)), $params['bytes']), + 'nested' => ['enabled' => ($id % 2) === 0, 'score' => (float) $id], + ]; + } + } + + public function benchCanonicalFingerprintSet(): void + { + $seen = []; + foreach ($this->values as $value) { + $seen[ArraySingleOps::fingerprint($value, true)] = true; + } + } + + public function benchDigestWithVerifiedBuckets(): void + { + $buckets = []; + foreach ($this->values as $value) { + $fingerprint = ArraySingleOps::fingerprint($value, true); + $digest = hash('xxh128', $fingerprint, true); + if (!isset($buckets[$digest])) { + $buckets[$digest] = [$fingerprint]; + + continue; + } + + if (!in_array($fingerprint, $buckets[$digest], true)) { + $buckets[$digest][] = $fingerprint; + } + } + } + + public function benchLooseNestedUnique(): void + { + ArraySingle::unique($this->values); + } + + public function benchMixedStrictUnique(): void + { + $object = new \stdClass(); + ArraySingle::unique([ + ...$this->values, + null, + false, + 0, + 0.0, + '0', + INF, + -INF, + NAN, + $object, + $object, + ], true); + } + + public function benchStrictNestedScan(): void + { + $seen = []; + foreach ($this->values as $value) { + if (!in_array($value, $seen, true)) { + $seen[] = $value; + } + } + } + + public function benchStrictNestedUnique(): void + { + ArraySingle::unique($this->values, true); + } + + /** @return array */ + public function provideValueSets(): array + { + $workloads = []; + foreach ([16, 32, 48, 64, 96, 128, 256, 512] as $bytes) { + $workloads[$bytes . 'b-duplicates'] = [ + 'bytes' => $bytes, + 'distribution' => 'duplicate-heavy', + ]; + $workloads[$bytes . 'b-unique'] = [ + 'bytes' => $bytes, + 'distribution' => 'mostly-unique', + ]; + } + + return $workloads; + } +} diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php new file mode 100644 index 0000000..366b514 --- /dev/null +++ b/benchmarks/CollectionBench.php @@ -0,0 +1,87 @@ + */ + 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); + } + + public function benchCollectionMap(): void + { + Collection::make($this->data)->map(static fn(int $value): int => $value * 2); + } + + 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) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->take(100) + ->all(); + } + + public function benchLazyMapMaterialization(): void + { + LazyCollection::fromFactory(fn(): array => $this->data) + ->mapLazy(static fn(int $value): int => $value * 2) + ->all(); + } + + public function benchPipelineMap(): void + { + Collection::make($this->data) + ->process() + ->map(static fn(int $value): int => $value * 2); + } + + /** @return array */ + public function provideSizes(): array + { + return [ + '1k' => ['size' => 1000], + '10k' => ['size' => 10000], + '100k' => ['size' => 100000], + '1m' => ['size' => 1000000], + ]; + } +} diff --git a/benchmarks/ConfigBench.php b/benchmarks/ConfigBench.php new file mode 100644 index 0000000..8d4a978 --- /dev/null +++ b/benchmarks/ConfigBench.php @@ -0,0 +1,117 @@ + false, + 'one' => [ + 'two' => [ + 'three' => [ + 'four' => [ + 'five' => 5, + ], + ], + ], + ], + 'service.name' => 'escaped', + 'users' => [['name' => 'Alice'], ['name' => 'Bob']], + ]; + + $this->path = $params['path']; + $this->reads = $params['reads']; + $this->cached = new Config(); + $this->cached->loadArray($items); + $this->uncached = new Config(); + $this->uncached->loadArray($items); + $this->uncached->readCache(false); + $this->cached->get($this->path); + } + + public function benchCachedGet(): void + { + for ($index = 0; $index < $this->reads; $index++) { + $this->cached->get($this->path); + } + } + + public function benchFill(): void + { + $this->cached->fill('one.two.added', 1); + } + + public function benchForget(): void + { + $this->cached->forget('one.two.three'); + } + + public function benchSet(): void + { + $this->cached->set('one.two.value', 1); + } + + public function benchTopLevelGet(): void + { + for ($index = 0; $index < $this->reads; $index++) { + $this->cached->get('debug'); + } + } + + public function benchTypedGetter(): void + { + $this->cached->getInt('one.two.three.four.five'); + } + + public function benchUncachedGet(): void + { + for ($index = 0; $index < $this->reads; $index++) { + $this->uncached->get($this->path); + } + } + + /** @return array */ + public function provideReads(): array + { + $paths = [ + 'plain' => 'debug', + 'one' => 'one', + 'three' => 'one.two.three', + 'five' => 'one.two.three.four.five', + 'escaped' => 'service\\.name', + 'wildcard' => 'users.*.name', + ]; + $workloads = []; + + foreach ($paths as $name => $path) { + foreach ([1, 2, 5, 10, 100, 1000] as $reads) { + $workloads[$name . '-' . $reads] = ['path' => $path, 'reads' => $reads]; + } + } + + return $workloads; + } +} diff --git a/benchmarks/DotNotationBench.php b/benchmarks/DotNotationBench.php new file mode 100644 index 0000000..49a3bab --- /dev/null +++ b/benchmarks/DotNotationBench.php @@ -0,0 +1,92 @@ + */ + private array $target = []; + + /** @param array{path:string} $params */ + public function setUp(array $params): void + { + $this->path = $params['path']; + $this->target = [ + 'plain' => 1, + 'one' => ['two' => ['three' => ['four' => ['five' => ['six' => ['seven' => ['eight' => ['nine' => ['ten' => 10]]]]]]]]], + 'service.name' => 'escaped', + 'users' => [ + ['name' => 'Alice', 'teams' => [['name' => 'A'], ['name' => 'B']]], + ['name' => 'Bob', 'teams' => [['name' => 'C'], ['name' => 'D']]], + ], + ]; + + DotNotation::get($this->target, $this->path); + } + + public function benchColdMissingPath(): void + { + DotNotation::get($this->target, 'cold.' . $this->coldPath++ . '.missing'); + } + + public function benchFill(): void + { + $target = $this->target; + DotNotation::fill($target, 'one.two.filled', true); + } + + public function benchForget(): void + { + $target = $this->target; + DotNotation::forget($target, 'users.*.teams.*.name'); + } + + public function benchNativeNestedGet(): void + { + $value = $this->target['one']['two']['three']['four']['five'] ?? null; + } + + public function benchSet(): void + { + $target = $this->target; + DotNotation::set($target, 'users.*.teams.*.active', true); + } + + public function benchWarmGet(): void + { + DotNotation::get($this->target, $this->path); + } + + /** @return array */ + public function providePaths(): array + { + return [ + 'plain' => ['path' => 'plain'], + 'one-segment' => ['path' => 'one'], + 'three-segments' => ['path' => 'one.two.three'], + 'five-segments' => ['path' => 'one.two.three.four.five'], + 'ten-segments' => ['path' => 'one.two.three.four.five.six.seven.eight.nine.ten'], + 'escaped' => ['path' => 'service\\.name'], + 'wildcard' => ['path' => 'users.*.name'], + 'nested-wildcard' => ['path' => 'users.*.teams.*.name'], + 'first' => ['path' => 'users.{first}.name'], + 'last' => ['path' => 'users.{last}.name'], + ]; + } +} diff --git a/benchmarks/EnvParserBench.php b/benchmarks/EnvParserBench.php new file mode 100644 index 0000000..b3dcc13 --- /dev/null +++ b/benchmarks/EnvParserBench.php @@ -0,0 +1,60 @@ + 'KEY_' . $index . '=value # comment', + 'escape-heavy' => 'KEY_' . $index . '="line\\n\\t\\"' . $index . '"', + 'interpolation' => 'KEY_' . $index . '=${BASE}-' . $index, + 'quoted' => 'KEY_' . $index . '="quoted value ' . $index . '"', + default => 'KEY_' . $index . '=value-' . $index, + }; + } + + $this->contents = implode("\n", $lines); + } + + public function benchParse(): void + { + EnvParser::parse($this->contents); + } + + public function benchParseRaw(): void + { + EnvParser::parseRaw($this->contents); + } + + /** @return array */ + public function provideInputs(): array + { + $workloads = []; + foreach ([10, 100, 1000, 10000] as $lines) { + foreach (['plain', 'quoted', 'interpolation', 'comments', 'escape-heavy'] as $mode) { + $workloads[$lines . '-' . $mode] = ['lines' => $lines, 'mode' => $mode]; + } + } + + return $workloads; + } +} diff --git a/benchmarks/LazyFileConfigBench.php b/benchmarks/LazyFileConfigBench.php new file mode 100644 index 0000000..2be78f0 --- /dev/null +++ b/benchmarks/LazyFileConfigBench.php @@ -0,0 +1,122 @@ +sourceDirectory = $base . DIRECTORY_SEPARATOR . 'source'; + $this->cacheDirectory = $base . DIRECTORY_SEPARATOR . 'cache'; + mkdir($this->sourceDirectory, 0777, true); + mkdir($this->cacheDirectory, 0777, true); + file_put_contents( + $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php', + " 'ArrayKit', 'nested' => ['value' => 42]];\n", + ); + + $warmer = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory); + $warmer->warmNamespaceCache('app'); + + $this->flatConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory); + $this->namespaceCacheConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory); + $this->loadedConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory); + $this->loadedConfig->preload('app'); + $this->sourceConfig = new LazyFileConfig($this->sourceDirectory); + $this->missingConfig = new LazyFileConfig($this->sourceDirectory); + $this->warmConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory); + } + + public function tearDown(): void + { + foreach (['app.php', '__flat.php'] as $file) { + $cachePath = $this->cacheDirectory . DIRECTORY_SEPARATOR . $file; + if (is_file($cachePath)) { + unlink($cachePath); + } + } + + $sourcePath = $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php'; + if (is_file($sourcePath)) { + unlink($sourcePath); + } + + if (is_dir($this->cacheDirectory)) { + rmdir($this->cacheDirectory); + } + if (is_dir($this->sourceDirectory)) { + rmdir($this->sourceDirectory); + } + + $base = dirname($this->sourceDirectory); + if (is_dir($base)) { + rmdir($base); + } + } + + public function benchAlreadyLoadedNamespace(): void + { + $this->loadedConfig->get('app.nested.value'); + } + + public function benchCacheWarm(): void + { + $this->warmConfig->warmNamespaceCache('app'); + } + + public function benchFlatLeafHit(): void + { + $this->flatConfig->get('app.nested.value'); + } + + public function benchMissingNamespace(): void + { + $this->missingConfig->get('missing.value'); + } + + public function benchNamespaceCacheLoad(): void + { + $this->namespaceCacheConfig->get('app'); + } + + public function benchRepeatedMissingLookup(): void + { + for ($index = 0; $index < 100; $index++) { + $this->missingConfig->get('missing.value'); + } + } + + public function benchSourceLoad(): void + { + $this->sourceConfig->get('app.nested.value'); + } +} diff --git a/composer.json b/composer.json index 890b2ee..e60268a 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "ext-hash": "*" }, "require-dev": { - "infocyph/phpforge": "dev-main" + "infocyph/phpforge": "dev-main@dev" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index e5034b9..58dc427 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -19,6 +19,10 @@ If you prefer one entry point, use ``Infocyph\ArrayKit\ArrayKit``: $flat = ArrayKit::multi()->flatten([[1], [2, [3]]]); $wrapped = ArrayKit::helper()->wrap('x'); +``wrap()`` returns ``[]`` only for ``null``, leaves arrays unchanged, and wraps +every other value. Falsey values such as ``false``, ``0``, ``0.0``, ``'0'``, +and ``''`` therefore remain data and become one-element arrays. + Choosing the Right Helper ------------------------- @@ -200,6 +204,12 @@ ArrayMulti: Grouping, Ordering, and Projection $scores = ArrayMulti::pluck($rows, 'score'); // [10,30,20] $transposed = ArrayMulti::transpose($rows); +For ``groupBy()``, ``keyBy()`` / ``indexBy()``, and ``countBy()``, rows missing +the requested field are skipped. Derived keys must be strings or integers; +``null``, booleans, floats, arrays, and objects raise +``InvalidArgumentException``. Literal strings such as ``'_undefined'`` and +``''`` are valid keys and are never used as missing-value sentinels. + ArrayMulti: Row Set Operations ------------------------------ @@ -227,6 +237,11 @@ ArrayMulti: Row Set Operations $sumById = ArrayMulti::sum($rows, 'id'); $sumByCallback = ArrayMulti::sum($rows, fn ($row, $key) => $row['id'] + $key); +Strict ``uniqueBy()`` and ``duplicatesBy()`` preserve PHP strict equality, +original row order, and original keys. Their verified fingerprint buckets avoid +quadratic scans for ordinary values while retaining collision checks and a safe +fallback for values that cannot be fingerprinted, including ``NAN``. + ArrayShape Validation --------------------- diff --git a/docs/collection.rst b/docs/collection.rst index b2c1b7d..dd7413b 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -15,6 +15,11 @@ Available classes: - ``Infocyph\ArrayKit\Collection\Pipeline`` - ``Infocyph\ArrayKit\Collection\LazyCollection`` +Choose the lowest-cost surface that fits the job: direct ``ArraySingle`` / +``ArrayMulti`` static calls for hot loops, ``Collection`` or ``Pipeline`` for a +convenient mutable chain, and ``LazyCollection`` for streaming or very large +iterables where intermediate materialization should be avoided. + Creating Collections -------------------- @@ -315,6 +320,8 @@ Behavior Notes -------------- - Most pipeline methods return the underlying ``Collection`` for chaining. +- Collection iteration is provided by ``IteratorAggregate``; manual internal + pointer methods are intentionally not part of the API. - Terminal methods return scalar/array/bool and stop the chain. - Dot-notation works in collection accessors and in ``HookedCollection`` get/set overrides. - ``merge()`` follows PHP ``array_merge`` semantics (string-key overwrite, numeric append/reindex). diff --git a/docs/config.rst b/docs/config.rst index 95ff50c..1069d4a 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -447,7 +447,10 @@ Config methods: - ``readonly()``, ``isReadonly()`` Read memoization is bounded to 1,024 resolved paths for predictable memory use -in persistent workers. Mutations and reloads invalidate the memoized values. +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. Hook-aware methods: diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst index cb513fc..0dfe306 100644 --- a/docs/dot-notation.rst +++ b/docs/dot-notation.rst @@ -260,5 +260,7 @@ Behavior Notes - Missing integer keys return the provided default. - Defaults may be plain values or callables, and callables are only evaluated when path resolution fails. - Wildcard traversal in ``get`` returns arrays of matched results. -- ``set`` supports wildcard paths when wildcard is the first segment. +- ``set`` supports wildcards at any path depth, including multiple wildcards. - ``forget`` supports wildcard and nested removal across arrays. +- Compiled paths are kept in a bounded cache, and traversal advances a segment + cursor without copying the remaining path on each step. diff --git a/docs/migration.rst b/docs/migration.rst index f628e37..b67de8c 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -34,6 +34,20 @@ Compatibility Notes - Pipeline methods are mutable by design: most transformation methods update the same collection instance and return it. - Use ``copy()`` or ``immutable()`` before pipeline operations when functional immutability is preferred. +Behavior Changes +---------------- + +- ``wrap()`` now treats only ``null`` as absence. Falsey non-null scalars are + wrapped instead of being discarded. +- Seeded ``ArraySingle::shuffle()`` uses an isolated randomizer and no longer + reseeds or advances PHP's global Mersenne Twister state. +- ``groupBy()``, ``keyBy()`` / ``indexBy()``, and ``countBy()`` skip rows whose + derived field is missing. Present values must produce integer or string keys; + ``null`` and other invalid key types now throw ``InvalidArgumentException``. +- ``Collection`` relies on ``IteratorAggregate``. Calls to the former manual + pointer surface (``current()``, ``key()``, ``next()``, ``rewind()``, and + ``valid()``) should be replaced with ``foreach`` or ``getIterator()``. + Recommended Upgrade Checklist ----------------------------- diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index edeaec8..6793681 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -197,7 +197,7 @@ ArraySingle public static function isNegative(array $array): bool public static function shuffle(array $array, ?int $seed = null): array public static function isInt(array $array): bool - public static function nonEmpty(array $array): array + public static function nonEmpty(array $array, bool $preserveKeys = false): array public static function avg(array $array): float|int public static function isUnique(array $array): bool public static function positive(array $array): array @@ -387,11 +387,6 @@ Collection uses ``BaseCollectionTrait``. Public API: public function offsetSet(mixed $offset, mixed $value): void public function offsetUnset(mixed $offset): void public function getIterator(): Traversable - public function current(): mixed - public function key(): string|int|null - public function next(): void - public function valid(): bool - public function rewind(): void public function count(): int public function jsonSerialize(): array @@ -532,6 +527,7 @@ LazyFileConfig -------------------------------------- LazyFileConfig loads top-level config files on first keyed access: +Calling ``all()`` throws by design because lazy configuration requires a key. .. code-block:: php @@ -549,7 +545,7 @@ LazyFileConfig loads top-level config files on first keyed access: public function namespaceCacheDirectory(): ?string public function warmNamespaceCache(string|array|null $namespaces = null): static public function flushNamespaceCache(string|array|null $namespaces = null): static - public function all(): array // throws (design choice) + public function all(): array Config Hook-Aware Variants -------------------------------------- diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index b0c2ded..ccc2091 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -5,6 +5,8 @@ namespace Infocyph\ArrayKit\Array; use InvalidArgumentException; +use Random\Engine\Mt19937; +use Random\Randomizer; class ArraySingle { @@ -800,28 +802,20 @@ public static function separate(array $array): array } /** - * Randomly shuffles the elements in the given array. + * Shuffle values, using isolated deterministic state when a seed is given. * - * If no seed is given, the internal PHP random number generator is used. - * If a seed is given, the Mersenne Twister random number generator is - * seeded with the given value, used to shuffle the array, and then reset - * to the current internal PHP random number generator seed. - * - * @param array $array The array to shuffle. - * @param int|null $seed Optional seed for the Mersenne Twister. - * @return array The shuffled array. + * @param array $array + * @return array */ public static function shuffle(array $array, ?int $seed = null): array { if ($seed === null) { - \shuffle($array); - } else { - \mt_srand($seed); - \shuffle($array); - \mt_srand(); + shuffle($array); + + return $array; } - return $array; + return new Randomizer(new Mt19937($seed))->shuffleArray($array); } /** diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php index 176ccf8..6a28a81 100644 --- a/src/Array/ArrayValueSetOps.php +++ b/src/Array/ArrayValueSetOps.php @@ -134,6 +134,40 @@ public static function same(array $left, array $right, bool $strict): bool return $leftCounts === $rightCounts; } + /** + * Track strict membership with canonical fingerprints and a safe scan fallback. + * + * Long fingerprints use verified digest buckets, so a digest collision cannot + * change equality. Values such as NaN that are not reflexive use PHP's strict + * comparison semantics in the fallback bucket. + * + * @param array $seen + * @param array> $digestBuckets + * @param array $fallback + */ + public static function strictValueAlreadySeen( + mixed $value, + array &$seen, + array &$digestBuckets, + array &$fallback, + ): bool { + if (!self::isStrictHashable($value)) { + if (in_array($value, $fallback, true)) { + return true; + } + + $fallback[] = $value; + + return false; + } + + return self::fingerprintAlreadySeen( + $seen, + $digestBuckets, + self::fingerprintStrict($value), + ); + } + /** * @param array $array * @return array @@ -147,16 +181,12 @@ public static function unique(array $array, bool $strict): array return $unique; } - if (!self::allStrictHashable($array)) { - return self::uniqueByScan($array); - } - $seen = []; $digestBuckets = []; + $fallback = []; $result = []; foreach ($array as $key => $item) { - $fingerprint = self::fingerprintStrict($item); - if (self::fingerprintAlreadySeen($seen, $digestBuckets, $fingerprint)) { + if (self::strictValueAlreadySeen($item, $seen, $digestBuckets, $fallback)) { continue; } @@ -411,25 +441,4 @@ private static function sameByScan(array $left, array $right, bool $strict): boo return true; } - - /** - * @param array $array - * @return array - */ - private static function uniqueByScan(array $array): array - { - $seen = []; - $result = []; - - foreach ($array as $key => $item) { - if (in_array($item, $seen, true)) { - continue; - } - - $seen[] = $item; - $result[$key] = $item; - } - - return $result; - } } diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 3c3488a..d9b058f 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -331,16 +331,13 @@ public static function unWrap(mixed $value): mixed } /** - * Wrap a value in an array if it's not already an array; otherwise return the array as is. + * Wrap a non-null value without discarding valid falsey data. * - * If the value is empty, an empty array is returned. - * - * @param mixed $value The value to wrap. - * @return array The wrapped value. + * @return array */ public static function wrap(mixed $value): array { - if (empty($value)) { + if ($value === null) { return []; } diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index ecd0ca4..fd9cff8 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -7,6 +7,8 @@ use Infocyph\ArrayKit\Array\ArraySharedOps; use Infocyph\ArrayKit\Array\ArraySingle; use Infocyph\ArrayKit\Array\ArraySingleOps; +use Infocyph\ArrayKit\Array\ArrayValueSetOps; +use InvalidArgumentException; use function Infocyph\ArrayKit\compare; @@ -44,14 +46,16 @@ public static function between(array $array, string $key, float|int $from, float public static function countBy(array $array, string|callable $groupBy): array { $counts = []; + $useCallback = is_callable($groupBy); foreach ($array as $key => $row) { - $bucket = is_callable($groupBy) - ? $groupBy($row, $key) - : ((is_array($row) && array_key_exists($groupBy, $row)) ? $row[$groupBy] : '_undefined'); + if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) { + continue; + } - $normalized = self::normalizeArrayKey($bucket); - $counts[$normalized] = ($counts[$normalized] ?? 0) + 1; + $bucket = $useCallback ? $groupBy($row, $key) : $row[$groupBy]; + $arrayKey = self::requireArrayKey($bucket, 'countBy'); + $counts[$arrayKey] = ($counts[$arrayKey] ?? 0) + 1; } return $counts; @@ -130,16 +134,15 @@ public static function firstWhereIn(array $array, string $key, array $values, bo public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array { $results = []; + $useCallback = is_callable($groupBy); + foreach ($array as $key => $row) { - $gKey = null; - if (is_callable($groupBy)) { - $gKey = $groupBy($row, $key); - } elseif (is_array($row) && array_key_exists($groupBy, $row)) { - $gKey = $row[$groupBy]; - } else { - $gKey = '_undefined'; + if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) { + continue; } - $groupKey = self::normalizeArrayKey($gKey); + + $resolved = $useCallback ? $groupBy($row, $key) : $row[$groupBy]; + $groupKey = self::requireArrayKey($resolved, 'groupBy'); if ($preserveKeys) { $results[$groupKey][$key] = $row; @@ -171,13 +174,15 @@ public static function indexBy(array $array, string|callable $indexBy): array public static function keyBy(array $array, string|callable $keyBy): array { $results = []; + $useCallback = is_callable($keyBy); foreach ($array as $index => $row) { - $resolved = is_callable($keyBy) - ? $keyBy($row, $index) - : ((is_array($row) && array_key_exists($keyBy, $row)) ? $row[$keyBy] : '_undefined'); + if (!$useCallback && (!is_array($row) || !array_key_exists($keyBy, $row))) { + continue; + } - $results[self::normalizeArrayKey($resolved)] = $row; + $resolved = $useCallback ? $keyBy($row, $index) : $row[$keyBy]; + $results[self::requireArrayKey($resolved, 'keyBy')] = $row; } return $results; @@ -288,7 +293,7 @@ public static function pluck(array $array, string $column, ?string $indexBy = nu $value = $row[$column]; if ($indexBy !== null && array_key_exists($indexBy, $row)) { - $results[self::normalizeArrayKey($row[$indexBy])] = $value; + $results[self::requireArrayKey($row[$indexBy], 'pluck')] = $value; } else { $results[] = $value; } @@ -817,29 +822,46 @@ private static function collectByDerivedKey( bool $strict, bool $keepDuplicates, ): array { - $seen = []; $results = []; + $useCallback = is_callable($keyOrCallback); - foreach ($array as $index => $row) { - $derived = self::resolveDerivedValue($row, $keyOrCallback, $index); - $alreadySeen = in_array($derived, $seen, $strict); + if (!$strict) { + $seen = []; + foreach ($array as $index => $row) { + $derived = $useCallback + ? self::invokeRowCallback($keyOrCallback, $row, $index) + : self::resolveDerivedValue($row, $keyOrCallback, $index); + $alreadySeen = in_array($derived, $seen, false); - if ($keepDuplicates) { - if ($alreadySeen) { + if ($alreadySeen === $keepDuplicates) { $results[$index] = $row; - } else { + } + if (!$alreadySeen) { $seen[] = $derived; } - - continue; } - if ($alreadySeen) { - continue; - } + return $results; + } - $seen[] = $derived; - $results[$index] = $row; + $seen = []; + $digestBuckets = []; + $fallback = []; + + foreach ($array as $index => $row) { + $derived = $useCallback + ? self::invokeRowCallback($keyOrCallback, $row, $index) + : self::resolveDerivedValue($row, $keyOrCallback, $index); + $alreadySeen = ArrayValueSetOps::strictValueAlreadySeen( + $derived, + $seen, + $digestBuckets, + $fallback, + ); + + if ($alreadySeen === $keepDuplicates) { + $results[$index] = $row; + } } return $results; @@ -1082,6 +1104,17 @@ private static function prepareSortByManyCriteria(array $array, array $criteria) return $prepared; } + private static function requireArrayKey(mixed $value, string $operation): int|string + { + if (is_int($value) || is_string($value)) { + return $value; + } + + throw new InvalidArgumentException( + $operation . ' derived key must be an integer or string; ' . get_debug_type($value) . ' given.', + ); + } + private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed { if (is_callable($keyOrCallback)) { diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index ea369f6..1050097 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -31,28 +31,27 @@ private static function flattenInto(array $array, string $prepend, array &$resul * @param array $array * @param array $segments */ - private static function forgetBySegments(array &$array, array $segments): void + private static function forgetBySegments(array &$array, array $segments, int $position = 0): void { - if ($segments === []) { + $segmentCount = count($segments); + if ($position >= $segmentCount) { return; } - $segment = self::shiftSegment($segments); - if ($segment === null) { - return; - } + $segment = $segments[$position]; + $next = $position + 1; if ($segment === '*') { - if ($segments !== []) { - self::forgetEach($array, $segments); + if ($next < $segmentCount) { + self::forgetEach($array, $segments, $next); } return; } $normalized = self::unescapeSegment($segment); - if ($segments !== [] && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) { - self::forgetBySegments($array[$normalized], $segments); + if ($next < $segmentCount && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) { + self::forgetBySegments($array[$normalized], $segments, $next); return; } @@ -66,11 +65,11 @@ private static function forgetBySegments(array &$array, array $segments): void * @param array $array * @param array $segments */ - private static function forgetEach(array &$array, array $segments): void + private static function forgetEach(array &$array, array $segments, int $position): void { foreach ($array as &$inner) { if (is_array($inner)) { - self::forgetBySegments($inner, $segments); + self::forgetBySegments($inner, $segments, $position); } } } @@ -97,16 +96,19 @@ private static function getValueSafe( /** * Sets values in the target using dot-notation with wildcard support. * + * @param array $target * @param array $segments */ - private static function handleWildcardSet(mixed &$target, array $segments, mixed $value, bool $overwrite): void - { - if (!is_array($target)) { - $target = []; - } - if (!empty($segments)) { + private static function handleWildcardSet( + array &$target, + array $segments, + int $position, + mixed $value, + bool $overwrite, + ): void { + if ($position < count($segments)) { foreach ($target as &$inner) { - self::setValueBySegments($inner, $segments, $value, $overwrite); + self::setValueBySegments($inner, $segments, $position, $value, $overwrite); } } elseif ($overwrite) { foreach ($target as &$inner) { @@ -178,26 +180,15 @@ private static function segmentExact(mixed $array, string $path, mixed $default) private static function setValue(array &$target, string $key, mixed $value, bool $overwrite): void { $segments = self::splitPath($key); - $first = self::shiftSegment($segments); - if ($first === null) { - return; - } + $segment = $segments[0]; - if ($first === '*') { - if ($segments !== []) { - foreach ($target as &$inner) { - self::setValueBySegments($inner, $segments, $value, $overwrite); - } - } elseif ($overwrite) { - foreach ($target as &$inner) { - $inner = $value; - } - } + if ($segment === '*') { + self::handleWildcardSet($target, $segments, 1, $value, $overwrite); return; } - self::setValueArray($target, $first, $segments, $value, $overwrite); + self::setValueArray($target, $segment, $segments, 1, $value, $overwrite); } /** @@ -206,15 +197,22 @@ private static function setValue(array &$target, string $key, mixed $value, bool * @param array &$target * @param array $segments */ - private static function setValueArray(array &$target, string $segment, array $segments, mixed $value, bool $overwrite): void - { + private static function setValueArray( + array &$target, + string $segment, + array $segments, + int $position, + mixed $value, + bool $overwrite, + ): void { $segment = self::unescapeSegment($segment); - if (!empty($segments)) { + if ($position < count($segments)) { if (!ArraySingle::exists($target, $segment)) { $target[$segment] = []; } - self::setValueBySegments($target[$segment], $segments, $value, $overwrite); + + self::setValueBySegments($target[$segment], $segments, $position, $value, $overwrite); } else { if ($overwrite || !ArraySingle::exists($target, $segment)) { $target[$segment] = $value; @@ -225,29 +223,36 @@ private static function setValueArray(array &$target, string $segment, array $se /** * @param array $segments */ - private static function setValueBySegments(mixed &$target, array $segments, mixed $value, bool $overwrite): void - { - if ($segments === []) { + private static function setValueBySegments( + mixed &$target, + array $segments, + int $position, + mixed $value, + bool $overwrite, + ): void { + if ($position >= count($segments)) { return; } - $first = self::shiftSegment($segments); - if ($first === null) { - return; - } + $segment = $segments[$position]; + $next = $position + 1; - if ($first === '*') { - self::handleWildcardSet($target, $segments, $value, $overwrite); + if ($segment === '*') { + if (!is_array($target)) { + $target = []; + } + + self::handleWildcardSet($target, $segments, $next, $value, $overwrite); return; } if (is_array($target)) { - self::setValueArray($target, $first, $segments, $value, $overwrite); + self::setValueArray($target, $segment, $segments, $next, $value, $overwrite); } elseif (is_object($target)) { - self::setValueObject($target, $first, $segments, $value, $overwrite); + self::setValueObject($target, $segment, $segments, $next, $value, $overwrite); } else { - self::setValueFallback($target, $first, $segments, $value, $overwrite); + self::setValueFallback($target, $segment, $segments, $next, $value, $overwrite); } } @@ -256,12 +261,18 @@ private static function setValueBySegments(mixed &$target, array $segments, mixe * * @param array $segments */ - private static function setValueFallback(mixed &$target, string $segment, array $segments, mixed $value, bool $overwrite): void - { + private static function setValueFallback( + mixed &$target, + string $segment, + array $segments, + int $position, + mixed $value, + bool $overwrite, + ): void { $segment = self::unescapeSegment($segment); $target = []; - if (!empty($segments)) { - self::setValueBySegments($target[$segment], $segments, $value, $overwrite); + if ($position < count($segments)) { + self::setValueBySegments($target[$segment], $segments, $position, $value, $overwrite); } elseif ($overwrite) { $target[$segment] = $value; } @@ -272,16 +283,23 @@ private static function setValueFallback(mixed &$target, string $segment, array * * @param array $segments */ - private static function setValueObject(object &$target, string $segment, array $segments, mixed $value, bool $overwrite): void - { + private static function setValueObject( + object &$target, + string $segment, + array $segments, + int $position, + mixed $value, + bool $overwrite, + ): void { $segment = self::unescapeSegment($segment); $propertyExists = property_exists($target, $segment); - if (!empty($segments)) { + if ($position < count($segments)) { if (!$propertyExists) { $target->{$segment} = []; } - self::setValueBySegments($target->{$segment}, $segments, $value, $overwrite); + + self::setValueBySegments($target->{$segment}, $segments, $position, $value, $overwrite); } else { if ($overwrite || !$propertyExists) { $target->{$segment} = $value; @@ -289,21 +307,6 @@ private static function setValueObject(object &$target, string $segment, array $ } } - /** - * @param array $segments - */ - private static function shiftSegment(array &$segments): ?string - { - if ($segments === []) { - return null; - } - - $segment = $segments[0] ?? null; - array_shift($segments); - - return is_string($segment) ? $segment : null; - } - /** * Parse a dot path into escaped segments and cache compiled segments. * diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index ef85b1d..b8570a8 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -143,9 +143,11 @@ public static function traverseGet( bool $throwOnTooDeep = false, int $currentDepth = 1, int &$visitedNodes = 0, + int $position = 0, ): mixed { - foreach ($segments as $index => $segment) { - unset($segments[$index]); + $segmentCount = count($segments); + for ($index = $position; $index < $segmentCount; $index++) { + $segment = $segments[$index]; $visitedNodes++; if ($maxNodes > 0 && $visitedNodes > $maxNodes) { @@ -164,6 +166,7 @@ public static function traverseGet( $throwOnTooDeep, $currentDepth, $visitedNodes, + $index + 1, ); } @@ -210,6 +213,21 @@ private static function handleTraversalLimit(object $missing, bool $throwOnTooDe return $missing; } + /** + * @param array $segments + */ + private static function hasWildcardFrom(array $segments, int $position): bool + { + $segmentCount = count($segments); + for ($index = $position; $index < $segmentCount; $index++) { + if ($segments[$index] === '*') { + return true; + } + } + + return false; + } + /** * Resolve the {first} segment for an array-like target. */ @@ -269,6 +287,7 @@ private static function traverseWildcard( bool $throwOnTooDeep, int $currentDepth, int &$visitedNodes, + int $position, ): mixed { $target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target; @@ -289,10 +308,12 @@ private static function traverseWildcard( $throwOnTooDeep, $currentDepth, $visitedNodes, + $position, ); $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; } - if (in_array('*', $segments, true)) { + + if (self::hasWildcardFrom($segments, $position)) { $result = ArrayMulti::collapse($result); } diff --git a/src/Collection/Concerns/BaseCollectionTrait.php b/src/Collection/Concerns/BaseCollectionTrait.php index 06b3566..291fff2 100644 --- a/src/Collection/Concerns/BaseCollectionTrait.php +++ b/src/Collection/Concerns/BaseCollectionTrait.php @@ -219,18 +219,6 @@ public function count(): int return count($this->data); } - /** - * Returns the current element in the collection. - * - * This is part of the Iterator interface. - * - * @return mixed The current element in the collection. - */ - public function current(): mixed - { - return current($this->data); - } - /** * Retrieve an item from the collection by key or keys. * @@ -380,19 +368,6 @@ public function jsonSerialize(): array ); } - /** - * Return the key of the current element. - * - * This is part of the Iterator interface. - * - * @return string|int|null The key of the current element, or null if the - * internal pointer is not valid. - */ - public function key(): string|int|null - { - return key($this->data); - } - /** * Return an array of all the keys in the collection. * @@ -415,16 +390,6 @@ public function merge(mixed $items): static return $this; } - /** - * Advances the internal pointer to the next element. - * - * This is part of the Iterator interface. - */ - public function next(): void - { - next($this->data); - } - /* |-------------------------------------------------------------------------- | ArrayAccess Interface @@ -549,16 +514,6 @@ public function process(): Pipeline return $this->pipeline ??= new Pipeline($this->data, $this); } - /** - * Rewinds the internal pointer of the collection to the first element. - * - * This is part of the Iterator interface. - */ - public function rewind(): void - { - reset($this->data); - } - /** * Set one or multiple items in the collection using dot notation. * @@ -596,16 +551,4 @@ public function toJson(int $options = 0): string return $json === false ? 'null' : $json; } - - /** - * Checks if the current element is valid. - * - * This is part of the Iterator interface. - * - * @return bool True if the current element is valid, false otherwise. - */ - public function valid(): bool - { - return key($this->data) !== null; - } } diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 82df72f..8db5ad3 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -608,6 +608,15 @@ protected function resolveDefault(mixed $default): mixed 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) { return DotNotation::get($this->items, $key, $this->missingValueMarker()); } diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php index 308b527..ae6f171 100644 --- a/src/DTO/Concerns/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -16,8 +16,7 @@ trait DTOTrait * Create a new instance of the using class and populate * its public properties from the given array. * - * Unknown keys are ignored. Only properties matching - * class property names will be set. + * Unknown keys are ignored. Matching public properties are assigned. * * @param array $values Key-value pairs matching property names */ diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php new file mode 100644 index 0000000..66771be --- /dev/null +++ b/tests/Feature/ApiSignatureTest.php @@ -0,0 +1,162 @@ + ArrayKit::class, + 'Facade ModuleProxy' => ModuleProxy::class, + 'BaseArrayHelper' => BaseArrayHelper::class, + 'ArraySharedOps (Internal)' => ArraySharedOps::class, + 'ArraySingle' => ArraySingle::class, + 'ArrayMulti' => ArrayMulti::class, + 'ArrayShape' => ArrayShape::class, + 'DotNotation' => DotNotation::class, + 'Collection' => Collection::class, + 'HookedCollection' => HookedCollection::class, + 'Pipeline' => Pipeline::class, + 'Config' => Config::class, + 'LazyFileConfig' => LazyFileConfig::class, + 'Config Hook-Aware Variants' => Config::class, + 'DTOTrait' => DTOTrait::class, + 'HookTrait' => HookTrait::class, + 'LazyCollection' => LazyCollection::class, + ]; + + $normalizeType = static function (?string $type): array { + if ($type === null || $type === '') { + return []; + } + + $types = str_starts_with($type, '?') + ? [substr($type, 1), 'null'] + : explode('|', $type); + + $normalized = array_map(static function (string $part): string { + $part = ltrim(trim($part), '\\'); + + return str_contains($part, '\\') ? basename(str_replace('\\', '/', $part)) : $part; + }, $types); + sort($normalized); + + return $normalized; + }; + + $reflectionType = static function (?ReflectionType $type) use ($normalizeType): array { + if ($type === null) { + return []; + } + + if ($type instanceof ReflectionUnionType) { + $parts = array_map(static fn(ReflectionNamedType $part): string => $part->getName(), $type->getTypes()); + + return $normalizeType(implode('|', $parts)); + } + + if ($type instanceof ReflectionIntersectionType) { + $parts = array_map(static fn(ReflectionNamedType $part): string => $part->getName(), $type->getTypes()); + sort($parts); + + return $parts; + } + + $name = $type->getName(); + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + $name .= '|null'; + } + + return $normalizeType($name); + }; + + $parseParameter = static function (string $parameter) use ($normalizeType): array { + $parameter = preg_replace('/^(?:(?:public|protected|private|readonly)\s+)+/', '', trim($parameter)); + preg_match( + '/^(?:(?[?\\\\A-Za-z_][\\\\A-Za-z0-9_|?]*)\s+)?(?&)?(?\.\.\.)?\$(?[A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*(?.+))?$/', + (string) $parameter, + $matches, + ); + + return [ + 'name' => $matches['name'] ?? '', + 'type' => $normalizeType($matches['type'] ?? null), + 'reference' => ($matches['reference'] ?? '') === '&', + 'variadic' => ($matches['variadic'] ?? '') === '...', + 'hasDefault' => array_key_exists('default', $matches) && $matches['default'] !== '', + 'default' => isset($matches['default']) ? ltrim(trim($matches['default']), '\\') : null, + ]; + }; + + $document = file_get_contents(__DIR__ . '/../../docs/rule-reference.rst'); + expect($document)->not->toBeFalse(); + $lines = preg_split('/\R/', (string) $document); + expect($lines)->toBeArray(); + + $activeClass = null; + foreach ($lines as $index => $line) { + $nextLine = $lines[$index + 1] ?? ''; + if (preg_match('/^-{3,}$/', $nextLine) === 1) { + $activeClass = $sections[$line] ?? null; + + continue; + } + + if ($activeClass === null || preg_match('/^\s+public (?static )?function (?[A-Za-z_][A-Za-z0-9_]*)\((?.*)\)(?:: (?[^\/]+))?/', $line, $matches) !== 1) { + continue; + } + + $method = new ReflectionMethod($activeClass, $matches['name']); + $documentedParameters = trim($matches['parameters']) === '' + ? [] + : array_map($parseParameter, preg_split('/,\s*/', $matches['parameters'])); + $actualParameters = $method->getParameters(); + + expect($method->isPublic())->toBeTrue($activeClass . '::' . $method->getName()) + ->and($method->isStatic())->toBe(($matches['static'] ?? '') !== '', $activeClass . '::' . $method->getName()) + ->and($documentedParameters)->toHaveCount(count($actualParameters), $activeClass . '::' . $method->getName()) + ->and($reflectionType($method->getReturnType()))->toBe( + $normalizeType(isset($matches['return']) ? trim($matches['return']) : null), + $activeClass . '::' . $method->getName() . ' return type', + ); + + foreach ($actualParameters as $parameterIndex => $actual) { + $documented = $documentedParameters[$parameterIndex]; + $actualDefault = null; + if ($actual->isDefaultValueAvailable()) { + if ($actual->isDefaultValueConstant()) { + $constantName = ltrim((string) $actual->getDefaultValueConstantName(), '\\'); + $actualDefault = basename(str_replace('\\', '/', $constantName)); + } elseif ($actual->getDefaultValue() === null) { + $actualDefault = 'null'; + } elseif ($actual->getDefaultValue() === []) { + $actualDefault = '[]'; + } else { + $actualDefault = var_export($actual->getDefaultValue(), true); + } + } + + expect($documented['name'])->toBe($actual->getName(), $activeClass . '::' . $method->getName()) + ->and($documented['type'])->toBe($reflectionType($actual->getType()), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) + ->and($documented['reference'])->toBe($actual->isPassedByReference(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) + ->and($documented['variadic'])->toBe($actual->isVariadic(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) + ->and($documented['hasDefault'])->toBe($actual->isDefaultValueAvailable(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) + ->and($documented['default'])->toBe($actualDefault, $activeClass . '::' . $method->getName() . ' $' . $actual->getName()); + } + } +}); diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index b7d6a6f..f2513dc 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -397,18 +397,42 @@ ]); }); -it('keeps null and missing grouping keys separate', function () { +it('skips missing derived fields without colliding with literal user values', function () { $rows = [ - ['id' => 1, 'role' => null], - ['id' => 2], + ['id' => 1, 'role' => '_undefined'], + ['id' => 2, 'role' => ''], + ['id' => 3], ]; expect(ArrayMulti::groupBy($rows, 'role'))->toBe([ - '' => [['id' => 1, 'role' => null]], - '_undefined' => [['id' => 2]], + '_undefined' => [['id' => 1, 'role' => '_undefined']], + '' => [['id' => 2, 'role' => '']], + ])->and(ArrayMulti::keyBy($rows, 'role'))->toBe([ + '_undefined' => ['id' => 1, 'role' => '_undefined'], + '' => ['id' => 2, 'role' => ''], + ])->and(ArrayMulti::indexBy($rows, 'role'))->toBe([ + '_undefined' => ['id' => 1, 'role' => '_undefined'], + '' => ['id' => 2, 'role' => ''], + ])->and(ArrayMulti::countBy($rows, 'role'))->toBe([ + '_undefined' => 1, + '' => 1, ]); }); +it('rejects null and other invalid derived array keys', function (mixed $invalid) { + $rows = [['id' => 1, 'group' => $invalid]]; + + expect(fn () => ArrayMulti::groupBy($rows, 'group'))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArrayMulti::keyBy($rows, 'group'))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArrayMulti::countBy($rows, 'group'))->toThrow(InvalidArgumentException::class); +})->with([ + 'null' => [null], + 'boolean' => [false], + 'float' => [1.5], + 'array' => [[]], + 'object' => [new stdClass()], +]); + it('handles null values and missing keys correctly in whereNotIn()', function () { $rows = [ ['id' => 1, 'role' => null], @@ -540,6 +564,47 @@ ]); }); +it('preserves strict derived equality for adversarial values', function () { + $firstObject = new stdClass(); + $secondObject = new stdClass(); + $resource = fopen('php://memory', 'rb'); + + $rows = [ + ['value' => 0], + ['value' => '0'], + ['value' => false], + ['value' => 0.0], + ['value' => ['nested' => [1, '1']]], + ['value' => ['nested' => [1, '1']]], + ['value' => $firstObject], + ['value' => $firstObject], + ['value' => $secondObject], + ['value' => $resource], + ['value' => $resource], + ['value' => NAN], + ['value' => NAN], + ]; + + expect(array_keys(ArrayMulti::uniqueBy($rows, 'value', true))) + ->toBe([0, 1, 2, 3, 4, 6, 8, 9, 11, 12]) + ->and(array_keys(ArrayMulti::duplicatesBy($rows, 'value', true))) + ->toBe([5, 7, 10]); + + fclose($resource); +}); + +it('handles large strict derived sets without changing key order', function () { + $rows = []; + for ($index = 0; $index < 10000; $index++) { + $rows['row-' . $index] = [ + 'derived' => ['id' => $index % 5000, 'payload' => str_repeat('x', 96)], + ]; + } + + expect(ArrayMulti::uniqueBy($rows, 'derived', true))->toHaveCount(5000) + ->and(array_key_first(ArrayMulti::duplicatesBy($rows, 'derived', true)))->toBe('row-5000'); +}); + it('supports sortByMany with mixed sort directions', function () { $rows = [ ['team' => 'A', 'score' => 10, 'id' => 2], diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index 93a887b..878a04c 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -80,6 +80,23 @@ ->and(ArraySingle::containsAny([INF], [-INF], true))->toBeFalse(); }); +it('isolates deterministic shuffle state from the global Mersenne Twister', function () { + $values = range(1, 20); + + expect(ArraySingle::shuffle($values, 12345)) + ->toBe(ArraySingle::shuffle($values, 12345)) + ->not->toBe(ArraySingle::shuffle($values, 54321)); + + mt_srand(9876); + $first = mt_rand(); + $second = mt_rand(); + + mt_srand(9876); + expect(mt_rand())->toBe($first); + ArraySingle::shuffle($values, 12345); + expect(mt_rand())->toBe($second); +}); + it('does not retry callbacks that throw argument count errors internally', function () { $calls = 0; $exception = null; diff --git a/tests/Feature/BaseArrayHelperTest.php b/tests/Feature/BaseArrayHelperTest.php index 145e32a..37a2244 100644 --- a/tests/Feature/BaseArrayHelperTest.php +++ b/tests/Feature/BaseArrayHelperTest.php @@ -15,6 +15,19 @@ expect($wrapped)->toBe(['hello']); }); +it('preserves every falsey value except null when wrapping', function (mixed $value, array $expected) { + expect(BaseArrayHelper::wrap($value))->toBe($expected); +})->with([ + 'null' => [null, []], + 'empty array' => [[], []], + 'false' => [false, [false]], + 'integer zero' => [0, [0]], + 'float zero' => [0.0, [0.0]], + 'negative float zero' => [-0.0, [-0.0]], + 'numeric zero string' => ['0', ['0']], + 'empty string' => ['', ['']], +]); + it('checks if at least one item meets a condition', function () { $data = [1, 2, 3]; $res = BaseArrayHelper::haveAny($data, fn ($val) => $val > 2); diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index fb951c0..48280ac 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -173,6 +173,62 @@ ->and($cfg->get('app.name'))->toBe('ArrayKitX'); }); +it('bypasses memoization for direct top-level reads', function () { + $cfg = new Config; + $cfg->loadArray([ + 'debug' => false, + 'zero' => 0, + 'nullable' => null, + ]); + + for ($index = 0; $index < 100; $index++) { + expect($cfg->get('debug'))->toBeFalse() + ->and($cfg->get('zero'))->toBe(0) + ->and($cfg->get('nullable', 'fallback'))->toBeNull() + ->and($cfg->get('missing', 'fallback'))->toBe('fallback'); + } + + $cacheSize = (fn (): int => count($this->resolvedValueCache))->call($cfg); + expect($cacheSize)->toBe(0); +}); + +it('invalidates memoized nested reads after every mutation family', function () { + $cfg = new Config; + $cfg->loadArray(['app' => ['value' => 1]]); + + expect($cfg->get('app.value'))->toBe(1); + + $cfg->set('app.value', 2); + expect($cfg->get('app.value'))->toBe(2); + + $cfg->set(['app.value' => 3, 'app.extra' => 'set']); + expect($cfg->get('app.value'))->toBe(3); + + $cfg->fill('app.filled', 4); + expect($cfg->get('app.filled'))->toBe(4); + + $cfg->forget('app.filled'); + expect($cfg->get('app.filled', 'missing'))->toBe('missing'); + + $cfg->replace(['app' => ['value' => 5]]); + expect($cfg->get('app.value'))->toBe(5); + + $cfg->merge(['app' => ['value' => 6]]); + expect($cfg->get('app.value'))->toBe(6); + + $cfg->overlay(['app' => ['value' => 7]]); + expect($cfg->get('app.value'))->toBe(7); + + $cfg->snapshot(); + $cfg->set('app.value', 8); + expect($cfg->get('app.value'))->toBe(8) + ->and($cfg->restore())->toBeTrue() + ->and($cfg->get('app.value'))->toBe(7); + + $cfg->reload(['app' => ['value' => 9]]); + expect($cfg->get('app.value'))->toBe(9); +}); + it('bounds the in-memory read cache for long-running processes', function () { $cfg = new Config; diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index 02f6770..f439755 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -196,6 +196,15 @@ ->and(DotNotation::has($data, 'service\\.name'))->toBeTrue(); }); +it('supports escaped backslashes without corrupting compiled paths', function () { + $data = [ + 'root\\name' => ['value' => 'found'], + ]; + + expect(DotNotation::get($data, 'root\\\\name.value'))->toBe('found') + ->and(DotNotation::get($data, 'root\\\\name.value'))->toBe('found'); +}); + it('retrieves multiple keys when passed an array', function () { $data = [ 'user' => ['name' => 'Carol', 'email' => 'carol@example.com'], @@ -288,6 +297,24 @@ ]); }); +it('supports multiple wildcards and missing wildcard branches', function () { + $data = [ + 'companies' => [ + ['teams' => [['name' => 'A'], ['name' => 'B']]], + ['teams' => [['name' => 'C'], []]], + ], + ]; + + expect(DotNotation::get($data, 'companies.*.teams.*.name', 'missing')) + ->toBe(['A', 'B', 'C', 'missing']); + + DotNotation::set($data, 'companies.*.teams.*.active', true); + DotNotation::forget($data, 'companies.*.teams.*.active'); + + expect(DotNotation::get($data, 'companies.*.teams.*.active', 'missing')) + ->toBe(['missing', 'missing', 'missing', 'missing']); +}); + it('supports hasWildcard, paths and matches helpers', function () { $data = [ 'users' => [ From db91c4577f68fc63cbfa95f4e382ccbb45211c86 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 10 Aug 2026 14:11:50 +0600 Subject: [PATCH 2/3] updated doc+fixing code issues --- .github/ISSUE_TEMPLATE/bug_report.yml | 84 ++++++-- .github/ISSUE_TEMPLATE/ci_failure.yml | 85 ++++++-- .github/ISSUE_TEMPLATE/docs_improvement.yml | 52 +++-- .github/ISSUE_TEMPLATE/feature_request.yml | 46 +++-- .github/ISSUE_TEMPLATE/question.yml | 42 ++-- .github/PULL_REQUEST_TEMPLATE.md | 126 ++++++++++-- .github/PULL_REQUEST_TEMPLATE/bug_fix.md | 94 +++++++++ .../PULL_REQUEST_TEMPLATE/documentation.md | 51 +++++ .github/PULL_REQUEST_TEMPLATE/feature.md | 106 ++++++++++ .github/PULL_REQUEST_TEMPLATE/maintenance.md | 91 +++++++++ .github/PULL_REQUEST_TEMPLATE/performance.md | 99 ++++++++++ .github/PULL_REQUEST_TEMPLATE/refactor.md | 108 +++++++++++ .../security_reliability.md | 92 +++++++++ CODE_OF_CONDUCT.md | 74 ++++--- CONTRIBUTING.md | 183 +++++++++++++++--- README.md | 26 ++- SECURITY.md | 65 ++++--- 17 files changed, 1239 insertions(+), 185 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE/bug_fix.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/documentation.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/feature.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/maintenance.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/performance.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/refactor.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/security_reliability.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8a5f881..5907267 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,70 +1,120 @@ name: Bug report -description: Report a reproducible problem -title: "[Bug]: " +description: Report reproducible incorrect or regressed behavior labels: - bug body: - type: markdown attributes: value: | - Thanks for reporting a bug. Please include enough detail to reproduce it. + Thanks for reporting a problem. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + + - type: dropdown + id: problem_type + attributes: + label: Problem type + description: Select the option that best describes the problem. + options: + - Bug + - Regression + - Not sure + validations: + required: true + - type: textarea id: summary attributes: label: Summary - description: What is wrong? - placeholder: Clear and short description of the bug. + description: Describe the incorrect behavior and its impact. + placeholder: A clear and concise description of the problem. + validations: + required: true + + - type: input + id: package_version + attributes: + label: Affected package version + placeholder: e.g. 2.4.1 or dev-main@abc1234 validations: required: true + + - type: input + id: last_known_working + attributes: + label: Last known working version or commit + description: Complete this when reporting a regression, if known. + placeholder: e.g. 2.4.0 or abc1234 + + - type: input + id: first_known_broken + attributes: + label: First known broken version or commit + description: Complete this when known. + placeholder: e.g. 2.4.1 or def5678 + - type: textarea id: reproduce attributes: - label: Steps to reproduce - description: Share exact commands, config, and steps. + label: Minimal reproduction + description: Provide the smallest code sample, command, configuration or repository that reproduces the problem. placeholder: | - 1. Run `composer ic:tests` - 2. ... + 1. Install or configure ... + 2. Run ... 3. Observe ... validations: required: true + - type: textarea id: expected attributes: label: Expected behavior - placeholder: What did you expect to happen? + placeholder: Describe what should happen. validations: required: true + - type: textarea id: actual attributes: label: Actual behavior - placeholder: What happened instead? Include full error output if possible. + placeholder: Describe what happens instead. validations: required: true + + - type: textarea + id: error_output + attributes: + label: Relevant output or errors + description: Include only the relevant, sanitized output. + render: shell + - type: input id: php_version attributes: label: PHP version - placeholder: "e.g. 8.3.8" + placeholder: e.g. 8.4.13 validations: required: true + - type: input id: composer_version attributes: label: Composer version - placeholder: "e.g. 2.9.2" + placeholder: e.g. 2.9.2 validations: required: true + - type: textarea id: environment attributes: - label: Environment details - description: OS, CI provider, shell, and anything else relevant. - placeholder: Ubuntu 24.04, GitHub Actions, bash... + label: Environment + description: Include the operating system, relevant extensions, dependency mode, runtime and CI provider when applicable. + placeholder: Ubuntu 24.04, locked dependencies, ext-json enabled, GitHub Actions... validations: required: true + - type: textarea id: additional attributes: label: Additional context - description: Links, screenshots, logs, or related issues. + description: Add related issues, screenshots, logs, workarounds or other useful context. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml index 3dcbac9..9c3883f 100644 --- a/.github/ISSUE_TEMPLATE/ci_failure.yml +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -1,48 +1,101 @@ name: CI failure -description: Report a reproducible CI or workflow failure -title: "[CI]: " +description: Report a reproducible PHPForge or workflow failure labels: - ci body: - type: markdown attributes: value: | - Use this form when CI fails unexpectedly and can be reproduced. + Use this form when a CI workflow or PHPForge check fails unexpectedly. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: input id: workflow attributes: - label: Workflow/job name - placeholder: security-standards / phpforge + label: Workflow and job + placeholder: e.g. CI / PHP 8.4 validations: required: true + - type: input id: run_url attributes: label: Failing run URL + description: Provide a link when the run is accessible. placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... - validations: - required: true + - type: textarea - id: command + id: failing_step attributes: - label: Failing command - description: Exact command or step that failed. + label: Failing step or command + description: Include the exact workflow step or command that failed. placeholder: composer ic:ci + render: shell validations: required: true + - type: textarea id: logs attributes: - label: Error output - description: Paste the relevant error section. + label: Relevant error output + description: Paste the smallest useful, sanitized error section. render: shell validations: required: true - - type: textarea - id: local_check + + - type: dropdown + id: local_result attributes: label: Local reproduction - description: Can you reproduce locally? If yes, include steps. - placeholder: Yes/No + details + description: Does the same failure occur when running the relevant command locally? + options: + - Yes + - No + - Not attempted + validations: + required: true + + - type: textarea + id: local_details + attributes: + label: Local reproduction details + description: Include the command, result and any differences from CI. + placeholder: composer ic:ci fails locally with the same error... + + - type: input + id: php_version + attributes: + label: PHP version + placeholder: e.g. 8.4.13 + validations: + required: true + + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: e.g. 2.9.2 validations: required: true + + - type: textarea + id: environment + attributes: + label: Runner and dependency environment + description: Include the runner OS, dependency mode, relevant extensions, matrix values and PHPForge version when known. + placeholder: ubuntu-latest, prefer-lowest, PHPForge 1.x, ext-json enabled... + validations: + required: true + + - type: textarea + id: recent_changes + attributes: + label: Relevant recent changes + description: Mention dependency, configuration, workflow or source changes that may be related. + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related issues, screenshots, logs or other useful context. diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml index 80b9607..2ea49e9 100644 --- a/.github/ISSUE_TEMPLATE/docs_improvement.yml +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -1,34 +1,58 @@ -name: Docs improvement -description: Report missing, unclear, or incorrect documentation -title: "[Docs]: " +name: Documentation improvement +description: Report missing, outdated, unclear or incorrect documentation labels: - documentation body: - - type: textarea + - type: dropdown + id: problem_type + attributes: + label: Documentation problem + options: + - Incorrect + - Outdated + - Missing + - Unclear + - Example needed + - Other + validations: + required: true + + - type: input id: location attributes: label: Documentation location - description: File path or URL. - placeholder: README.md section "Quick Start" + description: Provide the file path, section, symbol or URL. + placeholder: README.md — Quick Start validations: required: true + - type: textarea - id: issue + id: problem attributes: - label: What is unclear or incorrect? - placeholder: This section says... + label: Problem + description: Explain what is missing, unclear, outdated or incorrect. + placeholder: The current documentation says or omits... validations: required: true + - type: textarea - id: suggestion + id: expected attributes: - label: Suggested improvement - description: Propose revised wording, structure, or examples. - placeholder: It would be clearer if... + label: Expected documentation + description: Describe what readers should be able to understand or accomplish. + placeholder: Readers should be able to... validations: required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Optionally propose wording, structure, examples or references. + placeholder: It may be clearer to... + - type: textarea id: additional attributes: label: Additional context - description: Related links, screenshots, or prior discussions. + description: Add related links, screenshots, discussions or examples. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cc29614..bbee6d0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,44 +1,54 @@ name: Feature request -description: Suggest an improvement or new capability -title: "[Feature]: " +description: Suggest a new capability or improvement labels: - enhancement body: - type: markdown attributes: value: | - Thanks for the idea. Please describe the use case first, then the proposed solution. + Describe the problem or use case before proposing an implementation. + + For substantial public API, architectural or compatibility changes, discussion may be requested before implementation. + - type: textarea id: problem attributes: label: Problem or use case - description: What limitation are you hitting? - placeholder: I need to... + description: Explain the limitation, repeated difficulty or capability you need. + placeholder: I need to... because... validations: required: true + - type: textarea - id: proposal + id: proposed_behavior attributes: - label: Proposed solution - description: What should happen? - placeholder: Add a command/config/workflow option that... + label: Proposed behavior + description: Describe the expected user-facing behavior or outcome. + placeholder: The library should... validations: required: true + + - type: textarea + id: example + attributes: + label: Example usage + description: Optionally show the proposed API, configuration, command or workflow. + render: php + - type: textarea id: alternatives attributes: - label: Alternatives considered - description: Any workaround or alternative approach you evaluated. + label: Alternatives or workarounds + description: Describe existing approaches you considered or currently use. + - type: textarea - id: impact + id: compatibility attributes: - label: Expected impact - description: Who benefits and what changes for users/CI? - placeholder: This would improve... - validations: - required: true + label: Compatibility considerations + description: Mention possible public API, behavior, PHP-version, extension, platform or dependency implications. + - type: textarea id: additional attributes: label: Additional context - description: Related issues, links, examples, or prior art. + description: Add related issues, prior art, links, benchmarks or other supporting information. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2ca776f..62e91a7 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,40 +1,56 @@ name: Question -description: Ask a usage or integration question -title: "[Question]: " +description: Ask about usage, behavior, integration or configuration labels: - question body: - type: markdown attributes: value: | - Use this form for usage questions. For confirmed defects, use the bug report form. + Use this form for usage and integration questions. Use the bug form for reproducible incorrect behavior. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: textarea - id: context + id: goal attributes: label: What are you trying to do? - description: Describe your goal and expected outcome. + description: Describe the goal and expected outcome. placeholder: I want to... validations: required: true + - type: textarea id: attempted attributes: label: What have you tried? - description: Include commands, config snippets, or links you already checked. + description: Include relevant code, commands, configuration, documentation or approaches already checked. placeholder: I tried... validations: required: true + + - type: textarea + id: relevant_code + attributes: + label: Relevant code or configuration + description: Include a minimal sanitized example when applicable. + render: php + - type: textarea id: output attributes: - label: Current output or behavior - description: Include relevant command output, logs, or errors. + label: Relevant output or errors + description: Include sanitized output only when it helps explain the question. render: shell + - type: textarea id: environment attributes: - label: Environment details - description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... - validations: - required: true + label: Environment + description: Include package, PHP, Composer, OS, extensions or CI details only when relevant. + placeholder: Package 2.4.1, PHP 8.4, Composer 2.9, Ubuntu 24.04... + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related links, screenshots or prior discussions. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 59ae734..dcab1f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,33 +1,121 @@ ## Summary -Describe what changed and why. +Describe what changed, why it was needed and the expected behavior. -## Related Issues + -Link issues with `Closes #...` or `Relates #...`. +## Change -## Type of Change +### Type -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor -- [ ] Documentation update -- [ ] CI or tooling update -- [ ] Other (describe in summary) +* [ ] Bug fix +* [ ] New feature +* [ ] Refactor +* [ ] Performance +* [ ] Security or reliability +* [ ] Documentation or examples +* [ ] Dependency, CI or tooling +* [ ] Other + +### Behavior and Compatibility + +* [ ] No observable behavior changed +* [ ] Existing behavior was corrected +* [ ] New behavior was introduced +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + ## Validation -List the commands you ran and their result. +* [ ] `composer ic:ci` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Implementation Notes + + + +## Review Focus -```bash -composer ic:tests -``` + ## Checklist -- [ ] I followed `CONTRIBUTING.md`. -- [ ] I added or updated tests for behavior changes. -- [ ] I updated docs/config/examples when needed. -- [ ] I confirmed no security-sensitive data is exposed. +* [ ] The change is focused and excludes unrelated modifications. +* [ ] Tests cover new, corrected and regression-prone behavior. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation, examples and type information were updated where required. +* [ ] Performance claims are supported by reproducible benchmarks. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md new file mode 100644 index 0000000..66759b4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -0,0 +1,94 @@ +## Problem + +Describe the incorrect behavior, its impact and how it can be reproduced. + + + +## Root Cause + + + +## Fix + +Describe how the change corrects the problem and the expected behavior after the fix. + +## Behavior and Compatibility + +* [ ] Existing documented behavior was restored +* [ ] Existing undocumented behavior was corrected +* [ ] Public API remains compatible +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] The original failure no longer reproduces +* [ ] A regression test was added or updated +* [ ] Relevant boundary and failure paths were tested + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The fix is focused and excludes unrelated changes. +* [ ] The fix addresses the root cause rather than only masking symptoms. +* [ ] Regression-prone behavior is covered by tests. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and examples were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/documentation.md b/.github/PULL_REQUEST_TEMPLATE/documentation.md new file mode 100644 index 0000000..f983b85 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/documentation.md @@ -0,0 +1,51 @@ +## Documentation Change + +Describe what is being added, corrected, clarified or removed and why. + + + +## Affected Content + +* [ ] README or getting-started guidance +* [ ] API or reference documentation +* [ ] Configuration documentation +* [ ] Examples or tutorials +* [ ] Contribution or community documentation +* [ ] Changelog or release documentation +* [ ] Other + +## Verification + +* [ ] Links and references were checked +* [ ] Code examples were executed or otherwise verified +* [ ] Commands and configuration examples match current behavior +* [ ] Terminology is consistent with the project +* [ ] `composer ic:ci` +* [ ] No executable behavior changed + + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated code changes. +* [ ] Documentation reflects the current public behavior. +* [ ] Examples are minimal, accurate and safe to copy. +* [ ] Sensitive or private information is not included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md new file mode 100644 index 0000000..f6ce9c6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -0,0 +1,106 @@ +## Motivation + +Describe the problem, use case or capability this feature addresses. + + + +## Solution + +Describe the proposed behavior and how consumers are expected to use it. + +## API and Compatibility + +* [ ] No new public API +* [ ] New backward-compatible public API +* [ ] Existing public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Expected behavior is covered +* [ ] Boundary and edge cases are covered +* [ ] Failure and exception paths are covered +* [ ] Public API usage is covered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Review Focus + + + +## Checklist + +* [ ] The feature is focused and excludes unrelated changes. +* [ ] Tests cover the public contract and failure behavior. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation, examples and type information were updated. +* [ ] Performance claims are supported by reproducible benchmarks. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/maintenance.md b/.github/PULL_REQUEST_TEMPLATE/maintenance.md new file mode 100644 index 0000000..a4cab0a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/maintenance.md @@ -0,0 +1,91 @@ +## Maintenance Change + +Describe what changed, why it was needed and the expected effect on development, CI or releases. + + + +## Category + +* [ ] Dependency update +* [ ] CI or workflow change +* [ ] Build or release tooling +* [ ] PHPForge configuration +* [ ] Development tooling +* [ ] Repository maintenance +* [ ] Other + +## Impact and Compatibility + +* [ ] Runtime behavior is unaffected +* [ ] Development workflow changed +* [ ] CI or release behavior changed +* [ ] Supported PHP, extension, platform or dependency requirements changed +* [ ] Generated files or configuration changed +* [ ] Backward compatibility may be affected + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Relevant workflow or job was exercised +* [ ] Supported matrix or dependency mode was considered +* [ ] Generated or published files were verified +* [ ] Failure and rollback behavior was considered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated source refactoring. +* [ ] Dependency or workflow changes are minimal and justified. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and generated files were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/PULL_REQUEST_TEMPLATE/performance.md new file mode 100644 index 0000000..ce2d10d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/performance.md @@ -0,0 +1,99 @@ +## Bottleneck + +Describe the measured performance problem, affected execution path and practical impact. + + + +## Optimization + +Describe the change, why it improves the measured path and any trade-offs introduced. + +## Correctness and Compatibility + +* [ ] Observable behavior remains unchanged +* [ ] Public API remains compatible +* [ ] Error and exception behavior remains compatible +* [ ] Behavior or public API changed intentionally +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Benchmark Evidence + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] Multiple stable runs were considered +* [ ] Runtime impact was measured +* [ ] Memory or allocation impact was measured where relevant +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Expected behavior remains covered +* [ ] Boundary and failure paths remain covered +* [ ] Performance-sensitive behavior is covered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The optimization targets a measured bottleneck. +* [ ] Results are reproducible in comparable environments. +* [ ] Correctness was not traded for an unverified micro-optimization. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Benchmark and documentation changes are included where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md new file mode 100644 index 0000000..588be4f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md @@ -0,0 +1,108 @@ +## Intent and Scope + +Describe what was restructured, why it was necessary and what remains intentionally unchanged. + + + +## Behavioral Guarantee + +* [ ] No observable behavior changed +* [ ] Public API remains unchanged +* [ ] Existing behavior was intentionally corrected +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected + + + +## Design Notes + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Existing behavior remains covered +* [ ] Relevant regression and edge cases are covered +* [ ] Public API compatibility was verified + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Review Focus + + + +## Checklist + +* [ ] The refactor is focused and excludes unrelated behavior changes. +* [ ] Complexity was reduced without unnecessary abstraction or file growth. +* [ ] Existing contracts and failure behavior remain covered. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and type information were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/security_reliability.md b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md new file mode 100644 index 0000000..ef2ca28 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md @@ -0,0 +1,92 @@ + + +## Concern + +Describe the security weakness, reliability failure mode or defensive gap being addressed. + + + +## Mitigation + +Describe how the change reduces the risk and what assumptions or limitations remain. + +## Impact and Compatibility + +* [ ] Security hardening with no observable behavior change +* [ ] Reliability improvement with no public API change +* [ ] Failure or exception behavior changed +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Security-sensitive or failure behavior is covered +* [ ] Abuse, malformed-input or failure paths are covered +* [ ] Regression coverage was added or updated +* [ ] `composer ic:test:security` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] Confidential vulnerability details are not exposed publicly. +* [ ] The change is focused and avoids unrelated refactoring. +* [ ] Security or reliability claims are supported by tests. +* [ ] Failure paths and backward-compatibility implications were considered. +* [ ] Documentation and upgrade guidance were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `SECURITY.md`, `CONTRIBUTING.md` and the engineering principles. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9c2638f..eff64bb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,49 +2,71 @@ ## Our Commitment -We are committed to making participation in this project a harassment-free -experience for everyone, regardless of age, body size, disability, ethnicity, -gender identity and expression, level of experience, nationality, personal -appearance, race, religion or sexual identity and orientation. +We are committed to providing a welcoming, inclusive and harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity or expression, level of experience, nationality, personal appearance, race, religion, sexual identity or sexual orientation. ## Expected Behavior -Examples of behavior that contributes to a positive environment: +Examples of behavior that contributes to a positive environment include: -- Be respectful and constructive. -- Assume good intent and ask clarifying questions. -- Give and receive feedback professionally. -- Focus on what is best for the community and project. +* Being respectful, constructive and considerate +* Giving and receiving feedback professionally +* Disagreeing with ideas without attacking individuals +* Respecting differing viewpoints and experiences +* Accepting responsibility, apologizing when appropriate and learning from mistakes +* Focusing on what is best for the project and its community ## Unacceptable Behavior Examples of unacceptable behavior include: -- Harassment, discrimination or personal attacks. -- Trolling, insulting or derogatory comments. -- Publishing private information without consent. -- Any conduct that is inappropriate in a professional setting. - -## Enforcement Responsibilities - -Project maintainers are responsible for clarifying and enforcing this code of -conduct. They may remove, edit or reject comments, commits, code, issues, and -other contributions that violate this policy. +* Harassment, discrimination, intimidation or personal attacks +* Trolling, insults, threats or derogatory comments +* Sexualized language, imagery or unwanted attention +* Repeated disruption of discussions or project activities +* Publishing private or identifying information without permission +* Retaliating against anyone who reports an incident or participates in an investigation +* Any conduct that would reasonably be considered inappropriate in a professional setting ## Scope This code of conduct applies in all project spaces, including: -- Issue trackers -- Pull requests -- Discussions and chat related to the project -- Any public or private communication where someone represents the project +* Issues, pull requests and code reviews +* Discussions and project-related chat +* Documentation, commits and other contributions +* Public or private communication where an individual represents the project or its community ## Reporting -To report unacceptable behavior, contact project maintainers privately. +Report unacceptable behavior privately to the project maintainers. + +Do not include sensitive incident details in a public issue, discussion or pull request. When no private contact method is available, open a public issue requesting a private communication channel without describing the incident. + +Reports should include, when available: + +* A description of what occurred +* Relevant links, screenshots or other supporting information +* The approximate date and location of the incident +* Any immediate safety or confidentiality concerns + +All reports will be reviewed as confidentially and impartially as reasonably possible. Information will be shared only when necessary to investigate and respond to the report. + +## Enforcement Responsibilities + +Project maintainers are responsible for interpreting and enforcing this code of conduct. + +Maintainers may remove, edit or reject comments, commits, code, issues, pull requests and other contributions that violate this policy. Maintainers who have a conflict of interest regarding a report should not participate in its review. ## Enforcement -Maintainers may take any action they deem appropriate, including warnings, -temporary bans or permanent bans from community participation. +Actions will be based on the severity, frequency and context of the behavior and may include: + +* A private warning +* Removal or editing of inappropriate content +* Temporary restrictions on project participation +* Permanent removal from project spaces +* Reporting serious threats or unlawful conduct to the relevant platform or authorities + +Enforcement decisions should be proportionate, documented privately and applied consistently. + +Retaliation against reporters, witnesses or participants in an investigation is prohibited. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9950065..ad81ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ # Contributing -Thanks for contributing. +Thanks for contributing to this project. ## Before You Start -- Review the project code of conduct. -- For security issues, use private reporting and avoid opening a public issue. -- Check existing issues and pull requests first to avoid duplicates. +* Review `CODE_OF_CONDUCT.md`. +* Report security vulnerabilities privately according to `SECURITY.md`. +* Search existing issues and pull requests to avoid duplicate work. +* An issue is not required for small fixes or improvements discovered during development. +* Discuss substantial API, architectural or compatibility changes before implementation. ## Local Setup -Requirements: - -- See `README.md` for current PHP and Composer requirements. +Review `README.md` and `composer.json` for supported PHP versions, extensions, dependencies and project-specific requirements. Install dependencies: @@ -20,49 +20,180 @@ Install dependencies: composer install ``` +Inspect the detected PHPForge configuration: + +```bash +composer ic:doctor +``` + +Do not modify files inside `vendor/`. + +## Engineering Standards + +Before changing or reviewing code, read and follow: + +```text +vendor/infocyph/phpforge/resources/engineering-principles.md +``` + +These principles apply equally to human contributors and automated coding agents. They define the expected approach to implementation decisions, scope control, architecture, performance, security, compatibility, testing and maintainability. + +Project-specific requirements may extend these principles but should not silently weaken them. + ## Development Workflow -Typical contributor workflow: +1. Create a branch from the repository’s default branch. +2. Make one focused logical change. +3. Add or update tests for changed behavior. +4. Run relevant focused checks during development. +5. Apply automated processing where appropriate. +6. Review every automatically modified file. +7. Run the complete CI suite before opening a pull request. +8. Add reproducible benchmark evidence for performance-related changes. +9. Complete the pull request template accurately. + +## Automated Processing + +Run all configured processors: + +```bash +composer ic:process +``` + +Run an individual processor when only a targeted change is needed: + +```bash +composer ic:process:refactor +composer ic:process:lint +composer ic:process:sniff +``` + +Automated processing may modify source files and `composer.json`. Review all resulting changes before committing. -1. Create a branch from `main`. -2. Make focused changes. -3. Run quality checks locally. -4. Open a pull request with context and verification notes. +## Validation -Recommended checks: +Run the complete project validation suite before opening a pull request: ```bash -composer ic:tests +composer ic:ci ``` -Useful targeted commands: +When `composer ic:ci` passes, running the same checks individually is unnecessary. + +Use focused commands while developing or when the complete suite cannot run: + +
+Focused validation commands ```bash composer ic:test:syntax composer ic:test:code composer ic:test:lint composer ic:test:sniff +composer ic:test:duplicates +composer ic:test:probe +composer ic:test:comments +composer ic:test:architecture composer ic:test:static composer ic:test:security -composer ic:test:architecture +composer ic:test:refactor +``` + +
+ +When `composer ic:ci` cannot complete, document: + +* Why it could not complete +* Which focused checks passed +* Relevant PHP, dependency, extension or platform limitations +* Any remaining validation risk + +Do not suppress, baseline, exclude or weaken a check merely to make validation pass. Any configuration or baseline change must be intentional and explained in the pull request. + +## Tests + +Test observable behavior and public contracts rather than internal implementation details. + +Include relevant coverage for: + +* New or corrected behavior +* Regression scenarios +* Boundary and edge cases +* Failure and exception paths +* Public API compatibility +* PHP-version, dependency, extension or platform-sensitive behavior + +A bug fix should normally include a regression test that fails without the fix. + +## Performance Changes + +Run benchmarks when performance is affected or claimed: + +```bash +composer ic:benchmark ``` -Auto-fix and processing helpers: +Additional benchmark commands: ```bash -composer ic:process +composer ic:bench:quick +composer ic:bench:chart +``` + +Performance claims must include reproducible before-and-after results from comparable environments. Avoid conclusions based on a single unstable run. + +Add or update benchmark coverage when existing benchmarks do not represent the changed execution path. + +## Configuration + +Inspect the active PHPForge configuration sources: + +```bash +composer ic:list-config +composer ic:list-config --json +``` + +Publish a configuration file only when the project requires rules that differ from PHPForge defaults: + +```bash +composer ic:publish-config ``` +When changing quality configuration: + +* Explain why the current rule is unsuitable +* Keep exclusions narrow +* Avoid weakening checks globally for one change +* Document compatibility or baseline implications + ## Pull Request Guidelines -- Keep pull requests scoped to one logical change. -- Include why the change is needed and what behavior changed. -- Add or update tests when behavior changes. -- Update docs when command behavior, config, or workflow behavior changes. -- Ensure CI is green before requesting review. +* Keep each pull request limited to one logical change. +* Explain what changed, why it was needed and the expected behavior. +* Identify public API, backward-compatibility, PHP, extension, platform or dependency impacts. +* Select only validation and benchmark checkboxes that reflect work actually performed. +* Add or update tests for behavior changes. +* Update documentation, examples, types and configuration where required. +* Exclude unrelated formatting, refactoring, dependency or generated-file changes. +* Ensure CI passes before requesting review. +* Address review feedback through focused follow-up changes. + +Draft pull requests are welcome for incomplete work or early design feedback, but validation claims and checklist items must remain accurate. ## Reporting Bugs and Requesting Features -- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. -- Include reproducible steps, expected behavior, and actual behavior. -- Share environment details (PHP version, OS, Composer version). +Use the relevant issue template for bugs, regressions, CI failures, documentation problems, questions and feature requests. + +Include when relevant: + +* A clear description of the problem or proposed behavior +* A minimal reproduction +* Expected and actual behavior +* Package and dependency versions +* PHP and Composer versions +* Operating system and relevant extensions +* Logs or error output with sensitive information removed + +Small, self-contained fixes may be submitted directly as pull requests. Larger behavioral, architectural or compatibility changes should be discussed first. + +Security vulnerabilities must not be reported through public issues, discussions or pull requests. diff --git a/README.md b/README.md index b921b5d..6135d13 100644 --- a/README.md +++ b/README.md @@ -312,17 +312,33 @@ $host = $config->get('db.host'); ## Security -Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. +Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Follow [SECURITY.md](SECURITY.md) and use [GitHub private vulnerability reporting](https://github.com/infocyph/ArrayKit/security/advisories/new). + +ArrayKit is protected by [PHPForge](https://github.com/infocyph/PHPForge), which provides automated tests, static and taint analysis, dependency auditing, architecture checks and release-readiness gates. Automated controls do not replace responsible disclosure or manual review. + ---
Made with ❤️ for the PHP community
MIT Licensed
- Documentation • + DocumentationSecurityCode of Conduct • - Contributing • - Report Bug • - Request Feature + Contributing
+ 🗂️ + Bug • + Feature • + Documentation • + Question • + CI failure
+ 🔀 + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance
diff --git a/SECURITY.md b/SECURITY.md index 37a355e..ca14478 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,48 +2,51 @@ ## Supported Versions -The project currently supports security updates for the latest release. +Security updates are provided for the latest stable release. + +Reports affecting older versions are welcome, but fixes may be released only for the latest version. Users should upgrade before confirming whether an issue remains present. ## Reporting a Vulnerability -Please report vulnerabilities privately. +Please report suspected vulnerabilities privately. + +1. Go to `Security` → `Advisories` → `Report a vulnerability`. +2. If private vulnerability reporting is unavailable, open a public issue requesting a private security contact. +3. Do not include vulnerability details in that issue or disclose them through public issues, discussions, pull requests or other public channels. + +Include when available: -1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). -2. If private reporting is unavailable, contact maintainers through a private channel. -3. Do not open a public issue for security vulnerabilities. +* Affected package version and component +* PHP version and runtime environment +* Relevant extensions or dependencies +* Reproduction steps or a minimal proof of concept +* Exploitation requirements and potential impact +* Known workarounds or suggested remediation -Please include: +## Response and Disclosure -- Affected package version(s) -- PHP version and runtime environment -- Reproduction steps or proof of concept -- Impact assessment (confidentiality/integrity/availability) -- Any known workaround +The maintainers will make a best-effort attempt to: -## Response Process +* Acknowledge the report within five business days +* Validate the report and assess its severity +* Coordinate remediation and responsible disclosure +* Publish a fix, mitigation or security advisory when appropriate -- Initial acknowledgment: best effort, typically within a few days -- Triage: best effort, based on maintainer availability -- Fix and release timeline depends on severity and exploitability +Resolution timelines depend on severity, exploitability, complexity and maintainer availability. These targets are not a service-level agreement. -If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. +Please coordinate public disclosure with the maintainers so affected users have a reasonable opportunity to update or apply mitigations. -## Protected by PHPForge +Confirmed reporters will receive credit unless they request anonymity. -This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. +## PHPForge Security Controls -PHPForge helps keep the project reliable by running checks for: +This project uses [PHPForge](https://github.com/infocyph/PHPForge) to automate security and quality checks, including: -- Code style and standards -- Tests and syntax validation -- Static analysis and type safety -- Security and taint analysis -- Dependency vulnerability audit -- Architecture boundary validation -- Duplicate-code detection -- API snapshot and comment-policy checks -- Refactor safety checks -- Benchmark and release-readiness checks -- Git hooks and CI workflow protection +* Test and syntax validation +* Static and taint analysis +* Dependency vulnerability auditing +* Architecture validation +* Release-readiness checks +* Git hooks and CI enforcement -These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. +These controls help reduce security risk and prevent regressions, but they do not guarantee the absence of vulnerabilities or replace manual review and responsible reporting. From 2bd08141558ea91ef52995838330c4ae19353fab Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 10 Aug 2026 14:15:22 +0600 Subject: [PATCH 3/3] updated doc+fixing code issues --- tests/Feature/ApiSignatureTest.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php index 66771be..096b668 100644 --- a/tests/Feature/ApiSignatureTest.php +++ b/tests/Feature/ApiSignatureTest.php @@ -40,7 +40,7 @@ 'LazyCollection' => LazyCollection::class, ]; - $normalizeType = static function (?string $type): array { + $normalizeType = static function (?string $type, ?string $selfType = null): array { if ($type === null || $type === '') { return []; } @@ -49,9 +49,13 @@ ? [substr($type, 1), 'null'] : explode('|', $type); - $normalized = array_map(static function (string $part): string { + $normalized = array_map(static function (string $part) use ($selfType): string { $part = ltrim(trim($part), '\\'); + if ($part === 'self' && $selfType !== null) { + return $selfType; + } + return str_contains($part, '\\') ? basename(str_replace('\\', '/', $part)) : $part; }, $types); sort($normalized); @@ -59,7 +63,7 @@ return $normalized; }; - $reflectionType = static function (?ReflectionType $type) use ($normalizeType): array { + $reflectionType = static function (?ReflectionType $type, ?string $selfType = null) use ($normalizeType): array { if ($type === null) { return []; } @@ -67,7 +71,7 @@ if ($type instanceof ReflectionUnionType) { $parts = array_map(static fn(ReflectionNamedType $part): string => $part->getName(), $type->getTypes()); - return $normalizeType(implode('|', $parts)); + return $normalizeType(implode('|', $parts), $selfType); } if ($type instanceof ReflectionIntersectionType) { @@ -82,7 +86,7 @@ $name .= '|null'; } - return $normalizeType($name); + return $normalizeType($name, $selfType); }; $parseParameter = static function (string $parameter) use ($normalizeType): array { @@ -122,6 +126,7 @@ } $method = new ReflectionMethod($activeClass, $matches['name']); + $declaringType = $method->getDeclaringClass()->getShortName(); $documentedParameters = trim($matches['parameters']) === '' ? [] : array_map($parseParameter, preg_split('/,\s*/', $matches['parameters'])); @@ -130,8 +135,8 @@ expect($method->isPublic())->toBeTrue($activeClass . '::' . $method->getName()) ->and($method->isStatic())->toBe(($matches['static'] ?? '') !== '', $activeClass . '::' . $method->getName()) ->and($documentedParameters)->toHaveCount(count($actualParameters), $activeClass . '::' . $method->getName()) - ->and($reflectionType($method->getReturnType()))->toBe( - $normalizeType(isset($matches['return']) ? trim($matches['return']) : null), + ->and($reflectionType($method->getReturnType(), $declaringType))->toBe( + $normalizeType(isset($matches['return']) ? trim($matches['return']) : null, $declaringType), $activeClass . '::' . $method->getName() . ' return type', ); @@ -152,7 +157,7 @@ } expect($documented['name'])->toBe($actual->getName(), $activeClass . '::' . $method->getName()) - ->and($documented['type'])->toBe($reflectionType($actual->getType()), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) + ->and($documented['type'])->toBe($reflectionType($actual->getType(), $declaringType), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) ->and($documented['reference'])->toBe($actual->isPassedByReference(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) ->and($documented['variadic'])->toBe($actual->isVariadic(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName()) ->and($documented['hasDefault'])->toBe($actual->isDefaultValueAvailable(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName())