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
/**