From a4d5198977cd1aae945059308cfa7cee726bdb30 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 1 Oct 2026 22:00:04 +0800 Subject: [PATCH 1/2] Improve generic Class static method and static property handling --- src/Internal/Checker/InlineChecker.php | 54 ++- src/Internal/Checker/ParamChecker.php | 6 +- src/Internal/Docblock/DocblockExtractor.php | 9 +- src/Internal/RuntimeTypeChecker.php | 5 +- .../StaticGenericMethodCallIsolationTest.php | 401 ++++++++++++++++++ .../Generics/StaticGenericPropertyTest.php | 204 +++++++++ 6 files changed, 669 insertions(+), 10 deletions(-) create mode 100644 tests/TypeChecking/Generics/StaticGenericMethodCallIsolationTest.php create mode 100644 tests/TypeChecking/Generics/StaticGenericPropertyTest.php diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 3edd73b..2562b91 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -78,6 +78,13 @@ final class InlineChecker */ private static array $classHasSelfOutCache = []; + /** + * In-memory cache for resolved static property generic types: [$className][$propName] => TypeNode. + * + * @var array> + */ + private static array $resolvedStaticPropertyTypeCache = []; + /** * Resets internal type node and function caches. Useful for test isolation. */ @@ -88,6 +95,7 @@ public static function reset(): void self::$nullPropertyCache = []; self::$propertyUsesTemplatesCache = []; self::$classHasSelfOutCache = []; + self::$resolvedStaticPropertyTypeCache = []; } /** @@ -304,9 +312,6 @@ private static function isArrayShapeType(TypeNode $node): bool return false; } - /** - * Evaluates class property validation dynamically based on configuration with zero-allocation 2D caching. - */ /** * Evaluates class property validation dynamically based on configuration with zero-allocation 2D caching. */ @@ -326,8 +331,9 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } + // 1. Enforce magic property access permissions only for non-physical dynamic properties $rawTypeNode = null; - if (Config::isMagicPropertiesEnabled()) { + if (! property_exists($className, $propName) && Config::isMagicPropertiesEnabled()) { $magicContract = DocblockParser::parseMagicPropertyContract($className, $propName); if ($magicContract !== null) { if (! Config::isMagicPropertyWritesEnabled()) { @@ -342,6 +348,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string } } + // 2. Fall back to standard declared class property if not intercepted by a magic contract if ($rawTypeNode === null) { $rawTypeNode = DocblockParser::parseProperty($className, $propName); } @@ -364,6 +371,11 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string if ($propertyUsesTemplates) { $typeNode = self::substitutePropertyGenerics($rawTypeNode, $objectOrClass, $className); } + } elseif (\is_string($objectOrClass)) { + $propertyUsesTemplates = self::propertyUsesTemplates($rawTypeNode, $className, $propName); + if ($propertyUsesTemplates) { + $typeNode = self::substituteStaticPropertyGenerics($rawTypeNode, $className, $propName); + } } try { @@ -673,6 +685,40 @@ private static function substitutePropertyGenerics(TypeNode $typeNode, object $o return $typeNode; } + /** + * Substitutes generic template types declared on static class properties with zero-allocation memoization. + */ + private static function substituteStaticPropertyGenerics(TypeNode $typeNode, string $className, string $propName): TypeNode + { + if (isset(self::$resolvedStaticPropertyTypeCache[$className][$propName])) { + return self::$resolvedStaticPropertyTypeCache[$className][$propName]; + } + + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $classBindings = TemplateManager::getClassInheritedBindings($className); + $classAliases = DocblockParser::parseClassAliases($className); + $activeBindings = [...$classAliases, ...$classBindings]; + + if (\count($activeBindings) > 0 || \count($allTemplates) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $allTemplates); + + if (class_exists($className, false) || class_exists($className) || interface_exists($className) || trait_exists($className)) { + try { + /** @var class-string $className */ + $refClass = new \ReflectionClass($className); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); + } catch (\ReflectionException $e) { + // Silently continue if reflection fails + } + } + } + + return self::$resolvedStaticPropertyTypeCache[$className][$propName] = $typeNode; + } + /** * Inspects a TypeNode to check if it requires caller context resolution (templates, aliases, self/static/$this). */ diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index e0b6d82..9241ed9 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -273,8 +273,10 @@ private static function prepareGenericBindings( array $classTemplates, object|string|null $thisOrClass = null ): void { - if (\count($methodTemplates) > 0) { - TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates); + $hasTemplatesToClear = \count($methodTemplates) > 0 || ($thisObj === null && \count($classTemplates) > 0); + + if ($hasTemplatesToClear) { + TemplateManager::clearCallBindings($effectiveFunction, [...$classTemplates, ...$methodTemplates]); } if (\count($classTemplates) > 0 && str_contains($effectiveFunction, '::')) { diff --git a/src/Internal/Docblock/DocblockExtractor.php b/src/Internal/Docblock/DocblockExtractor.php index abfc122..0bf87e0 100644 --- a/src/Internal/Docblock/DocblockExtractor.php +++ b/src/Internal/Docblock/DocblockExtractor.php @@ -111,6 +111,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam foreach ($phpDocNode->getPropertyTagValues() as $tag) { if (ltrim($tag->propertyName, '$') === $propName) { $propTag = $tag; + break; } } @@ -119,6 +120,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam foreach ($phpDocNode->getPropertyReadTagValues() as $tag) { if (ltrim($tag->propertyName, '$') === $propName) { $readTag = $tag; + break; } } @@ -127,6 +129,7 @@ public static function extractMagicPropertyContract(string $doc, string $propNam foreach ($phpDocNode->getPropertyWriteTagValues() as $tag) { if (ltrim($tag->propertyName, '$') === $propName) { $writeTag = $tag; + break; } } @@ -338,9 +341,9 @@ public static function getVarTags(PhpDocNode $node): array } $unnamed = []; - $unnamedPhpstan = array_values(array_filter($node->getVarTagValues('@phpstan-var'), fn($t) => $t->variableName === '')); - $unnamedPsalm = array_values(array_filter($node->getVarTagValues('@psalm-var'), fn($t) => $t->variableName === '')); - $unnamedStandard = array_values(array_filter($node->getVarTagValues('@var'), fn($t) => $t->variableName === '')); + $unnamedPhpstan = array_values(array_filter($node->getVarTagValues('@phpstan-var'), fn ($t) => $t->variableName === '')); + $unnamedPsalm = array_values(array_filter($node->getVarTagValues('@psalm-var'), fn ($t) => $t->variableName === '')); + $unnamedStandard = array_values(array_filter($node->getVarTagValues('@var'), fn ($t) => $t->variableName === '')); if (\count($unnamedPhpstan) > 0) { $unnamed = $unnamedPhpstan; diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index d322c32..069fee6 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -253,8 +253,11 @@ public static function setupScope(string $function, array $vars, object|string|n $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null; if ($hasMethodTemplates === null) { + $isStaticCall = ($thisObj === null); $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] = ( - (($contract['templates'] ?? []) !== []) || ($contract['returnUsesMethodTemplates'] ?? false) + (($contract['templates'] ?? []) !== []) + || ($contract['returnUsesMethodTemplates'] ?? false) + || ($isStaticCall && ($contract['classTemplates'] ?? []) !== []) ); self::$hasMethodTemplatesCache[$function] = $hasMethodTemplates; } diff --git a/tests/TypeChecking/Generics/StaticGenericMethodCallIsolationTest.php b/tests/TypeChecking/Generics/StaticGenericMethodCallIsolationTest.php new file mode 100644 index 0000000..5e97558 --- /dev/null +++ b/tests/TypeChecking/Generics/StaticGenericMethodCallIsolationTest.php @@ -0,0 +1,401 @@ + + */ +class ParameterizedStaticChild extends ConfigurableStaticParent +{ +} + +describe('Static Generic Method Call-Stack Isolation & Leak Prevention', function () { + describe('1. Simulated Long-Running Process: Class-Level Templates on Static Methods', function () { + test('does not leak template bindings across sequential static method calls (Simulated Requests 1 to 4)', function () { + $req1 = ClassLevelTemplateStaticBucket::store(new StaticUserFixture('Bob')); + expect($req1)->toBeInstanceOf(StaticUserFixture::class); + + $req2 = ClassLevelTemplateStaticBucket::store(new StaticProductFixture('SKU-2')); + expect($req2)->toBeInstanceOf(StaticProductFixture::class); + + $req3 = ClassLevelTemplateStaticBucket::store(new StaticOrderFixture(100)); + expect($req3)->toBeInstanceOf(StaticOrderFixture::class); + + $req4 = ClassLevelTemplateStaticBucket::store(new StaticUserFixture('Alice')); + expect($req4)->toBeInstanceOf(StaticUserFixture::class); + }); + + test('cleans up call-stack bindings in TemplateManager after static method execution', function () { + ClassLevelTemplateStaticBucket::store(new StaticUserFixture('Bob')); + + $ref = new ReflectionClass(TemplateManager::class); + $prop = $ref->getProperty('callStackBindings'); + $callStack = $prop->getValue(); + + $targetKey = ClassLevelTemplateStaticBucket::class . '::store'; + $frames = $callStack[$targetKey] ?? []; + + expect($frames)->toBeEmpty(); + }); + }); + + describe('2. Within-Call Parameter Consistency (Same Static Call)', function () { + test('accepts identical types for multiple parameters of type T in the same static call', function () { + $userA = new StaticUserFixture('UserA'); + $userB = new StaticUserFixture('UserB'); + + expect(ClassLevelTemplateStaticBucket::compareSame($userA, $userB))->toBeTrue(); + }); + + test('rejects conflicting types passed to multiple parameters of type T in the same static call', function () { + $user = new StaticUserFixture('UserA'); + $product = new StaticProductFixture('SKU-100'); + + expect(fn () => ClassLevelTemplateStaticBucket::compareSame($user, $product)) + ->toThrow(TypeError::class, 'StaticUserFixture') + ; + }); + }); + + describe('3. Method-Level Templates on Static Methods (@template T on method)', function () { + test('allows sequential calls with different types on method-level static templates without leakage', function () { + $res1 = MethodLevelTemplateStaticBucket::process(new StaticUserFixture()); + expect($res1)->toBeInstanceOf(StaticUserFixture::class); + + $res2 = MethodLevelTemplateStaticBucket::process(new StaticProductFixture()); + expect($res2)->toBeInstanceOf(StaticProductFixture::class); + + $res3 = MethodLevelTemplateStaticBucket::process(42); + expect($res3)->toBe(42); + }); + + test('strictly enforces consistency for multiple parameters within the same method-level static call', function () { + expect(MethodLevelTemplateStaticBucket::assertSameType(10, 20))->toBeTrue(); + + expect(fn () => MethodLevelTemplateStaticBucket::assertSameType(10, 'string')) + ->toThrow(TypeError::class, 'int') + ; + }); + }); + + describe('4. Bounded Class-Level Templates on Static Methods (@template T of Animal)', function () { + test('accepts any valid subtype of Animal across sequential static calls', function () { + $dog = BoundedClassLevelStaticBucket::registerPet(new Dog()); + expect($dog)->toBeInstanceOf(Dog::class); + + $cat = BoundedClassLevelStaticBucket::registerPet(new Cat()); + expect($cat)->toBeInstanceOf(Cat::class); + }); + + test('rejects non-Animal types on static method call', function () { + expect(fn () => BoundedClassLevelStaticBucket::registerPet(new Car())) + ->toThrow(TypeError::class, 'Animal') + ; + }); + }); + + describe('5. Traits Providing Generic Static Methods', function () { + test('does not leak generic bindings across sequential calls to static methods provided by traits', function () { + $res1 = ClassUsingStaticGenericTrait::logRecord(new StaticUserFixture('User-Trait')); + expect($res1)->toBeInstanceOf(StaticUserFixture::class); + + $res2 = ClassUsingStaticGenericTrait::logRecord(new StaticProductFixture('Product-Trait')); + expect($res2)->toBeInstanceOf(StaticProductFixture::class); + + $res3 = ClassUsingStaticGenericTrait::logRecord(new StaticOrderFixture(777)); + expect($res3)->toBeInstanceOf(StaticOrderFixture::class); + }); + + test('enforces parameter consistency within the same call on trait static methods', function () { + expect(ClassUsingStaticGenericTrait::assertTraitPair(100, 200))->toBeTrue(); + + expect(fn () => ClassUsingStaticGenericTrait::assertTraitPair(100, 'mismatch')) + ->toThrow(TypeError::class, 'int') + ; + }); + + test('keeps multiple traits on the same class isolated without cross-contamination', function () { + $resFirst = MultiTraitStaticService::firstAction(new StaticUserFixture()); + expect($resFirst)->toBeInstanceOf(StaticUserFixture::class); + + $resSecond = MultiTraitStaticService::secondAction(new StaticProductFixture()); + expect($resSecond)->toBeInstanceOf(StaticProductFixture::class); + }); + }); + + describe('6. Abstract Class Inheritance with Static Generic Methods', function () { + test('does not leak generic bindings across sequential static calls on concrete child class', function () { + $call1 = ConcreteStaticGenericChild::handlePayload(new StaticUserFixture()); + expect($call1)->toBeInstanceOf(StaticUserFixture::class); + + $call2 = ConcreteStaticGenericChild::handlePayload(new StaticProductFixture()); + expect($call2)->toBeInstanceOf(StaticProductFixture::class); + + $call3 = ConcreteStaticGenericChild::handlePayload(500); + expect($call3)->toBe(500); + }); + }); + + describe('7. Multi-Level Deep Inheritance Hierarchy (GrandParent -> Parent -> Child)', function () { + test('isolates static generic calls across 3 hierarchy levels', function () { + $childRes1 = ChildStaticGenericHolder::dispatch(new StaticUserFixture()); + expect($childRes1)->toBeInstanceOf(StaticUserFixture::class); + + $childRes2 = ChildStaticGenericHolder::dispatch(new StaticProductFixture()); + expect($childRes2)->toBeInstanceOf(StaticProductFixture::class); + + $parentRes = ParentStaticGenericHolder::dispatch(new StaticOrderFixture(10)); + expect($parentRes)->toBeInstanceOf(StaticOrderFixture::class); + + $grandParentRes = GrandParentStaticGenericHolder::dispatch('raw_string'); + expect($grandParentRes)->toBe('raw_string'); + }); + }); + + describe('8. Statically Parameterized Subclass (@extends Parent)', function () { + test('enforces static @extends binding on child while keeping unparameterized parent isolated', function () { + $user = new StaticUserFixture('Alice'); + $childResult = ParameterizedStaticChild::save($user); + expect($childResult)->toBe($user); + + expect(fn () => ParameterizedStaticChild::save(new StaticProductFixture())) + ->toThrow(TypeError::class) + ; + + $parentProduct = new StaticProductFixture('Parent-Product'); + $parentResult = ConfigurableStaticParent::save($parentProduct); + expect($parentResult)->toBe($parentProduct); + }); + }); +}); diff --git a/tests/TypeChecking/Generics/StaticGenericPropertyTest.php b/tests/TypeChecking/Generics/StaticGenericPropertyTest.php new file mode 100644 index 0000000..cb43269 --- /dev/null +++ b/tests/TypeChecking/Generics/StaticGenericPropertyTest.php @@ -0,0 +1,204 @@ + + */ +class UserStaticRegistry extends BaseGenericStaticRegistry +{ +} + +/** + * Closed generic subclass binding T to StaticPropAdmin + * + * @extends BaseGenericStaticRegistry + */ +class AdminStaticRegistry extends BaseGenericStaticRegistry +{ +} + +/** + * @template T of StaticPropModel + */ +class BoundedStaticStorage +{ + /** + * @var T|null + */ + public static mixed $item = null; +} + +/** + * @template T + */ +class UnboundStaticBucket +{ + /** + * @var T|null + */ + public static mixed $element = null; +} + +/** + * @template T + */ +trait HasGenericStaticCache +{ + /** + * @var T|null + */ + public static mixed $cached = null; +} + +class UserCachedService +{ + /** + * @use HasGenericStaticCache + */ + use HasGenericStaticCache; +} + +describe('Static Generic Property Contract Enforcement', function () { + beforeEach(function () { + UserStaticRegistry::$current = null; + AdminStaticRegistry::$current = null; + BoundedStaticStorage::$item = null; + UnboundStaticBucket::$element = null; + UserCachedService::$cached = null; + }); + + afterEach(function () { + UserStaticRegistry::$current = null; + AdminStaticRegistry::$current = null; + BoundedStaticStorage::$item = null; + UnboundStaticBucket::$element = null; + UserCachedService::$cached = null; + }); + + describe('1. Parameterized Subclasses (@extends Base)', function () { + test('accepts valid instance matching @extends generic parameter on static property', function () { + $user = new StaticPropUser('Alice'); + UserStaticRegistry::$current = $user; + + expect(UserStaticRegistry::$current)->toBe($user); + }); + + test('accepts subtype of @extends generic parameter (Admin is a User)', function () { + $admin = new StaticPropAdmin('SuperAdmin'); + UserStaticRegistry::$current = $admin; + + expect(UserStaticRegistry::$current)->toBe($admin); + }); + + test('rejects unrelated class assigned to statically parameterized property', function () { + expect(function () { + UserStaticRegistry::$current = new StaticPropProduct('SKU-100'); + })->toThrow(TypeError::class, 'StaticPropUser'); + }); + + test('strictly enforces narrower binding on specialized subclass (Admin vs User)', function () { + $admin = new StaticPropAdmin('AdminOnly'); + AdminStaticRegistry::$current = $admin; + expect(AdminStaticRegistry::$current)->toBe($admin); + + expect(function () { + AdminStaticRegistry::$current = new StaticPropUser('RegularUser'); + })->toThrow(TypeError::class, 'StaticPropAdmin'); + }); + }); + + describe('2. Unparameterized Class with Upper Bound (@template T of Model)', function () { + test('accepts any subtype satisfying declared upper bound on static property', function () { + $user = new StaticPropUser('Bob'); + BoundedStaticStorage::$item = $user; + expect(BoundedStaticStorage::$item)->toBe($user); + + $admin = new StaticPropAdmin('AdminBob'); + BoundedStaticStorage::$item = $admin; + expect(BoundedStaticStorage::$item)->toBe($admin); + }); + + test('rejects class violating declared upper bound on unparameterized static property', function () { + expect(function () { + BoundedStaticStorage::$item = new StaticPropProduct('SKU-FAIL'); + })->toThrow(TypeError::class, 'StaticPropModel'); + }); + + test('rejects primitive violating object upper bound on unparameterized static property', function () { + expect(function () { + BoundedStaticStorage::$item = 'scalar_string'; + })->toThrow(TypeError::class, 'StaticPropModel'); + }); + }); + + describe('3. Unparameterized Class with Unbound Template (@template T falls back to mixed)', function () { + test('allows any value without throwing false "must be of type T" error', function () { + UnboundStaticBucket::$element = 42; + expect(UnboundStaticBucket::$element)->toBe(42); + + UnboundStaticBucket::$element = 'hello'; + expect(UnboundStaticBucket::$element)->toBe('hello'); + + $user = new StaticPropUser('Charlie'); + UnboundStaticBucket::$element = $user; + expect(UnboundStaticBucket::$element)->toBe($user); + }); + }); + + describe('4. Traits with Generic Static Properties (@use Trait)', function () { + test('resolves trait template binding on static property assigned through class', function () { + $user = new StaticPropUser('Dave'); + UserCachedService::$cached = $user; + + expect(UserCachedService::$cached)->toBe($user); + }); + + test('rejects invalid type assigned to trait static property', function () { + expect(function () { + UserCachedService::$cached = new StaticPropProduct('SKU-TRAIT'); + })->toThrow(TypeError::class, 'StaticPropUser'); + }); + }); +}); From 7a23a407803a3ec9fc5eb5aafcd2bfdc144f3df2 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 1 Oct 2026 22:43:34 +0800 Subject: [PATCH 2/2] Enhance destructuring type propagation for array shapes and implement comprehensive tests for type validation --- src/Internal/Ast/ContractVisitor.php | 93 ++++++++ src/Internal/Ast/ScopeManager.php | 13 ++ .../DestructuringTypePropagationTest.php | 206 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 tests/TypeChecking/ArraysAndShapes/DestructuringTypePropagationTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index c37dd36..0078c1e 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -6,7 +6,10 @@ use PhpParser\Node; use PhpParser\NodeVisitorAbstract; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Parser\TokenIterator; use TypePHP\Internal\Docblock\DocblockExtractor; +use TypePHP\Internal\Docblock\DocblockNormalizer; use TypePHP\Internal\Util\Config; /** @@ -30,6 +33,11 @@ final class ContractVisitor extends NodeVisitorAbstract Node\Expr\AssignOp\Coalesce::class => Node\Expr\BinaryOp\Coalesce::class, ]; + /** + * @var array> + */ + private static array $parsedShapeCache = []; + private ScopeManager $scopeManager; private string $currentNamespace = ''; @@ -441,6 +449,16 @@ private function handleExpression(Node\Stmt\Expression $node): ?array /** @var Node\Expr\List_|Node\Expr\Array_ $destructuringVar */ $destructuringVar = $node->expr->var; + + // Auto-propagate types from RHS if it's a known ArrayShape in scope + $rhs = $node->expr->expr; + if ($rhs instanceof Node\Expr\Variable && \is_string($rhs->name)) { + $sourceType = $this->scopeManager->getVarTypeFromScope($rhs->name); + if ($sourceType !== null && str_contains($sourceType, '{')) { + $this->propagateShapeTypesToDestructuring($destructuringVar, $sourceType); + } + } + $destructuredVars = $this->extractDestructuringVariables($destructuringVar); $checkStmts = []; @@ -463,6 +481,81 @@ private function handleExpression(Node\Stmt\Expression $node): ?array return $checkStmts !== [] ? [$node, ...$checkStmts] : null; } + /** + * Propagates field types from an array shape into destructured local variables with O(1) AST memoization. + */ + private function propagateShapeTypesToDestructuring(Node\Expr\List_|Node\Expr\Array_ $destructuringVar, string $shapeTypeString): void + { + try { + $shapeMap = self::$parsedShapeCache[$shapeTypeString] ?? null; + + if ($shapeMap === null) { + $normalized = DocblockNormalizer::normalize($shapeTypeString); + [$typeParser, $lexer] = DocblockExtractor::getTypeParserComponents(); + $tokens = new TokenIterator($lexer->tokenize($normalized)); + $parsed = $typeParser->parse($tokens); + + if (! ($parsed instanceof ArrayShapeNode)) { + return; + } + + $shapeMap = []; + $autoIdx = 0; + foreach ($parsed->items as $item) { + $key = match (true) { + $item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode => $item->keyName->value, + $item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode => (int) $item->keyName->value, + $item->keyName instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode => $item->keyName->name, + $item->keyName !== null => (string) $item->keyName, + default => $autoIdx, + }; + + if (\is_int($key)) { + $autoIdx = max($autoIdx, $key + 1); + } + + $shapeMap[$key] = (string) $item->valueType; + } + + self::$parsedShapeCache[$shapeTypeString] = $shapeMap; + } + + $currentIdx = 0; + foreach ($destructuringVar->items as $item) { + if ($item === null) { + $currentIdx++; + + continue; + } + + $destructKey = null; + if ($item->key instanceof Node\Scalar\String_) { + $destructKey = $item->key->value; + } elseif ($item->key instanceof Node\Scalar\LNumber) { + $destructKey = $item->key->value; + $currentIdx = max($currentIdx, $destructKey + 1); + } elseif ($item->key === null) { + $destructKey = $currentIdx; + $currentIdx++; + } + + if ($destructKey !== null && isset($shapeMap[$destructKey])) { + $targetVar = $item->value; + $fieldType = $shapeMap[$destructKey]; + + if ($targetVar instanceof Node\Expr\Variable && \is_string($targetVar->name)) { + $this->scopeManager->registerVarType($targetVar->name, $fieldType); + } elseif ($targetVar instanceof Node\Expr\List_ || $targetVar instanceof Node\Expr\Array_) { + // Recursively propagate nested shape fields + $this->propagateShapeTypesToDestructuring($targetVar, $fieldType); + } + } + } + } catch (\Throwable $e) { + // Silently ignore parsing errors + } + } + private function handleAssign(Node\Expr\Assign $node): void { if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) { diff --git a/src/Internal/Ast/ScopeManager.php b/src/Internal/Ast/ScopeManager.php index 66c8de0..2fb0087 100644 --- a/src/Internal/Ast/ScopeManager.php +++ b/src/Internal/Ast/ScopeManager.php @@ -29,6 +29,19 @@ final class ScopeManager */ private int $depth = 0; + /** + * Programmatically registers a variable's type into the current scope frame. + * Preserves explicit inline @var declarations in the current frame unless overwrite is true. + */ + public function registerVarType(string $varName, string $typeString, bool $overwrite = false): void + { + if (! $overwrite && isset($this->scopeStack[$this->depth][$varName])) { + return; + } + + $this->scopeStack[$this->depth][$varName] = $typeString; + } + /** * Pushes a new lexical scope frame (O(1)). * diff --git a/tests/TypeChecking/ArraysAndShapes/DestructuringTypePropagationTest.php b/tests/TypeChecking/ArraysAndShapes/DestructuringTypePropagationTest.php new file mode 100644 index 0000000..1a1a334 --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/DestructuringTypePropagationTest.php @@ -0,0 +1,206 @@ + $var] = $source)', function () { + test('propagates member types to extracted variables and allows valid reassignments', function () { + /** @var array{id: positive-int, name: non-empty-string} $source */ + $source = ['id' => 1, 'name' => 'Alice']; + + ['id' => $extractedId, 'name' => $extractedName] = $source; + + expect($extractedId)->toBe(1) + ->and($extractedName)->toBe('Alice') + ; + + $extractedId = 42; + $extractedName = 'Bob'; + + expect($extractedId)->toBe(42) + ->and($extractedName)->toBe('Bob') + ; + }); + + test('throws TypeError when variable extracted from shape is reassigned to invalid integer', function () { + /** @var array{id: positive-int, name: non-empty-string} $source */ + $source = ['id' => 1, 'name' => 'Alice']; + + ['id' => $extractedId, 'name' => $extractedName] = $source; + + expect(function () use (&$extractedId) { + $extractedId = -5; + })->toThrow(TypeError::class, 'Variable $extractedId must be of type positive-int'); + + expect(function () use (&$extractedId) { + $extractedId = 0; + })->toThrow(TypeError::class, 'Variable $extractedId must be of type positive-int'); + }); + + test('throws TypeError when variable extracted from shape is reassigned to invalid string', function () { + /** @var array{id: positive-int, name: non-empty-string} $source */ + $source = ['id' => 1, 'name' => 'Alice']; + + ['id' => $extractedId, 'name' => $extractedName] = $source; + + expect(function () use (&$extractedName) { + $extractedName = ''; + })->toThrow(TypeError::class, 'Variable $extractedName must be of type non-empty-string'); + }); + }); + + describe('Positional Tuple Destructuring ([$a, $b] = $source)', function () { + test('propagates positional tuple types and prevents invalid reassignments', function () { + /** @var array{positive-int, non-empty-string} $tupleSource */ + $tupleSource = [10, 'First']; + + [$first, $second] = $tupleSource; + + expect($first)->toBe(10) + ->and($second)->toBe('First') + ; + + $first = 99; + $second = 'Updated'; + expect($first)->toBe(99)->and($second)->toBe('Updated'); + + expect(function () use (&$first) { + $first = -10; + })->toThrow(TypeError::class, 'Variable $first must be of type positive-int'); + + expect(function () use (&$second) { + $second = ''; + })->toThrow(TypeError::class, 'Variable $second must be of type non-empty-string'); + }); + + test('propagates types when elements are skipped using empty comma syntax', function () { + /** @var array{positive-int, string, int<1, 100>} $tupleSource */ + $tupleSource = [42, 'skipped', 50]; + + [$first, , $third] = $tupleSource; + + expect($first)->toBe(42) + ->and($third)->toBe(50) + ; + + expect(function () use (&$first) { + $first = 0; + })->toThrow(TypeError::class, 'Variable $first must be of type positive-int'); + + expect(function () use (&$third) { + $third = 150; + })->toThrow(TypeError::class, '<= 100'); + }); + }); + + describe('Traditional list(...) Syntax Destructuring', function () { + test('propagates types when using traditional list() syntax with associative keys', function () { + /** @var array{code: positive-int, label: non-empty-string} $data */ + $data = ['code' => 200, 'label' => 'OK']; + + list('code' => $code, 'label' => $label) = $data; + + expect($code)->toBe(200) + ->and($label)->toBe('OK') + ; + + expect(function () use (&$code) { + $code = -1; + })->toThrow(TypeError::class, 'Variable $code must be of type positive-int'); + + expect(function () use (&$label) { + $label = ''; + })->toThrow(TypeError::class, 'Variable $label must be of type non-empty-string'); + }); + }); + + describe('Nested Destructuring ([[\'id\' => $id]] = $source)', function () { + test('recursively propagates types through nested array shape destructuring', function () { + /** + * @var array{ + * account: array{id: positive-int, role: 'admin'|'user'}, + * status: non-empty-string + * } $payload + */ + $payload = [ + 'account' => ['id' => 100, 'role' => 'admin'], + 'status' => 'active', + ]; + + ['account' => ['id' => $accountId, 'role' => $accountRole], 'status' => $status] = $payload; + + expect($accountId)->toBe(100) + ->and($accountRole)->toBe('admin') + ->and($status)->toBe('active') + ; + + $accountRole = 'user'; + expect($accountRole)->toBe('user'); + + expect(function () use (&$accountId) { + $accountId = -99; + })->toThrow(TypeError::class, 'Variable $accountId must be of type positive-int'); + + expect(function () use (&$accountRole) { + $accountRole = 'superadmin'; + })->toThrow(TypeError::class, "('admin' | 'user')"); + + expect(function () use (&$status) { + $status = ''; + })->toThrow(TypeError::class, 'Variable $status must be of type non-empty-string'); + }); + }); + + describe('Complex Types Propagated from Shapes (Unions & Refinements)', function () { + test('propagates literal union types and validates reassignment against union', function () { + /** @var array{mode: 'sync'|'async', timeout: int<1, 60>} $options */ + $options = ['mode' => 'sync', 'timeout' => 30]; + + ['mode' => $mode, 'timeout' => $timeout] = $options; + + $mode = 'async'; + $timeout = 10; + expect($mode)->toBe('async')->and($timeout)->toBe(10); + + expect(function () use (&$mode) { + $mode = 'deferred'; + })->toThrow(TypeError::class, "('sync' | 'async')"); + + expect(function () use (&$timeout) { + $timeout = 120; + })->toThrow(TypeError::class, '<= 60'); + }); + }); + + describe('Non-Regressions & Unannotated Fallbacks', function () { + test('does not interfere with destructuring from untyped raw arrays', function () { + $untyped = ['x' => 1, 'y' => 'hello']; + + ['x' => $x, 'y' => $y] = $untyped; + + expect($x)->toBe(1)->and($y)->toBe('hello'); + + $x = -50; + $y = ''; + expect($x)->toBe(-50)->and($y)->toBe(''); + }); + + test('retaining explicit inline @var on destructuring statement overrides source shape', function () { + /** @var array{id: int, name: string} $source */ + $source = ['id' => 10, 'name' => 'Alice']; + + /** + * Explicit inline @var refines $id to positive-int: + * + * @var positive-int $id + */ + ['id' => $id, 'name' => $name] = $source; + + expect(function () use (&$id) { + $id = -1; + })->toThrow(TypeError::class, 'Variable $id must be of type positive-int'); + }); + }); +});