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 $mappedClass */ + $mappedClass = $typesTmp->getTypes()[0]; + $array[$key] = $this->map($jsonValue, $mappedClass); $foundArrayy = true; break; @@ -404,7 +415,7 @@ private function getFullNamespace($type, $strNs) * @param \ReflectionClass $rc Reflection class to check * @param string $name Property name * - * @return array First value: if the property exists + * @return array{0: bool, 1: \ReflectionMethod|\ReflectionProperty|string|null, 2: string|null} First value: if the property exists * Second value: the accessor to use ( * Array-Key-String or ReflectionMethod or ReflectionProperty, or null) * Third value: type of the property @@ -485,7 +496,7 @@ private function inspectProperty(\ReflectionClass $rc, $name): array * * @param string $docblock Full method docblock * - * @return array + * @return array> */ private static function parseAnnotations($docblock): array { @@ -739,7 +750,7 @@ private function removeNullable($type) * * @internal * - * @template TClass + * @template TClass of object * @phpstan-param TClass|class-string $class * @phpstan-return TClass */ diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index feb0c21..fbb2b3f 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -38,6 +38,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, } $className = $scope->resolveName($methodCall->class); + /* @phpstan-ignore phpstanApi.runtimeReflection */ if (!\is_a($className, Arrayy::class, true)) { return null; } diff --git a/src/Type/DetectFirstValueTypeCollection.php b/src/Type/DetectFirstValueTypeCollection.php index b0693a1..4dd4643 100644 --- a/src/Type/DetectFirstValueTypeCollection.php +++ b/src/Type/DetectFirstValueTypeCollection.php @@ -26,7 +26,7 @@ final class DetectFirstValueTypeCollection extends Collection implements TypeInt * @param string $iteratorClass * @param bool $checkPropertiesInConstructor * - * @phpstan-param array|Arrayy> $data + * @phpstan-param array|Arrayy>|T $data * @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass */ public function __construct( @@ -50,7 +50,7 @@ public function __construct( $this->getTypeHelper = $this->getTypeFromFirstValue($firstValue); parent::__construct( - $data, + $data, // @phpstan-ignore argument.type (a single "T" value was wrapped into an array above) $iteratorClass, $checkPropertiesInConstructor ); diff --git a/src/TypeCheck/TypeCheckCallback.php b/src/TypeCheck/TypeCheckCallback.php index 8600770..2162683 100644 --- a/src/TypeCheck/TypeCheckCallback.php +++ b/src/TypeCheck/TypeCheckCallback.php @@ -52,6 +52,8 @@ public function checkType(&$value): bool /** * @return array + * + * @phpstan-return list */ public function getTypes(): array { diff --git a/src/TypeCheck/TypeCheckPhpDoc.php b/src/TypeCheck/TypeCheckPhpDoc.php index 43fbbe7..28f5f80 100644 --- a/src/TypeCheck/TypeCheckPhpDoc.php +++ b/src/TypeCheck/TypeCheckPhpDoc.php @@ -14,11 +14,6 @@ */ final class TypeCheckPhpDoc extends AbstractTypeCheck implements TypeCheckInterface { - /** - * @var bool - */ - private $hasTypeDeclaration = false; - /** * @var string */ @@ -68,8 +63,6 @@ public static function fromDocTypeObject(string $property, $type) $tmpReflection = new self($property); if ($type) { - $tmpReflection->hasTypeDeclaration = true; - $docTypes = self::parseDocTypeObject($type); if (\is_array($docTypes) === true) { foreach ($docTypes as $docType) { @@ -94,8 +87,6 @@ public static function fromReflectionProperty(\ReflectionProperty $reflectionPro $docTypes = self::getTypesFromReflectionPropertyDocBlock($reflectionProperty); if ($docTypes !== null) { - $tmpReflection->hasTypeDeclaration = true; - if (\is_array($docTypes) === true) { foreach ($docTypes as $docType) { $tmpReflection->types[] = $docType; @@ -109,8 +100,6 @@ public static function fromReflectionProperty(\ReflectionProperty $reflectionPro return $tmpReflection; } else { - $tmpReflection->hasTypeDeclaration = true; - $docTypes = self::parseReflectionTypeObject($type); if (\is_array($docTypes) === true) { foreach ($docTypes as $docType) { @@ -237,7 +226,7 @@ public static function parseDocTypeObject($type) } /** - * @return list + * @return list */ private static function getScalarPseudoTypeClasses(): array { diff --git a/tests/ArrayyTest.php b/tests/ArrayyTest.php index 6eea6f7..0e6894d 100644 --- a/tests/ArrayyTest.php +++ b/tests/ArrayyTest.php @@ -1629,6 +1629,8 @@ public function removeProvider(): array [[1.18], 0, []], [[' string ', 'foo'], 'foo', [' string ', 'foo']], [[' string ', 'foo' => 'foo'], 'foo', [' string ']], + // a float key must not be truncated into the int key "1" + [[1 => 'one', 2 => 'two'], 1.5, [1 => 'one', 2 => 'two']], ]; } @@ -6546,6 +6548,94 @@ public function testWalkSimpleRecursively(): void static::assertSame($expected, $resultArrayy->getArray()); } + /** + * @return array + */ + public function whereProvider(): array + { + return [ + 'public property' => ['name', 'Lars', [0]], + 'public method' => ['getCity', 'Berlin', [1]], + 'private property is not usable' => ['secret', 'x', []], + 'protected method is not usable' => ['getSecret', 'x', []], + ]; + } + + /** + * @dataProvider whereProvider() + * + * @param string $keyOrPropertyOrMethod + * @param mixed $value + * @param int[] $expectedKeys + */ + public function testWhere(string $keyOrPropertyOrMethod, $value, array $expectedKeys): void + { + $objects = [ + new class('Lars', 'Düsseldorf') { + /** + * @var string + */ + public $name; + + /** + * @var string + */ + private $city; + + /** + * @var string + */ + private $secret = 'x'; + + public function __construct(string $name, string $city) + { + $this->name = $name; + $this->city = $city; + } + + public function getCity(): string + { + return $this->city; + } + + protected function getSecret(): string + { + return $this->secret; + } + }, + new class('Foo', 'Berlin') { + /** + * @var string + */ + public $name; + + /** + * @var string + */ + private $city; + + public function __construct(string $name, string $city) + { + $this->name = $name; + $this->city = $city; + } + + public function getCity(): string + { + return $this->city; + } + }, + ]; + + if ($expectedKeys === []) { + $this->expectException(\InvalidArgumentException::class); + } + + $result = (new A($objects))->where($keyOrPropertyOrMethod, $value)->getArray(); + + static::assertSame($expectedKeys, \array_keys($result)); + } + /** * @return array */ diff --git a/tests/Collection/StringTypeTest.php b/tests/Collection/StringTypeTest.php index ec4f5e2..a7eef14 100644 --- a/tests/Collection/StringTypeTest.php +++ b/tests/Collection/StringTypeTest.php @@ -51,7 +51,6 @@ public function testWrongValue(): void { $this->expectException(\TypeError::class); - /* @phpstan-ignore offsetAssign.valueType */ new StringCollection(['A', 'B', 'C', 1]); } diff --git a/tests/JsonMapperCoverageTest.php b/tests/JsonMapperCoverageTest.php index a8d599f..0fe5b2c 100644 --- a/tests/JsonMapperCoverageTest.php +++ b/tests/JsonMapperCoverageTest.php @@ -37,6 +37,17 @@ public function testMapInvokesUndefinedPropertyHandlerWithSafeName(): void static::assertSame([$target, 'UnknownKey', 'value'], $captured); } + public function testMapIgnoresNonCallableUndefinedPropertyHandler(): void + { + $mapper = new Json(); + $target = new \stdClass(); + + /* @phpstan-ignore assign.propertyType (the public property can hold any value at runtime) */ + $mapper->undefinedPropertyHandler = false; + + static::assertSame($target, $mapper->map(['unknown-key' => 'value'], $target)); + } + public function testMapSkipsPrivatePropertiesWithoutSetters(): void { $mapper = new Json(); diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index 2f530d2..c596b1c 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -77,7 +77,7 @@ private function runPhpStanFixture(string $fixtureFile): array 'analyse', '--no-progress', '--error-format=raw', - '--configuration=' . $repoRoot . '/phpstan.neon', + '--configuration=' . $repoRoot . '/phpstan-fixtures.neon', $repoRoot . '/tests/PHPStan/' . $fixtureFile, ]; diff --git a/tests/StaticArrayyTest.php b/tests/StaticArrayyTest.php index bbb5b54..1bce59f 100644 --- a/tests/StaticArrayyTest.php +++ b/tests/StaticArrayyTest.php @@ -103,9 +103,14 @@ public function testArgumentNumbers(): void $staticArrayyClass = new \ReflectionClass(A::class); $arrayyClass = new \ReflectionClass(Arrayy::class); + // "methodArgs" is filled lazily by the first static call, so don't depend on test order + A::first([1]); // @phpstan-ignore staticMethod.notFound + // getStaticPropertyValue can't access protected properties $properties = $staticArrayyClass->getStaticProperties(); + static::assertNotEmpty($properties['methodArgs']); + foreach ((array) $properties['methodArgs'] as $method => $expected) { $num = $arrayyClass->getMethod($method)->getNumberOfParameters() + 2;