Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ build/logs/
# php (infection)
build/infection/
infection-log.txt
infection-summary.txt

# php (phpcs fixer)
.php_cs.cache
Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
voku marked this conversation as resolved.
# 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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 7 additions & 3 deletions infection.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
"phpUnit": {
"customPath": "vendor\/bin\/phpunit"
},
"staticAnalysisTool": "phpstan",
"tmpDir": "build/infection/",
"logs": {
"text": "infection-log.txt"
}
}
"text": "infection-log.txt",
"summary": "infection-summary.txt"
},
"minMsi": 65,
"minCoveredMsi": 75
}
12 changes: 12 additions & 0 deletions phpstan-fixtures.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
parameters:
level: 8
reportUnmatchedIgnoredErrors: true
paths:
- %currentWorkingDirectory%/src/
- %currentWorkingDirectory%/tests/

services:
-
class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension
tags:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
6 changes: 5 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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:
-
Expand Down
53 changes: 35 additions & 18 deletions src/Arrayy.php
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@
);
}

/* @phpstan-ignore argument.type */
$this->internalSet($key, $value);

return $this;
Expand Down Expand Up @@ -788,9 +789,11 @@
$value = null;

if ($this->offsetExists($offset)) {
/* @phpstan-ignore argument.type, argument.templateType */
$value = &$this->__get($offset);
}

/* @phpstan-ignore return.type */
return $value;
}

Expand Down Expand Up @@ -1052,7 +1055,7 @@
* @return $this
* <p>(Mutable) Return this Arrayy object, with the appended values.</p>
*
* @phpstan-param array<T> $values
* @phpstan-param array<T> $values
* @phpstan-param TKey|null $key
* @phpstan-return static
*/
Expand All @@ -1067,6 +1070,7 @@
\is_array($this->array[$key])
) {
foreach ($values as $value) {
/* @phpstan-ignore assign.propertyType */
$this->array[$key][] = $value;
}
} else {
Expand Down Expand Up @@ -1824,7 +1828,7 @@
* @return $this
* <p>(Mutable) Return this Arrayy object.</p>
*
* @phpstan-param array<TKey,T> $array
* @phpstan-param array<array-key|TKey,T> $array
* @phpstan-return $this
*
* @internal this will not check any types because it's set directly as reference
Expand Down Expand Up @@ -2090,6 +2094,7 @@
{
$this->generatorToArray();

/* @phpstan-ignore argument.type (internal keys are array-key|TKey, the callback contract is TKey) */
\uksort($this->array, $callable);

return $this;
Expand Down Expand Up @@ -2120,6 +2125,7 @@
/**
* @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;
Expand Down Expand Up @@ -2957,7 +2963,6 @@

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);
Expand Down Expand Up @@ -3021,7 +3026,7 @@
*
* @return mixed|static
*
* @phpstan-param TKey $key
* @phpstan-param array-key|null $key
* @phpstan-param array<array-key,mixed>|array<TKey,T> $array
* @psalm-mutation-free
*/
Expand Down Expand Up @@ -3643,7 +3648,7 @@
*
* @return bool
*
* @phpstan-param null|TKey|TKey[] $key
* @phpstan-param null|array-key|array<array-key> $key
*/
public function has($key): bool
{
Expand Down Expand Up @@ -4302,7 +4307,10 @@
{
$this->generatorToArray();

return \array_key_last($this->array);
/** @phpstan-var TKey|null $return - help for phpstan */
$return = \array_key_last($this->array);

Check warning on line 4311 in src/Arrayy.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Immediately return this expression instead of assigning it to the temporary variable "$return".

See more on https://sonarcloud.io/project/issues?id=voku_Arrayy&issues=AaDUNctHS00-8V9QC5p9&open=AaDUNctHS00-8V9QC5p9&pullRequest=183

return $return;
}

/**
Expand Down Expand Up @@ -5078,7 +5086,7 @@
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;
Expand Down Expand Up @@ -5419,7 +5427,7 @@

if ($number === null) {
$arrayRandValue = [$this->array[\array_rand($this->array)]];
$this->array = $arrayRandValue; // @phpstan-ignore assign.propertyType
$this->array = $arrayRandValue;

return $this;
}
Expand Down Expand Up @@ -7291,6 +7299,7 @@
}
} 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 */
Expand Down Expand Up @@ -7451,7 +7460,7 @@
*
* @return void
*
* @phpstan-param array<TKey,T>|null $currentOffset
* @phpstan-param array<array-key,mixed>|null $currentOffset
* @psalm-mutation-free
*/
protected function callAtPath($path, $callable, &$currentOffset = null)
Expand Down Expand Up @@ -7487,8 +7496,8 @@
/**
* Extracts the value of the given property or method from the object.
*
* @param static $object
* <p>The object to extract the value from.</p>
* @param mixed $object
* <p>The Arrayy instance, object, or other value from which to extract the property or method value.</p>
* @param string $keyOrPropertyOrMethod
* <p>The property or method for which the
* value should be extracted.</p>
Expand All @@ -7498,11 +7507,10 @@
* @return mixed
* <p>The value extracted from the specified property or method.</p>
*
* @phpstan-param self<TKey,T,TData> $object
*/
final protected function extractValue(self $object, string $keyOrPropertyOrMethod)
final protected function extractValue($object, string $keyOrPropertyOrMethod)

Check warning on line 7511 in src/Arrayy.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Arrayy&issues=AaDULQMUe5tQl4W2W6V6&open=AaDULQMUe5tQl4W2W6V6&pullRequest=183
{
if (isset($object[$keyOrPropertyOrMethod])) {
if ($object instanceof self && isset($object[$keyOrPropertyOrMethod])) {
$return = $object->get($keyOrPropertyOrMethod);

if ($return instanceof self) {
Expand All @@ -7512,11 +7520,12 @@
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}();
}

Expand Down Expand Up @@ -8019,11 +8028,11 @@
/**
* Internal mechanics of remove method.
*
* @param float|int|string $key
* @param float|int|string|null $key
*
* @return bool
*/
protected function internalRemove($key): bool

Check warning on line 8035 in src/Arrayy.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=voku_Arrayy&issues=AaDULQMUe5tQl4W2W6V7&open=AaDULQMUe5tQl4W2W6V7&pullRequest=183
{
$this->generatorToArray();

Expand All @@ -8049,6 +8058,14 @@
$key = \array_shift($path);
}

if ($key === null) {
return false;
}

if (\is_float($key)) {
return false;
}

unset($this->array[$key]);

return true;
Expand All @@ -8063,7 +8080,7 @@
*
* @return bool
*
* @phpstan-param TKey|null $key
* @phpstan-param array-key|null $key
* @phpstan-param T $value
*/
protected function internalSet(
Expand Down
2 changes: 2 additions & 0 deletions src/Collection/AbstractCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/Create.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
*/
function create($data): Arrayy
{
return new Arrayy($data);
/** @var Arrayy<int|string,mixed,array<int|string,mixed>> $array */
$array = new Arrayy($data);

Check warning on line 21 in src/Create.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Immediately return this expression instead of assigning it to the temporary variable "$array".

See more on https://sonarcloud.io/project/issues?id=voku_Arrayy&issues=AaDULQQKe5tQl4W2W6V8&open=AaDULQQKe5tQl4W2W6V8&pullRequest=183

return $array;
}
}

Expand Down
Loading
Loading