Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8a77e8
Add meta PHPStan regression coverage
Copilot Apr 28, 2026
f90830a
Finish meta phpstan validation coverage
Copilot Apr 28, 2026
10ea9d7
Address validation feedback for meta coverage
Copilot Apr 28, 2026
5a7f695
Polish meta validation assertions
Copilot Apr 28, 2026
dc71c66
Tighten meta phpstan fixture handling
Copilot Apr 28, 2026
d13f07e
Finalize meta phpstan support polish
Copilot Apr 28, 2026
2841acf
Fix meta phpstan return type caching
Copilot Apr 28, 2026
50aa3f2
Refine meta phpstan follow-up
Copilot Apr 28, 2026
337123d
Skip meta phpstan tests at setup
Copilot Apr 28, 2026
84a7d98
Finalize meta phpstan goal follow-up
Copilot Apr 28, 2026
a07d959
Add array-shape constructor typing
Copilot Apr 28, 2026
d38bb58
Add array-shape phpstan coverage
Copilot Apr 28, 2026
a56fdbe
Apply fixes from StyleCI
StyleCIBot Apr 29, 2026
836abe2
Merge pull request #166 from voku/analysis-0pdKkL
voku Apr 29, 2026
044531f
Stabilize meta fixture and add extension tests
Copilot Apr 29, 2026
623e091
Validate meta phpstan test updates
Copilot Apr 29, 2026
6d28019
Stabilize array shape fixture and ensure build logs dir
Copilot Apr 30, 2026
4678441
Tighten bootstrap log directory permissions
Copilot Apr 30, 2026
b66e1ae
Restrict bootstrap log directory permissions
Copilot Apr 30, 2026
7bafc7f
Loosen lowest-deps array shape fixture assertion
Copilot Apr 30, 2026
8b74740
Stabilize array-shape fixtures across dependency sets
Copilot Apr 30, 2026
cdbeb45
Template array-shape access fixture helper
Copilot Apr 30, 2026
011cf0e
Update release docs for meta and CI work
Copilot Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
2 changes: 1 addition & 1 deletion build/docs/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ parameters:
paths:
- %currentWorkingDirectory%/src/
- %currentWorkingDirectory%/tests/

services:
-
class: Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension
tags:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
2 changes: 2 additions & 0 deletions src/Arrayy.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class Arrayy extends \ArrayObject implements \IteratorAggregate, \ArrayAccess, \
* true, otherwise this option didn't not work anyway.
* </p>
*
* @phpstan-param TData|self<TKey,T,TData>|\Traversable<TKey,T>|callable|object|scalar|null $data
* @phpstan-param class-string<\Arrayy\ArrayyIterator<TKey,T>> $iteratorClass
*/
public function __construct(
Expand Down Expand Up @@ -1752,6 +1753,7 @@ public function countValues(): self
* @return static
* <p>(Immutable) Returns an new instance of the Arrayy object.</p>
*
* @phpstan-param TData|self<TKey,T,TData>|\Traversable<TKey,T>|callable|object|scalar|null $data
* @phpstan-param class-string<\Arrayy\ArrayyIterator<TKey,T>> $iteratorClass
* @phpstan-return static
* @psalm-mutation-free
Expand Down
63 changes: 63 additions & 0 deletions src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

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;

final class MetaDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var array<class-string, ObjectShapeType>
*/
private array $types = [];

public function getClass(): string
{
return Arrayy::class;
}

public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'meta';
}

public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type

Check warning on line 34 in src/PHPStan/MetaDynamicStaticMethodReturnTypeExtension.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=voku_Arrayy&issues=AZ3ZrXXJdL2e0yQgiPsc&open=AZ3ZrXXJdL2e0yQgiPsc&pullRequest=165
{
if (!$methodCall->class instanceof Name) {
return null;
}

$className = $scope->resolveName($methodCall->class);
if (!\is_a($className, Arrayy::class, true)) {
return null;
}

if (isset($this->types[$className])) {
return $this->types[$className];
}

/** @var \Arrayy\ArrayyMeta $meta */
$meta = $className::meta();
$properties = [];
foreach (\get_object_vars($meta) as $propertyName => $value) {
if (!\is_string($propertyName) || !\is_string($value)) {
continue;
}

$properties[$propertyName] = new ConstantStringType($value);
}

// Passing an empty optionalProperties list makes every inferred meta key concrete/required.
return $this->types[$className] = new ObjectShapeType($properties, []);
}
}
104 changes: 104 additions & 0 deletions tests/MetaDynamicStaticMethodReturnTypeExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace Arrayy\tests;

use Arrayy\PHPStan\MetaDynamicStaticMethodReturnTypeExtension;
use Arrayy\tests\PHPStan\ArrayShapeUser;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\ObjectShapeType;
use PHPStan\Type\VerbosityLevel;

require_once __DIR__ . '/PHPStan/ArrayShapeCity.php';
require_once __DIR__ . '/PHPStan/ArrayShapeUser.php';

/**
* @internal
*/
final class MetaDynamicStaticMethodReturnTypeExtensionTest extends \PHPUnit\Framework\TestCase
{
public function testGetClassTargetsArrayy(): void
{
$extension = new MetaDynamicStaticMethodReturnTypeExtension();

self::assertSame(\Arrayy\Arrayy::class, $extension->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())
);
}
}
102 changes: 102 additions & 0 deletions tests/MetaPhpStanIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Arrayy\tests;

/**
* @internal
*/
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
{
$this->assertFixturePassesPhpStan('MetaValidUsage.php');
}

public function testPhpStanAcceptsValidArrayShapeUsage(): void
{
$this->assertFixturePassesPhpStan('ArrayShapeValidUsage.php');
}

public function testPhpStanRejectsInvalidMetaUsage(): void
{
$output = $this->assertFixtureFailsPhpStan('MetaInvalidUsage.php');

static::assertStringContainsString('Access to an undefined property', $output);
static::assertStringContainsString('$ghost', $output);
static::assertStringContainsString('strlen', $output);
static::assertStringContainsString('expects string', $output);
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}
*/
private function runPhpStanFixture(string $fixtureFile): array
{
$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, (string) $stdout, (string) $stderr];
}
}
53 changes: 53 additions & 0 deletions tests/MetaRuntimeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Arrayy\tests;

use Arrayy\tests\PHPStan\ArrayShapeCity;
use Arrayy\tests\PHPStan\ArrayShapeUser;

/**
* @internal
*/
final class MetaRuntimeTest extends \PHPUnit\Framework\TestCase
{
public function testArrayShapeMetaSupportsNestedRuntimeAccess(): void
{
$cityMeta = ArrayShapeCity::meta();
$city = new ArrayShapeCity([
$cityMeta->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->expectExceptionMessageMatches('/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';
}
}
Loading
Loading