From b8a77e85fc534f35bcc64d810ccbe2ed528d56c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:38:30 +0000 Subject: [PATCH 01/22] Add meta PHPStan regression coverage Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- phpstan.neon | 6 ++ ...DynamicStaticMethodReturnTypeExtension.php | 55 +++++++++++++++ tests/MetaPhpStanIntegrationTest.php | 67 +++++++++++++++++++ tests/MetaRuntimeTest.php | 53 +++++++++++++++ tests/PHPStan/MetaInvalidUsage.php | 23 +++++++ tests/PHPStan/MetaValidUsage.php | 32 +++++++++ 6 files changed, 236 insertions(+) create mode 100644 src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php create mode 100644 tests/MetaPhpStanIntegrationTest.php create mode 100644 tests/MetaRuntimeTest.php create mode 100644 tests/PHPStan/MetaInvalidUsage.php create mode 100644 tests/PHPStan/MetaValidUsage.php diff --git a/phpstan.neon b/phpstan.neon index d865762..ff963b3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,9 @@ parameters: paths: - %currentWorkingDirectory%/src/ - %currentWorkingDirectory%/tests/ + +services: + - + class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php new file mode 100644 index 0000000..134e867 --- /dev/null +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -0,0 +1,55 @@ +getName() === 'meta'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (!$methodCall->class instanceof Name) { + return null; + } + + $className = $scope->resolveTypeByName($methodCall->class)->getClassName(); + if (!\is_a($className, Arrayy::class, true)) { + return null; + } + + $reflection = new \ReflectionClass($className); + /** @var Arrayy> $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + + $properties = []; + foreach (\array_keys($instance->getPhpDocPropertiesFromClass()) as $propertyName) { + if (!\is_string($propertyName)) { + continue; + } + + $properties[$propertyName] = new ConstantStringType($propertyName); + } + + return new ObjectShapeType($properties, []); + } +} diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php new file mode 100644 index 0000000..b839d78 --- /dev/null +++ b/tests/MetaPhpStanIntegrationTest.php @@ -0,0 +1,67 @@ +runPhpStanFixture('MetaValidUsage.php'); + + static::assertSame(0, $exitCode, $output); + } + + public function testPhpStanRejectsInvalidMetaUsage(): void + { + [$exitCode, $output] = $this->runPhpStanFixture('MetaInvalidUsage.php'); + + static::assertSame(1, $exitCode, $output); + static::assertStringContainsString('undefined property', \strtolower($output)); + static::assertStringContainsString('$ghost', $output); + static::assertStringContainsString('Parameter #1 $string of function strlen expects string, int|null given.', $output); + } + + /** + * @return array{int, string} + */ + private function runPhpStanFixture(string $fixtureFile): array + { + if (!\function_exists('proc_open')) { + self::markTestSkipped('proc_open() is required to execute PHPStan.'); + } + + $repoRoot = \dirname(__DIR__); + $command = [ + \PHP_BINARY, + $repoRoot . '/vendor/bin/phpstan', + 'analyse', + '--no-progress', + '--error-format=raw', + '--configuration=' . $repoRoot . '/phpstan.neon', + $repoRoot . '/tests/PHPStan/' . $fixtureFile, + ]; + + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = \proc_open($command, $descriptorSpec, $pipes, $repoRoot); + static::assertIsResource($process); + + $stdout = \stream_get_contents($pipes[1]); + $stderr = \stream_get_contents($pipes[2]); + + \fclose($pipes[1]); + \fclose($pipes[2]); + + $exitCode = \proc_close($process); + + return [$exitCode, \trim($stdout . $stderr)]; + } +} diff --git a/tests/MetaRuntimeTest.php b/tests/MetaRuntimeTest.php new file mode 100644 index 0000000..f1b64f6 --- /dev/null +++ b/tests/MetaRuntimeTest.php @@ -0,0 +1,53 @@ +name => 'Düsseldorf', + $cityMeta->plz => null, + ]); + + $userMeta = ArrayShapeUser::meta(); + $user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => $city, + ]); + + static::assertSame('id', $userMeta->id); + static::assertSame('city', $userMeta->city); + static::assertSame('name', $cityMeta->name); + static::assertSame(1, $user[$userMeta->id]); + static::assertInstanceOf(ArrayShapeCity::class, $user[$userMeta->city]); + static::assertSame('Düsseldorf', $user[$userMeta->city][$cityMeta->name]); + } + + public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Invalid type: expected "id" to be of type {int}'); + + $userMeta = ArrayShapeUser::meta(); + $user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + ]); + + $user[$userMeta->id] = 'wrong-id'; + } +} diff --git a/tests/PHPStan/MetaInvalidUsage.php b/tests/PHPStan/MetaInvalidUsage.php new file mode 100644 index 0000000..d52f614 --- /dev/null +++ b/tests/PHPStan/MetaInvalidUsage.php @@ -0,0 +1,23 @@ +id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => new ArrayShapeCity([ + $cityMeta->name => 'Düsseldorf', + $cityMeta->plz => null, + ]), +]); + +$ghost = $userMeta->ghost; +$length = \strlen($user[$userMeta->id]); diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php new file mode 100644 index 0000000..22b6b31 --- /dev/null +++ b/tests/PHPStan/MetaValidUsage.php @@ -0,0 +1,32 @@ +id); +\PHPStan\Testing\assertType("'city'", $userMeta->city); +\PHPStan\Testing\assertType("'name'", $cityMeta->name); + +$user = new ArrayShapeUser([ + $userMeta->id => 1, + $userMeta->firstName => 'Lars', + $userMeta->lastName => 'Moelleken', + $userMeta->city => new ArrayShapeCity([ + $cityMeta->name => 'Düsseldorf', + $cityMeta->plz => null, + ]), +]); + +\PHPStan\Testing\assertType('int|null', $user[$userMeta->id]); +\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user[$userMeta->city]); + +if ($user[$userMeta->city] !== null) { + \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user[$userMeta->city]); + \PHPStan\Testing\assertType('string|null', $user[$userMeta->city][$cityMeta->name]); +} From f90830ae1b1db432464314a70fccf1fa9db46c40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:39:34 +0000 Subject: [PATCH 02/22] Finish meta phpstan validation coverage Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php | 2 +- tests/MetaPhpStanIntegrationTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index 134e867..f7afdcb 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -32,7 +32,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - $className = $scope->resolveTypeByName($methodCall->class)->getClassName(); + $className = $scope->resolveName($methodCall->class); if (!\is_a($className, Arrayy::class, true)) { return null; } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index b839d78..7c95570 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -21,7 +21,7 @@ public function testPhpStanRejectsInvalidMetaUsage(): void [$exitCode, $output] = $this->runPhpStanFixture('MetaInvalidUsage.php'); static::assertSame(1, $exitCode, $output); - static::assertStringContainsString('undefined property', \strtolower($output)); + static::assertStringContainsString('Access to an undefined property', $output); static::assertStringContainsString('$ghost', $output); static::assertStringContainsString('Parameter #1 $string of function strlen expects string, int|null given.', $output); } From 10ea9d74e08be9030e128666f544638b51999e45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:40:14 +0000 Subject: [PATCH 03/22] Address validation feedback for meta coverage Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- .../MetaDynamicStaticMethodReturnTypeExtension.php | 2 +- tests/MetaPhpStanIntegrationTest.php | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index f7afdcb..2d00a35 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -50,6 +50,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $properties[$propertyName] = new ConstantStringType($propertyName); } - return new ObjectShapeType($properties, []); + return new ObjectShapeType($properties, []); // no optional meta properties } } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index 7c95570..1d6d963 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -11,14 +11,16 @@ final class MetaPhpStanIntegrationTest extends \PHPUnit\Framework\TestCase { public function testPhpStanAcceptsValidMetaUsage(): void { - [$exitCode, $output] = $this->runPhpStanFixture('MetaValidUsage.php'); + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture('MetaValidUsage.php'); + $output = \trim($stdout . $stderr); static::assertSame(0, $exitCode, $output); } public function testPhpStanRejectsInvalidMetaUsage(): void { - [$exitCode, $output] = $this->runPhpStanFixture('MetaInvalidUsage.php'); + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture('MetaInvalidUsage.php'); + $output = \trim($stdout . $stderr); static::assertSame(1, $exitCode, $output); static::assertStringContainsString('Access to an undefined property', $output); @@ -27,7 +29,7 @@ public function testPhpStanRejectsInvalidMetaUsage(): void } /** - * @return array{int, string} + * @return array{int, string, string} */ private function runPhpStanFixture(string $fixtureFile): array { @@ -62,6 +64,6 @@ private function runPhpStanFixture(string $fixtureFile): array $exitCode = \proc_close($process); - return [$exitCode, \trim($stdout . $stderr)]; + return [$exitCode, $stdout, $stderr]; } } From 5a7f6953ebfad58a6946399554ca34f5b23445a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:40:56 +0000 Subject: [PATCH 04/22] Polish meta validation assertions Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/MetaPhpStanIntegrationTest.php | 4 +++- tests/MetaRuntimeTest.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index 1d6d963..5e798e6 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -25,7 +25,9 @@ public function testPhpStanRejectsInvalidMetaUsage(): void static::assertSame(1, $exitCode, $output); static::assertStringContainsString('Access to an undefined property', $output); static::assertStringContainsString('$ghost', $output); - static::assertStringContainsString('Parameter #1 $string of function strlen expects string, int|null given.', $output); + static::assertStringContainsString('strlen', $output); + static::assertStringContainsString('expects string', $output); + static::assertStringContainsString('int|null', $output); } /** diff --git a/tests/MetaRuntimeTest.php b/tests/MetaRuntimeTest.php index f1b64f6..3e5b810 100644 --- a/tests/MetaRuntimeTest.php +++ b/tests/MetaRuntimeTest.php @@ -39,7 +39,7 @@ public function testArrayShapeMetaSupportsNestedRuntimeAccess(): void public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void { $this->expectException(\TypeError::class); - $this->expectExceptionMessage('Invalid type: expected "id" to be of type {int}'); + $this->expectExceptionMessageMatches('#Invalid type: expected "id" to be of type \{int\}#'); $userMeta = ArrayShapeUser::meta(); $user = new ArrayShapeUser([ From dc71c66d279effbcd8b5bcb92e7e8bd01f5764fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:41:56 +0000 Subject: [PATCH 05/22] Tighten meta phpstan fixture handling Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- .../MetaDynamicStaticMethodReturnTypeExtension.php | 9 +++------ tests/MetaPhpStanIntegrationTest.php | 2 +- tests/PHPStan/MetaInvalidUsage.php | 2 +- tests/PHPStan/MetaValidUsage.php | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index 2d00a35..d7291ad 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -5,6 +5,7 @@ namespace Arrayy\PHPStan; use Arrayy\Arrayy; +use Arrayy\ArrayyMeta; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantStringType; @@ -37,13 +38,9 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - $reflection = new \ReflectionClass($className); - /** @var Arrayy> $instance */ - $instance = $reflection->newInstanceWithoutConstructor(); - $properties = []; - foreach (\array_keys($instance->getPhpDocPropertiesFromClass()) as $propertyName) { - if (!\is_string($propertyName)) { + foreach (\get_object_vars((new ArrayyMeta())->getMetaObject($className)) as $propertyName => $value) { + if (!\is_string($propertyName) || !\is_string($value)) { continue; } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index 5e798e6..e2c945c 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -66,6 +66,6 @@ private function runPhpStanFixture(string $fixtureFile): array $exitCode = \proc_close($process); - return [$exitCode, $stdout, $stderr]; + return [$exitCode, $stdout !== false ? $stdout : '', $stderr !== false ? $stderr : '']; } } diff --git a/tests/PHPStan/MetaInvalidUsage.php b/tests/PHPStan/MetaInvalidUsage.php index d52f614..0b61ef0 100644 --- a/tests/PHPStan/MetaInvalidUsage.php +++ b/tests/PHPStan/MetaInvalidUsage.php @@ -4,7 +4,7 @@ namespace Arrayy\tests\PHPStan; -require_once __DIR__ . '/../../.phpUnitAndStanFix.php'; +require_once \dirname(__DIR__, 2) . '/.phpUnitAndStanFix.php'; $cityMeta = ArrayShapeCity::meta(); $userMeta = ArrayShapeUser::meta(); diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php index 22b6b31..812aa96 100644 --- a/tests/PHPStan/MetaValidUsage.php +++ b/tests/PHPStan/MetaValidUsage.php @@ -4,7 +4,7 @@ namespace Arrayy\tests\PHPStan; -require_once __DIR__ . '/../../.phpUnitAndStanFix.php'; +require_once \dirname(__DIR__, 2) . '/.phpUnitAndStanFix.php'; $cityMeta = ArrayShapeCity::meta(); $userMeta = ArrayShapeUser::meta(); From d13f07e00ce8612f4a38c38b2cdcd7c6deb0d43e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:42:40 +0000 Subject: [PATCH 06/22] Finalize meta phpstan support polish Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php | 2 +- tests/MetaPhpStanIntegrationTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index d7291ad..eee9128 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -44,7 +44,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, continue; } - $properties[$propertyName] = new ConstantStringType($propertyName); + $properties[$propertyName] = new ConstantStringType($value); } return new ObjectShapeType($properties, []); // no optional meta properties diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index e2c945c..ad2bf6b 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -66,6 +66,6 @@ private function runPhpStanFixture(string $fixtureFile): array $exitCode = \proc_close($process); - return [$exitCode, $stdout !== false ? $stdout : '', $stderr !== false ? $stderr : '']; + return [$exitCode, (string) $stdout, (string) $stderr]; } } From 2841acfd8fe946b49eebd9a679c84daf0bc923b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:44:11 +0000 Subject: [PATCH 07/22] Fix meta phpstan return type caching Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/33fd0b1f-7b30-4581-b6dc-16ec2d60d246 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- .../MetaDynamicStaticMethodReturnTypeExtension.php | 11 ++++++++++- tests/MetaPhpStanIntegrationTest.php | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index eee9128..4b18ca8 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -17,6 +17,11 @@ final class MetaDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { + /** + * @var array + */ + private array $types = []; + public function getClass(): string { return Arrayy::class; @@ -38,6 +43,10 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } + if (isset($this->types[$className])) { + return $this->types[$className]; + } + $properties = []; foreach (\get_object_vars((new ArrayyMeta())->getMetaObject($className)) as $propertyName => $value) { if (!\is_string($propertyName) || !\is_string($value)) { @@ -47,6 +56,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $properties[$propertyName] = new ConstantStringType($value); } - return new ObjectShapeType($properties, []); // no optional meta properties + return $this->types[$className] = new ObjectShapeType($properties, []); // no optional meta properties } } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index ad2bf6b..ee705fa 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -36,7 +36,7 @@ public function testPhpStanRejectsInvalidMetaUsage(): void private function runPhpStanFixture(string $fixtureFile): array { if (!\function_exists('proc_open')) { - self::markTestSkipped('proc_open() is required to execute PHPStan.'); + static::markTestSkipped('proc_open() is required to execute PHPStan.'); } $repoRoot = \dirname(__DIR__); From 50aa3f26bbcee201503c6abd797c5502d376786c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:52:32 +0000 Subject: [PATCH 08/22] Refine meta phpstan follow-up Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/0feb5c22-2057-4765-afd7-67fe492f5aec Co-authored-by: voku <264695+voku@users.noreply.github.com> --- src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php | 7 ++++--- tests/MetaPhpStanIntegrationTest.php | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index 4b18ca8..f74b752 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -5,7 +5,6 @@ namespace Arrayy\PHPStan; use Arrayy\Arrayy; -use Arrayy\ArrayyMeta; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantStringType; @@ -47,8 +46,10 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return $this->types[$className]; } + /** @var object $meta */ + $meta = $className::meta(); $properties = []; - foreach (\get_object_vars((new ArrayyMeta())->getMetaObject($className)) as $propertyName => $value) { + foreach (\get_object_vars($meta) as $propertyName => $value) { if (!\is_string($propertyName) || !\is_string($value)) { continue; } @@ -56,6 +57,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $properties[$propertyName] = new ConstantStringType($value); } - return $this->types[$className] = new ObjectShapeType($properties, []); // no optional meta properties + return $this->types[$className] = new ObjectShapeType($properties, []); // second argument is optionalProperties; meta exposes only concrete keys } } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index ee705fa..b4ab78e 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -36,6 +36,7 @@ public function testPhpStanRejectsInvalidMetaUsage(): void private function runPhpStanFixture(string $fixtureFile): array { if (!\function_exists('proc_open')) { + // Without proc_open() the test cannot execute the PHPStan CLI process, so skipping is the only meaningful fallback. static::markTestSkipped('proc_open() is required to execute PHPStan.'); } From 337123dfac7a9edba01fb5ee02647e4fbb1e9c3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:53:10 +0000 Subject: [PATCH 09/22] Skip meta phpstan tests at setup Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/0feb5c22-2057-4765-afd7-67fe492f5aec Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/MetaPhpStanIntegrationTest.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index b4ab78e..605cf8f 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -9,6 +9,13 @@ */ final class MetaPhpStanIntegrationTest extends \PHPUnit\Framework\TestCase { + protected function setUp(): void + { + if (!\function_exists('proc_open')) { + static::markTestSkipped('proc_open() is required to execute PHPStan.'); + } + } + public function testPhpStanAcceptsValidMetaUsage(): void { [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture('MetaValidUsage.php'); @@ -35,10 +42,6 @@ public function testPhpStanRejectsInvalidMetaUsage(): void */ private function runPhpStanFixture(string $fixtureFile): array { - if (!\function_exists('proc_open')) { - // Without proc_open() the test cannot execute the PHPStan CLI process, so skipping is the only meaningful fallback. - static::markTestSkipped('proc_open() is required to execute PHPStan.'); - } $repoRoot = \dirname(__DIR__); $command = [ From 84a7d98cd1dcd2910996b8356e18abbc8dd7fcad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:55:38 +0000 Subject: [PATCH 10/22] Finalize meta phpstan goal follow-up Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/0feb5c22-2057-4765-afd7-67fe492f5aec Co-authored-by: voku <264695+voku@users.noreply.github.com> --- src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php | 5 +++-- tests/MetaPhpStanIntegrationTest.php | 7 +++---- tests/MetaRuntimeTest.php | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index f74b752..2aeaacf 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -46,7 +46,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return $this->types[$className]; } - /** @var object $meta */ + /** @var \Arrayy\ArrayyMeta $meta */ $meta = $className::meta(); $properties = []; foreach (\get_object_vars($meta) as $propertyName => $value) { @@ -57,6 +57,7 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $properties[$propertyName] = new ConstantStringType($value); } - return $this->types[$className] = new ObjectShapeType($properties, []); // second argument is optionalProperties; meta exposes only concrete keys + // Passing an empty optionalProperties list makes every inferred meta key concrete/required. + return $this->types[$className] = new ObjectShapeType($properties, []); } } diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index 605cf8f..d541ae6 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -38,11 +38,10 @@ public function testPhpStanRejectsInvalidMetaUsage(): void } /** - * @return array{int, string, string} + * @return array{0: int, 1: string, 2: string} */ private function runPhpStanFixture(string $fixtureFile): array { - $repoRoot = \dirname(__DIR__); $command = [ \PHP_BINARY, @@ -62,8 +61,8 @@ private function runPhpStanFixture(string $fixtureFile): array $process = \proc_open($command, $descriptorSpec, $pipes, $repoRoot); static::assertIsResource($process); - $stdout = \stream_get_contents($pipes[1]); - $stderr = \stream_get_contents($pipes[2]); + $stdout = \stream_get_contents($pipes[1]) ?: ''; + $stderr = \stream_get_contents($pipes[2]) ?: ''; \fclose($pipes[1]); \fclose($pipes[2]); diff --git a/tests/MetaRuntimeTest.php b/tests/MetaRuntimeTest.php index 3e5b810..64bf969 100644 --- a/tests/MetaRuntimeTest.php +++ b/tests/MetaRuntimeTest.php @@ -39,7 +39,7 @@ public function testArrayShapeMetaSupportsNestedRuntimeAccess(): void public function testArrayShapeMetaRejectsWrongRuntimeTypes(): void { $this->expectException(\TypeError::class); - $this->expectExceptionMessageMatches('#Invalid type: expected "id" to be of type \{int\}#'); + $this->expectExceptionMessageMatches('/Invalid type: expected "id" to be of type \\{int\\}/'); $userMeta = ArrayShapeUser::meta(); $user = new ArrayShapeUser([ From a07d959252c451c8d2215f8fdb6a259a4c30c962 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:38:38 +0000 Subject: [PATCH 11/22] Add array-shape constructor typing Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/a2b21013-bb1a-4fab-bdbf-e3f84a7fa6b3 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- src/Arrayy.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Arrayy.php b/src/Arrayy.php index 105d428..55f51ad 100644 --- a/src/Arrayy.php +++ b/src/Arrayy.php @@ -117,6 +117,7 @@ class Arrayy extends \ArrayObject implements \IteratorAggregate, \ArrayAccess, \ * true, otherwise this option didn't not work anyway. *

* + * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data * @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass */ public function __construct( @@ -1752,6 +1753,7 @@ public function countValues(): self * @return static *

(Immutable) Returns an new instance of the Arrayy object.

* + * @phpstan-param TData|self|\Traversable|callable|object|scalar|null $data * @phpstan-param class-string<\Arrayy\ArrayyIterator> $iteratorClass * @phpstan-return static * @psalm-mutation-free From d38bb58702a5d6cf7c3da173f61f4e38ef064ebc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:39:57 +0000 Subject: [PATCH 12/22] Add array-shape phpstan coverage Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/a2b21013-bb1a-4fab-bdbf-e3f84a7fa6b3 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/MetaPhpStanIntegrationTest.php | 40 ++++++++++++++++++++---- tests/PHPStan/ArrayShapeAccessTest.php | 6 ++-- tests/PHPStan/ArrayShapeInvalidUsage.php | 24 ++++++++++++++ tests/PHPStan/ArrayShapeValidUsage.php | 27 ++++++++++++++++ tests/PHPStan/MetaValidUsage.php | 4 +-- 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 tests/PHPStan/ArrayShapeInvalidUsage.php create mode 100644 tests/PHPStan/ArrayShapeValidUsage.php diff --git a/tests/MetaPhpStanIntegrationTest.php b/tests/MetaPhpStanIntegrationTest.php index d541ae6..2f530d2 100644 --- a/tests/MetaPhpStanIntegrationTest.php +++ b/tests/MetaPhpStanIntegrationTest.php @@ -18,18 +18,18 @@ protected function setUp(): void public function testPhpStanAcceptsValidMetaUsage(): void { - [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture('MetaValidUsage.php'); - $output = \trim($stdout . $stderr); + $this->assertFixturePassesPhpStan('MetaValidUsage.php'); + } - static::assertSame(0, $exitCode, $output); + public function testPhpStanAcceptsValidArrayShapeUsage(): void + { + $this->assertFixturePassesPhpStan('ArrayShapeValidUsage.php'); } public function testPhpStanRejectsInvalidMetaUsage(): void { - [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture('MetaInvalidUsage.php'); - $output = \trim($stdout . $stderr); + $output = $this->assertFixtureFailsPhpStan('MetaInvalidUsage.php'); - static::assertSame(1, $exitCode, $output); static::assertStringContainsString('Access to an undefined property', $output); static::assertStringContainsString('$ghost', $output); static::assertStringContainsString('strlen', $output); @@ -37,6 +37,34 @@ public function testPhpStanRejectsInvalidMetaUsage(): void static::assertStringContainsString('int|null', $output); } + public function testPhpStanRejectsInvalidArrayShapeUsage(): void + { + $output = $this->assertFixtureFailsPhpStan('ArrayShapeInvalidUsage.php'); + + static::assertStringContainsString('Parameter #1 $data of class Arrayy\tests\PHPStan\ArrayShapeUser constructor expects', $output); + static::assertStringContainsString("array{id: 'wrong', firstName: 'Lars', lastName: 'Moelleken'} given", $output); + static::assertStringContainsString("array{id: 1, firstName: 'Lars'} given", $output); + static::assertStringContainsString('Parameter #1 $data of static method Arrayy\Arrayy', $output); + } + + private function assertFixturePassesPhpStan(string $fixtureFile): void + { + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile); + $output = \trim($stdout . $stderr); + + static::assertSame(0, $exitCode, $output); + } + + private function assertFixtureFailsPhpStan(string $fixtureFile): string + { + [$exitCode, $stdout, $stderr] = $this->runPhpStanFixture($fixtureFile); + $output = \trim($stdout . $stderr); + + static::assertSame(1, $exitCode, $output); + + return $output; + } + /** * @return array{0: int, 1: string, 2: string} */ diff --git a/tests/PHPStan/ArrayShapeAccessTest.php b/tests/PHPStan/ArrayShapeAccessTest.php index 66e781a..f09d253 100644 --- a/tests/PHPStan/ArrayShapeAccessTest.php +++ b/tests/PHPStan/ArrayShapeAccessTest.php @@ -26,12 +26,12 @@ public function testArrayShapeOffsetsAreTyped(): void \PHPStan\Testing\assertType('int|null', $user['id']); \PHPStan\Testing\assertType('string|null', $user['firstName']); \PHPStan\Testing\assertType('string|null', $user['lastName']); - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); + \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); if ($user['city'] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); + \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); \PHPStan\Testing\assertType('string|null', $user['city']['name']); - \PHPStan\Testing\assertType('string|null', $user['city']['plz']); + \PHPStan\Testing\assertType('null', $user['city']['plz']); } self::assertSame('Moelleken', $user['lastName']); diff --git a/tests/PHPStan/ArrayShapeInvalidUsage.php b/tests/PHPStan/ArrayShapeInvalidUsage.php new file mode 100644 index 0000000..0242b69 --- /dev/null +++ b/tests/PHPStan/ArrayShapeInvalidUsage.php @@ -0,0 +1,24 @@ + 'wrong', + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', +]); + +new ArrayShapeUser([ + 'id' => 1, + 'firstName' => 'Lars', +]); + +ArrayShapeUser::create([ + 'id' => 'wrong', + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', +]); diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php new file mode 100644 index 0000000..a4d3d53 --- /dev/null +++ b/tests/PHPStan/ArrayShapeValidUsage.php @@ -0,0 +1,27 @@ + 1, + 'firstName' => 'Lars', + 'lastName' => 'Moelleken', + 'city' => new ArrayShapeCity([ + 'name' => 'Düsseldorf', + 'plz' => null, + ]), +]); + +\PHPStan\Testing\assertType('int|null', $user['id']); +\PHPStan\Testing\assertType('string|null', $user['firstName']); +\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); + +if ($user['city'] !== null) { + \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); + \PHPStan\Testing\assertType('string|null', $user['city']['name']); + \PHPStan\Testing\assertType('null', $user['city']['plz']); +} diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php index 812aa96..1e6025d 100644 --- a/tests/PHPStan/MetaValidUsage.php +++ b/tests/PHPStan/MetaValidUsage.php @@ -24,9 +24,9 @@ ]); \PHPStan\Testing\assertType('int|null', $user[$userMeta->id]); -\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user[$userMeta->city]); +\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user[$userMeta->city]); if ($user[$userMeta->city] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user[$userMeta->city]); + \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user[$userMeta->city]); \PHPStan\Testing\assertType('string|null', $user[$userMeta->city][$cityMeta->name]); } From a56fdbe46273c270629527a865180252d7ddfaea Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Wed, 29 Apr 2026 14:39:02 +0000 Subject: [PATCH 13/22] Apply fixes from StyleCI --- src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php index 2aeaacf..feb0c21 100644 --- a/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php +++ b/src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php @@ -5,14 +5,14 @@ namespace Arrayy\PHPStan; use Arrayy\Arrayy; +use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; use PHPStan\Type\ObjectShapeType; use PHPStan\Type\Type; -use PhpParser\Node\Expr\StaticCall; -use PhpParser\Node\Name; final class MetaDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { From 044531fcc8cb095a84dee57f2fc80f18c63e202d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:57:45 +0000 Subject: [PATCH 14/22] Stabilize meta fixture and add extension tests Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/c4e66b1d-ab22-4673-bb59-d9a16e9774a0 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- ...micStaticMethodReturnTypeExtensionTest.php | 104 ++++++++++++++++++ tests/PHPStan/MetaValidUsage.php | 18 ++- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php diff --git a/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php new file mode 100644 index 0000000..2891b73 --- /dev/null +++ b/tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php @@ -0,0 +1,104 @@ +getClass()); + } + + public function testIsStaticMethodSupportedOnlyForMeta(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $metaMethod = $this->createMock(MethodReflection::class); + $metaMethod->method('getName')->willReturn('meta'); + + $createMethod = $this->createMock(MethodReflection::class); + $createMethod->method('getName')->willReturn('create'); + + self::assertTrue($extension->isStaticMethodSupported($metaMethod)); + self::assertFalse($extension->isStaticMethodSupported($createMethod)); + } + + public function testReturnsNullWhenStaticCallClassIsNotANameNode(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::never())->method('resolveName'); + + $type = $extension->getTypeFromStaticMethodCall( + $method, + new StaticCall(new Variable('className'), 'meta'), + $scope + ); + + self::assertNull($type); + } + + public function testReturnsNullForNonArrayyClasses(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::once()) + ->method('resolveName') + ->willReturn(\stdClass::class); + + $type = $extension->getTypeFromStaticMethodCall( + $method, + new StaticCall(new Name('stdClass'), 'meta'), + $scope + ); + + self::assertNull($type); + } + + public function testBuildsAndCachesMetaShapeTypes(): void + { + $extension = new MetaDynamicStaticMethodReturnTypeExtension(); + + $method = $this->createMock(MethodReflection::class); + $scope = $this->createMock(Scope::class); + $scope->expects(self::exactly(2)) + ->method('resolveName') + ->willReturn(ArrayShapeUser::class); + + $call = new StaticCall(new Name('ArrayShapeUser'), 'meta'); + + $firstType = $extension->getTypeFromStaticMethodCall($method, $call, $scope); + $secondType = $extension->getTypeFromStaticMethodCall($method, $call, $scope); + + self::assertInstanceOf(ObjectShapeType::class, $firstType); + self::assertSame($firstType, $secondType); + self::assertSame( + "object{id: 'id', firstName: 'firstName', lastName: 'lastName', city: 'city'}", + $firstType->describe(VerbosityLevel::precise()) + ); + } +} diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php index 1e6025d..a218a49 100644 --- a/tests/PHPStan/MetaValidUsage.php +++ b/tests/PHPStan/MetaValidUsage.php @@ -6,6 +6,20 @@ require_once \dirname(__DIR__, 2) . '/.phpUnitAndStanFix.php'; +/** + * @param ArrayShapeCity|null $city + */ +function assertMetaFixtureNullableCity(?ArrayShapeCity $city): void +{ +} + +/** + * @param ArrayShapeCity $city + */ +function assertMetaFixtureCity(ArrayShapeCity $city): void +{ +} + $cityMeta = ArrayShapeCity::meta(); $userMeta = ArrayShapeUser::meta(); @@ -24,9 +38,9 @@ ]); \PHPStan\Testing\assertType('int|null', $user[$userMeta->id]); -\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user[$userMeta->city]); +assertMetaFixtureNullableCity($user[$userMeta->city]); if ($user[$userMeta->city] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user[$userMeta->city]); + assertMetaFixtureCity($user[$userMeta->city]); \PHPStan\Testing\assertType('string|null', $user[$userMeta->city][$cityMeta->name]); } From 623e091d20e7cb689d99d7b09d4a707cc00fbd92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:59:03 +0000 Subject: [PATCH 15/22] Validate meta phpstan test updates Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/c4e66b1d-ab22-4673-bb59-d9a16e9774a0 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/PHPStan/MetaValidUsage.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/MetaValidUsage.php b/tests/PHPStan/MetaValidUsage.php index a218a49..12d910c 100644 --- a/tests/PHPStan/MetaValidUsage.php +++ b/tests/PHPStan/MetaValidUsage.php @@ -7,14 +7,18 @@ require_once \dirname(__DIR__, 2) . '/.phpUnitAndStanFix.php'; /** - * @param ArrayShapeCity|null $city + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity|null $city */ function assertMetaFixtureNullableCity(?ArrayShapeCity $city): void { } /** - * @param ArrayShapeCity $city + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity $city */ function assertMetaFixtureCity(ArrayShapeCity $city): void { From 6d280193336e56e74494c6fc66d742d42f8da749 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:34:56 +0000 Subject: [PATCH 16/22] Stabilize array shape fixture and ensure build logs dir Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/4a27999e-ada6-4c83-94e8-4c92b3d507c7 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/PHPStan/ArrayShapeValidUsage.php | 22 ++++++++++++++++++++-- tests/bootstrap.php | 5 +++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php index a4d3d53..59bfae7 100644 --- a/tests/PHPStan/ArrayShapeValidUsage.php +++ b/tests/PHPStan/ArrayShapeValidUsage.php @@ -6,6 +6,24 @@ require_once \dirname(__DIR__, 2) . '/.phpUnitAndStanFix.php'; +/** + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity|null $city + */ +function assertValidArrayShapeNullableCity(?ArrayShapeCity $city): void +{ +} + +/** + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity $city + */ +function assertValidArrayShapeCity(ArrayShapeCity $city): void +{ +} + $user = new ArrayShapeUser([ 'id' => 1, 'firstName' => 'Lars', @@ -18,10 +36,10 @@ \PHPStan\Testing\assertType('int|null', $user['id']); \PHPStan\Testing\assertType('string|null', $user['firstName']); -\PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); +assertValidArrayShapeNullableCity($user['city']); if ($user['city'] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); + assertValidArrayShapeCity($user['city']); \PHPStan\Testing\assertType('string|null', $user['city']['name']); \PHPStan\Testing\assertType('null', $user['city']['plz']); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f1953e1..4960e81 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,4 +3,9 @@ \error_reporting(\E_ALL); \ini_set('display_errors', '1'); +$buildLogsDir = __DIR__ . '/../build/logs'; +if (!\is_dir($buildLogsDir)) { + \mkdir($buildLogsDir, 0777, true); +} + require_once __DIR__ . '/../vendor/autoload.php'; From 4678441d9bbcf612e1411f6f36eb15a9b8e08160 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:36:23 +0000 Subject: [PATCH 17/22] Tighten bootstrap log directory permissions Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/4a27999e-ada6-4c83-94e8-4c92b3d507c7 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4960e81..7554d9e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,7 +5,7 @@ $buildLogsDir = __DIR__ . '/../build/logs'; if (!\is_dir($buildLogsDir)) { - \mkdir($buildLogsDir, 0777, true); + \mkdir($buildLogsDir, 0755, true); } require_once __DIR__ . '/../vendor/autoload.php'; From b66e1ae4072d51a53fe75a976e8e55572005ea3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:37:46 +0000 Subject: [PATCH 18/22] Restrict bootstrap log directory permissions Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/4a27999e-ada6-4c83-94e8-4c92b3d507c7 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7554d9e..791b6dc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,7 +5,7 @@ $buildLogsDir = __DIR__ . '/../build/logs'; if (!\is_dir($buildLogsDir)) { - \mkdir($buildLogsDir, 0755, true); + \mkdir($buildLogsDir, 0700, true); } require_once __DIR__ . '/../vendor/autoload.php'; From 7bafc7f9a20c9b26e3a1cb33f53574eec8b8ce41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:17:36 +0000 Subject: [PATCH 19/22] Loosen lowest-deps array shape fixture assertion Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/78f9da24-6a45-484d-bf37-488a4c32cd76 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/PHPStan/ArrayShapeValidUsage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php index 59bfae7..526485f 100644 --- a/tests/PHPStan/ArrayShapeValidUsage.php +++ b/tests/PHPStan/ArrayShapeValidUsage.php @@ -41,5 +41,5 @@ function assertValidArrayShapeCity(ArrayShapeCity $city): void if ($user['city'] !== null) { assertValidArrayShapeCity($user['city']); \PHPStan\Testing\assertType('string|null', $user['city']['name']); - \PHPStan\Testing\assertType('null', $user['city']['plz']); + \PHPStan\Testing\assertType('string|null', $user['city']['plz']); } From 8b74740378f4b9997c66f919c3e28e24ca7ea6e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:29:17 +0000 Subject: [PATCH 20/22] Stabilize array-shape fixtures across dependency sets Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/5e56143b-2c0d-4d9d-8112-8db61419f0ec Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/PHPStan/ArrayShapeAccessTest.php | 19 +++++++++++++++---- tests/PHPStan/ArrayShapeValidUsage.php | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/ArrayShapeAccessTest.php b/tests/PHPStan/ArrayShapeAccessTest.php index f09d253..2675ee6 100644 --- a/tests/PHPStan/ArrayShapeAccessTest.php +++ b/tests/PHPStan/ArrayShapeAccessTest.php @@ -11,6 +11,17 @@ */ final class ArrayShapeAccessTest extends \PHPUnit\Framework\TestCase { + /** + * @param ArrayShapeCity|null $city + */ + private static function assertNullableCity(?ArrayShapeCity $city): void + { + } + + private static function assertNullableString(?string $value): void + { + } + public function testArrayShapeOffsetsAreTyped(): void { $user = new ArrayShapeUser([ @@ -26,12 +37,12 @@ public function testArrayShapeOffsetsAreTyped(): void \PHPStan\Testing\assertType('int|null', $user['id']); \PHPStan\Testing\assertType('string|null', $user['firstName']); \PHPStan\Testing\assertType('string|null', $user['lastName']); - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity|null', $user['city']); + self::assertNullableCity($user['city']); if ($user['city'] !== null) { - \PHPStan\Testing\assertType('Arrayy\tests\PHPStan\ArrayShapeCity', $user['city']); - \PHPStan\Testing\assertType('string|null', $user['city']['name']); - \PHPStan\Testing\assertType('null', $user['city']['plz']); + self::assertNullableCity($user['city']); + self::assertNullableString($user['city']['name']); + self::assertNullableString($user['city']['plz']); } self::assertSame('Moelleken', $user['lastName']); diff --git a/tests/PHPStan/ArrayShapeValidUsage.php b/tests/PHPStan/ArrayShapeValidUsage.php index 526485f..ffc1d63 100644 --- a/tests/PHPStan/ArrayShapeValidUsage.php +++ b/tests/PHPStan/ArrayShapeValidUsage.php @@ -24,6 +24,10 @@ function assertValidArrayShapeCity(ArrayShapeCity $city): void { } +function assertValidArrayShapeNullableString(?string $value): void +{ +} + $user = new ArrayShapeUser([ 'id' => 1, 'firstName' => 'Lars', @@ -41,5 +45,5 @@ function assertValidArrayShapeCity(ArrayShapeCity $city): void if ($user['city'] !== null) { assertValidArrayShapeCity($user['city']); \PHPStan\Testing\assertType('string|null', $user['city']['name']); - \PHPStan\Testing\assertType('string|null', $user['city']['plz']); + assertValidArrayShapeNullableString($user['city']['plz']); } From cdbeb45434ea11dc707d0eb3dd1fdda559811166 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:31:55 +0000 Subject: [PATCH 21/22] Template array-shape access fixture helper Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/5e56143b-2c0d-4d9d-8112-8db61419f0ec Co-authored-by: voku <264695+voku@users.noreply.github.com> --- tests/PHPStan/ArrayShapeAccessTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/ArrayShapeAccessTest.php b/tests/PHPStan/ArrayShapeAccessTest.php index 2675ee6..a13539c 100644 --- a/tests/PHPStan/ArrayShapeAccessTest.php +++ b/tests/PHPStan/ArrayShapeAccessTest.php @@ -12,7 +12,9 @@ final class ArrayShapeAccessTest extends \PHPUnit\Framework\TestCase { /** - * @param ArrayShapeCity|null $city + * @template TCity of array{name: string, plz?: string|null} + * + * @param ArrayShapeCity|null $city */ private static function assertNullableCity(?ArrayShapeCity $city): void { From 011cf0ec1152d918e37174525f518cf487e0761d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:45:32 +0000 Subject: [PATCH 22/22] Update release docs for meta and CI work Agent-Logs-Url: https://github.com/voku/Arrayy/sessions/b18664a2-0812-49f2-9963-dce9e17ea2f3 Co-authored-by: voku <264695+voku@users.noreply.github.com> --- CHANGELOG.md | 2 ++ README.md | 2 +- build/docs/base.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 335239e..aa24e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - fix `average()` so non-numeric values no longer error on modern PHP versions - make `changeKeyCase()` Unicode case conversion deterministic across PHP 8.0–8.5 - strengthen native property type checks, array-shape contracts, and regression coverage across Json mapper and collection helpers +- 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 ### 7.10.0 (2026-04-24) diff --git a/README.md b/README.md index a490fd3..55c48cd 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ $arrayy->Lars->lastname; // 'Müller' ## PhpDoc array-shape / property checking -The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. +The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. ```php /** diff --git a/build/docs/base.md b/build/docs/base.md index b182a06..793c9ed 100644 --- a/build/docs/base.md +++ b/build/docs/base.md @@ -117,7 +117,7 @@ $arrayy->Lars->lastname; // 'Müller' ## PhpDoc array-shape / property checking -The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. +The library offers type checking for phpdoc array-shape annotations, legacy `@property` phpdoc-class-comments, and native declared properties. Prefer the array-shape form because it can reuse the `Arrayy` template for IDE autocompletion and static-analysis support. `meta()` is also understood by PHPStan, so `meta()`-derived keys such as `$userMeta->city` and `$cityMeta->name` keep precise literal-string information during static analysis. When you want PHPStan to check reads precisely, prefer array-like access with literal keys (for example `$user['lastName']`) or narrowed `meta()` keys on these array-shape-based models. Do not combine array-shape annotations and `@property` tags on the same model. ```php /**