diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 81458a2..9096067 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,8 +67,7 @@ jobs:
php vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover=build/logs/clover.xml
- name: Run phpstan
- continue-on-error: true
- if: ${{ matrix.php == '8.0' }}
+ if: ${{ matrix.php == '8.3' && matrix.composer == 'basic' }}
run: |
php vendor/bin/phpstan analyse
@@ -91,3 +90,57 @@ jobs:
name: logs_composer-${{ matrix.composer }}_php-${{ matrix.php }}
path: |
build/logs
+
+ mutation:
+ # Infection 0.32.x requires PHP 8.3+; a full run takes ~12 minutes, so it gets its own job.
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
+ with:
+ php-version: '8.3'
+ coverage: pcov
+ extensions: zip
+ tools: composer
+
+ - name: Determine composer cache directory
+ id: composer-cache
+ run: echo "directory=$(composer config cache-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v5
+ with:
+ path: ${{ steps.composer-cache.outputs.directory }}
+ key: 8.3-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: 8.3-composer-
+
+ - name: Install dependencies
+ run: |
+ composer install --prefer-dist --no-interaction
+ composer dump-autoload -o
+
+ - name: Install Infection
+ run: |
+ curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
+ --output infection.phar \
+ https://github.com/infection/infection/releases/download/0.32.7/infection.phar
+ echo "91ade5625c397719cd39a135bb68b9993242e92ca72b8a94e8fcc12364588937 infection.phar" | sha256sum --check --strict
+ chmod +x infection.phar
+
+ # MSI thresholds live in infection.json.dist (minMsi / minCoveredMsi).
+ - name: Run Infection
+ run: |
+ php infection.phar --threads=max --no-progress --logger-github=false
+
+ - name: Archive Infection logs
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: infection-logs
+ path: |
+ infection-log.txt
+ infection-summary.txt
diff --git a/.gitignore b/.gitignore
index f3a1ab9..586e9fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ build/logs/
# php (infection)
build/infection/
infection-log.txt
+infection-summary.txt
# php (phpcs fixer)
.php_cs.cache
diff --git a/AGENTS.md b/AGENTS.md
index 1621d2e..bfef00a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,6 +58,24 @@ php vendor/bin/phpunit --no-coverage
- Known pre-existing failures (unrelated to feature work): 2 errors (`array_sum` on strings) + 1 failure (sigma case)
- Run targeted tests with `--filter "testMethodName"` to speed up iteration
+### Mutation testing (Infection)
+
+- Config: `infection.json.dist` (source: `src/`, static analysis: PHPStan, 2s mutant timeout)
+- CI runs it in the dedicated `mutation` job (PHP 8.3 + pcov, 30 min timeout); a full run takes ~12 minutes, so it is kept out of the 10-minute test matrix
+- Quality gates are set in the config: `minMsi: 65`, `minCoveredMsi: 75`
+- Baseline (2026-09): 2729 mutants, 2114 killed, 594 escaped, covered-code MSI ~78%, line coverage ~91.6%
+- Run locally (requires PHP 8.3+ and pcov or xdebug):
+
+```bash
+curl -sSL -o infection.phar https://github.com/infection/infection/releases/download/0.32.7/infection.phar
+echo "91ade5625c397719cd39a135bb68b9993242e92ca72b8a94e8fcc12364588937 infection.phar" | sha256sum --check --strict
+php infection.phar --threads=max
+# only mutate one file while iterating on tests:
+php infection.phar --threads=max --filter=src/Arrayy.php --show-mutations
+```
+
+- Escaped mutants are listed in `infection-log.txt`; raise the thresholds when new tests kill more mutants
+
---
## Regenerating README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa24e48..5b9a4dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- add PHPStan + runtime coverage for `meta()` with array-shape-backed models and document the recommended usage in the README
- stabilize the full PHPUnit / PHPStan CI matrix across PHP 8.0–8.5 for both lowest and current dependency sets
- remove stale PHP 8-only compatibility branches, clean up PHPStan ignores, and refresh the PHP 8.0+ docs/CI matrix
+- run Infection mutation testing in a dedicated CI job with enforced MSI thresholds (min MSI 65%, min covered MSI 75%)
### 7.10.0 (2026-04-24)
diff --git a/infection.json.dist b/infection.json.dist
index 7b38b1f..835781d 100644
--- a/infection.json.dist
+++ b/infection.json.dist
@@ -8,8 +8,12 @@
"phpUnit": {
"customPath": "vendor\/bin\/phpunit"
},
+ "staticAnalysisTool": "phpstan",
"tmpDir": "build/infection/",
"logs": {
- "text": "infection-log.txt"
- }
-}
\ No newline at end of file
+ "text": "infection-log.txt",
+ "summary": "infection-summary.txt"
+ },
+ "minMsi": 65,
+ "minCoveredMsi": 75
+}
diff --git a/phpstan-fixtures.neon b/phpstan-fixtures.neon
new file mode 100644
index 0000000..ff963b3
--- /dev/null
+++ b/phpstan-fixtures.neon
@@ -0,0 +1,12 @@
+parameters:
+ level: 8
+ reportUnmatchedIgnoredErrors: true
+ paths:
+ - %currentWorkingDirectory%/src/
+ - %currentWorkingDirectory%/tests/
+
+services:
+ -
+ class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicStaticMethodReturnTypeExtension
diff --git a/phpstan.neon b/phpstan.neon
index ff963b3..f5b41c4 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,8 +2,12 @@ parameters:
level: 8
reportUnmatchedIgnoredErrors: true
paths:
+ # Keep the repo-wide CI pass focused on production code; fixture-style PHPStan tests run via phpstan-fixtures.neon.
- %currentWorkingDirectory%/src/
- - %currentWorkingDirectory%/tests/
+ ignoreErrors:
+ -
+ message: '#^Parameter \#1 \$data of static method Arrayy\\Arrayy<.*>::create\(\) expects .*, .* given\.$#'
+ path: %currentWorkingDirectory%/src/Arrayy.php
services:
-
diff --git a/src/Arrayy.php b/src/Arrayy.php
index 55f51ad..0ee5f1b 100644
--- a/src/Arrayy.php
+++ b/src/Arrayy.php
@@ -282,6 +282,7 @@ public function add($value, $key = null)
);
}
+ /* @phpstan-ignore argument.type */
$this->internalSet($key, $value);
return $this;
@@ -788,9 +789,11 @@ public function &offsetGet($offset)
$value = null;
if ($this->offsetExists($offset)) {
+ /* @phpstan-ignore argument.type, argument.templateType */
$value = &$this->__get($offset);
}
+ /* @phpstan-ignore return.type */
return $value;
}
@@ -1052,7 +1055,7 @@ public function unserialize($string): self
* @return $this
*
(Mutable) Return this Arrayy object, with the appended values.
*
- * @phpstan-param array $values
+ * @phpstan-param array $values
* @phpstan-param TKey|null $key
* @phpstan-return static
*/
@@ -1067,6 +1070,7 @@ public function appendArrayValues(array $values, $key = null)
\is_array($this->array[$key])
) {
foreach ($values as $value) {
+ /* @phpstan-ignore assign.propertyType */
$this->array[$key][] = $value;
}
} else {
@@ -1824,7 +1828,7 @@ public function flatten($delimiter = '.', $prepend = '', $items = null)
* @return $this
* (Mutable) Return this Arrayy object.
*
- * @phpstan-param array $array
+ * @phpstan-param array $array
* @phpstan-return $this
*
* @internal this will not check any types because it's set directly as reference
@@ -2090,6 +2094,7 @@ public function customSortKeys(callable $callable): self
{
$this->generatorToArray();
+ /* @phpstan-ignore argument.type (internal keys are array-key|TKey, the callback contract is TKey) */
\uksort($this->array, $callable);
return $this;
@@ -2120,6 +2125,7 @@ public function customSortKeysImmutable(callable $callable): self
/**
* @psalm-suppress ImpureFunctionCall - object is already cloned
*/
+ /* @phpstan-ignore argument.type (internal keys are array-key|TKey, the callback contract is TKey) */
\uksort($that->array, $callable);
return $that;
@@ -2957,7 +2963,6 @@ public function firstsMutable(?int $number = null): self
if ($number === null) {
$shift = \array_shift($this->array);
- /* @phpstan-ignore assign.propertyType */
$this->array = $shift !== null ? [$shift] : [];
} else {
$splice = \array_splice($this->array, 0, $number);
@@ -3021,7 +3026,7 @@ public function flip(): self
*
* @return mixed|static
*
- * @phpstan-param TKey $key
+ * @phpstan-param array-key|null $key
* @phpstan-param array|array $array
* @psalm-mutation-free
*/
@@ -3643,7 +3648,7 @@ public function group($grouper, bool $saveKeys = false): self
*
* @return bool
*
- * @phpstan-param null|TKey|TKey[] $key
+ * @phpstan-param null|array-key|array $key
*/
public function has($key): bool
{
@@ -4302,7 +4307,10 @@ public function lastKey()
{
$this->generatorToArray();
- return \array_key_last($this->array);
+ /** @phpstan-var TKey|null $return - help for phpstan */
+ $return = \array_key_last($this->array);
+
+ return $return;
}
/**
@@ -5078,7 +5086,7 @@ public function prepend($value, $key = null)
if ($key === null) {
\array_unshift($this->array, $value);
} else {
- $this->array = [$key => $value] + $this->array; // @phpstan-ignore assign.propertyType
+ $this->array = [$key => $value] + $this->array;
}
return $this;
@@ -5419,7 +5427,7 @@ public function randomMutable(?int $number = null): self
if ($number === null) {
$arrayRandValue = [$this->array[\array_rand($this->array)]];
- $this->array = $arrayRandValue; // @phpstan-ignore assign.propertyType
+ $this->array = $arrayRandValue;
return $this;
}
@@ -7291,6 +7299,7 @@ public function walk(
}
} else {
if ($userData !== self::ARRAYY_HELPER_WALK) {
+ /* @phpstan-ignore argument.type (internal keys are array-key|TKey, the callback contract is TKey) */
\array_walk($this->array, $callable, $userData);
} else {
/* @phpstan-ignore argument.type */
@@ -7451,7 +7460,7 @@ protected function array_keys_recursive(
*
* @return void
*
- * @phpstan-param array|null $currentOffset
+ * @phpstan-param array|null $currentOffset
* @psalm-mutation-free
*/
protected function callAtPath($path, $callable, &$currentOffset = null)
@@ -7487,8 +7496,8 @@ protected function callAtPath($path, $callable, &$currentOffset = null)
/**
* Extracts the value of the given property or method from the object.
*
- * @param static $object
- * The object to extract the value from.
+ * @param mixed $object
+ * The Arrayy instance, object, or other value from which to extract the property or method value.
* @param string $keyOrPropertyOrMethod
* The property or method for which the
* value should be extracted.
@@ -7498,11 +7507,10 @@ protected function callAtPath($path, $callable, &$currentOffset = null)
* @return mixed
* The value extracted from the specified property or method.
*
- * @phpstan-param self $object
*/
- final protected function extractValue(self $object, string $keyOrPropertyOrMethod)
+ final protected function extractValue($object, string $keyOrPropertyOrMethod)
{
- if (isset($object[$keyOrPropertyOrMethod])) {
+ if ($object instanceof self && isset($object[$keyOrPropertyOrMethod])) {
$return = $object->get($keyOrPropertyOrMethod);
if ($return instanceof self) {
@@ -7512,11 +7520,12 @@ final protected function extractValue(self $object, string $keyOrPropertyOrMetho
return $return;
}
- if (\property_exists($object, $keyOrPropertyOrMethod)) {
+ // only use properties / methods that are accessible (and initialized) from here
+ if (\is_object($object) && \array_key_exists($keyOrPropertyOrMethod, \get_object_vars($object))) {
return $object->{$keyOrPropertyOrMethod};
}
- if (\method_exists($object, $keyOrPropertyOrMethod)) {
+ if (\is_object($object) && \is_callable([$object, $keyOrPropertyOrMethod])) {
return $object->{$keyOrPropertyOrMethod}();
}
@@ -8019,7 +8028,7 @@ protected function internalGetArray(&$data)
/**
* Internal mechanics of remove method.
*
- * @param float|int|string $key
+ * @param float|int|string|null $key
*
* @return bool
*/
@@ -8049,6 +8058,14 @@ protected function internalRemove($key): bool
$key = \array_shift($path);
}
+ if ($key === null) {
+ return false;
+ }
+
+ if (\is_float($key)) {
+ return false;
+ }
+
unset($this->array[$key]);
return true;
@@ -8063,7 +8080,7 @@ protected function internalRemove($key): bool
*
* @return bool
*
- * @phpstan-param TKey|null $key
+ * @phpstan-param array-key|null $key
* @phpstan-param T $value
*/
protected function internalSet(
diff --git a/src/Collection/AbstractCollection.php b/src/Collection/AbstractCollection.php
index 58fbcd1..2d7b222 100644
--- a/src/Collection/AbstractCollection.php
+++ b/src/Collection/AbstractCollection.php
@@ -317,10 +317,12 @@ public static function createFromJsonMapper(string $json)
if (\is_array($jsonObject)) {
foreach ($jsonObject as $jsonObjectSingle) {
$collectionData = $mapper->map($jsonObjectSingle, $type);
+ /** @phpstan-var T $collectionData */
$return->add($collectionData);
}
} else {
$collectionData = $mapper->map($jsonObject, $type);
+ /** @phpstan-var T $collectionData */
$return->add($collectionData);
}
} else {
diff --git a/src/Create.php b/src/Create.php
index 9241591..45d15fb 100644
--- a/src/Create.php
+++ b/src/Create.php
@@ -17,7 +17,10 @@
*/
function create($data): Arrayy
{
- return new Arrayy($data);
+ /** @var Arrayy> $array */
+ $array = new Arrayy($data);
+
+ return $array;
}
}
diff --git a/src/Mapper/Json.php b/src/Mapper/Json.php
index a5724e0..755d773 100644
--- a/src/Mapper/Json.php
+++ b/src/Mapper/Json.php
@@ -19,7 +19,7 @@ final class Json
* Override class names that JsonMapper uses to create objects.
* Useful when your setter methods accept abstract classes or interfaces.
*
- * @var array
+ * @var array
*/
public $classMap = [];
@@ -33,7 +33,7 @@ final class Json
* 2. Name of the unknown JSON property
* 3. JSON value of the property
*
- * @var callable
+ * @var null|callable(object, string, mixed): void
*/
public $undefinedPropertyHandler;
@@ -41,14 +41,14 @@ final class Json
* Runtime cache for inspected classes. This is particularly effective if
* mapArray() is called with a large number of objects
*
- * @var array property inspection result cache
+ * @var array> property inspection result cache
*/
private $arInspectedClasses = [];
/**
* Map data all data in $json into the given $object instance.
*
- * @param object|iterable $json
+ * @param object|iterable $json
* JSON object structure from json_decode()
* @param object|string $object
* Object to map $json data into
@@ -58,7 +58,8 @@ final class Json
*
* @see mapArray()
*
- * @template TObject
+ * @template TObject of object
+ * @phpstan-param object|iterable $json
* @phpstan-param TObject|class-string $object
* Object to map $json data into.
* @phpstan-return TObject
@@ -79,6 +80,11 @@ public function map($json, $object)
$strClassName = \get_class($object);
$rc = new \ReflectionClass($object);
$strNs = $rc->getNamespaceName();
+
+ if (\is_object($json) && !($json instanceof \Traversable)) {
+ $json = \get_object_vars($json);
+ }
+
foreach ($json as $key => $jsonValue) {
$key = $this->getSafeName($key);
@@ -96,8 +102,7 @@ public function map($json, $object)
if (!$hasProperty) {
if (\is_callable($this->undefinedPropertyHandler)) {
- \call_user_func(
- $this->undefinedPropertyHandler,
+ ($this->undefinedPropertyHandler)(
$object,
$key,
$jsonValue
@@ -111,7 +116,7 @@ public function map($json, $object)
continue;
}
- if ($this->isNullable($type)) {
+ if ($type !== null && $this->isNullable($type)) {
if ($jsonValue === null) {
$this->setProperty($object, $accessor, null);
@@ -247,7 +252,7 @@ public function map($json, $object)
/**
* Map an array
*
- * @param array $json JSON array structure from json_decode()
+ * @param array $json JSON array structure from json_decode()
* @param mixed $array Array or ArrayObject that gets filled with
* data from $json
* @param string|null $class Class name for children objects.
@@ -261,6 +266,8 @@ public function map($json, $object)
* @pslam-param null|class-string $class
*
* @return mixed Mapped $array is returned
+ *
+ * @phpstan-param array $json
*/
public function mapArray($json, $array, $class = null, $parent_key = '')
{
@@ -301,8 +308,12 @@ public function mapArray($json, $array, $class = null, $parent_key = '')
)
&&
\count($typesTmp->getTypes()) === 1
+ &&
+ \class_exists($typesTmp->getTypes()[0])
) {
- $array[$key] = $this->map($jsonValue, $typesTmp->getTypes()[0]);
+ /** @var class-string