diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 020a344..200fe40 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -49,13 +49,15 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isConstructor = $isClassMethod && $methodName === '__construct'; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); + $isMagicGet = $isClassMethod && $methodName === '__get'; + $isMagicCall = $isClassMethod && ($methodName === '__call' || $methodName === '__callstatic'); $isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never'; $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; $isPrivate = $isClassMethod && $node->isPrivate(); $paramCount = \count($node->params); - $hasParam = self::hasParamContracts( + $hasParam = ($isMagicGet || $isMagicCall) || self::hasParamContracts( $docText, $isClassMethod, $hasInheritance, @@ -90,20 +92,20 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $hasReturn = ! $isMagicLifecycle && ! $isNativeNever && ! ($isNativeVoid && ! $hasReturnDoc) - && self::hasReturnContracts($docText, $isClassMethod, $isPrivate); + && ($isMagicGet || $isMagicCall || self::hasReturnContracts($docText, $isClassMethod, $isPrivate)); if (! $hasParam && ! $hasReturn && ! $hasParamOut && ! $hasSelfOut) { return; } $thisArg = self::resolveThisArg($isClassMethod, $node); - $needsReturnVars = $hasParam && ($paramCount > 0) && ( + $needsReturnVars = ($hasParam && ($paramCount > 0) && ( $hasInheritance || str_contains($docText, ' is ') || ($hasReturnDoc && str_contains($docText, '$')) - ); + )) || ($isClassMethod && $paramCount > 0 && ($isMagicGet || $isMagicCall)); $injectedStmts = []; if ($hasParam) { - $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isReadonlyClass); + $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isReadonlyClass, $needsReturnVars); } if ($hasReturn || $hasParamOut || $hasSelfOut) { @@ -313,9 +315,10 @@ private static function buildParamInjections( array $params, string $docText, Node\Expr $thisArg, - bool $isReadonlyClass = false + bool $isReadonlyClass = false, + bool $needsReturnVars = false ): array { - $injectedStmts = [self::buildSetupScopeStmt($params, $thisArg)]; + $injectedStmts = [self::buildSetupScopeStmt($params, $thisArg, $needsReturnVars)]; $callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable', $isReadonlyClass); $iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable', $isReadonlyClass); @@ -325,7 +328,7 @@ private static function buildParamInjections( /** * @param array $params */ - private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): Node\Stmt\If_ + private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Stmt\If_ { $arrayItems = []; foreach ($params as $param) { @@ -389,6 +392,35 @@ private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): $combinedCondition = new Node\Expr\BinaryOp\BooleanOr($noParamCacheCheck, $hasTemplatesCheck); + if ($needsReturnVars) { + $ifStmt = new Node\Stmt\If_( + new Node\Expr\ConstFetch(new Node\Name('true')), + [ + 'stmts' => [ + $argsAssign, + new Node\Stmt\If_( + $combinedCondition, + [ + 'stmts' => [ + new Node\Stmt\If_( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpErr'), $checkCall), + new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') + ), + ['stmts' => [$throwStmt]] + ), + ], + ] + ), + ], + ] + ); + + $ifStmt->setAttribute('typephp_injected', true); + + return $ifStmt; + } + $ifStmt = new Node\Stmt\If_( $combinedCondition, [ diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index fdbb9cb..1d79dec 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -61,6 +61,22 @@ final class InlineChecker */ public static array $nullPropertyCache = []; + /** + * Cache for whether a class property's type references generic templates: + * [$className][$propName] => bool. + * + * @var array> + */ + private static array $propertyUsesTemplatesCache = []; + + /** + * Cache for whether a class has any methods declaring @self-out or @this-out: + * [$className] => bool. + * + * @var array + */ + private static array $classHasSelfOutCache = []; + /** * Resets internal type node and function caches. Useful for test isolation. */ @@ -69,6 +85,8 @@ public static function reset(): void self::$parsedTypeNodeCache = []; self::$resolvedClassContextCache = []; self::$nullPropertyCache = []; + self::$propertyUsesTemplatesCache = []; + self::$classHasSelfOutCache = []; } /** @@ -236,24 +254,39 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - $typeNode = DocblockParser::parseProperty($className, $propName); - if ($typeNode === null) { + $rawTypeNode = DocblockParser::parseProperty($className, $propName); + if ($rawTypeNode === null) { self::$nullPropertyCache[$className][$propName] = true; return $value; } - if (! self::shouldValidateType($typeNode)) { + if (! self::shouldValidateType($rawTypeNode)) { return $value; } + $propertyUsesTemplates = false; + $typeNode = $rawTypeNode; + if (\is_object($objectOrClass)) { - $typeNode = self::substitutePropertyGenerics($typeNode, $objectOrClass, $className); + $propertyUsesTemplates = self::propertyUsesTemplates($rawTypeNode, $className, $propName); + if ($propertyUsesTemplates) { + $typeNode = self::substitutePropertyGenerics($rawTypeNode, $objectOrClass, $className); + } } try { $err = $registry->validate($value, $typeNode, 'Property ' . $className . '::$' . $propName); if ($err !== null) { + if ( + $propertyUsesTemplates && + \is_object($objectOrClass) && + self::classHasSelfOut($className) && + self::trySelfOutTransition($value, $objectOrClass, $propName, $registry) + ) { + return $value; + } + return $err; } } catch (\Throwable $e) { @@ -263,6 +296,138 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } + /** + * Checks if a property's declared type references class generic templates with memoization. + */ + private static function propertyUsesTemplates(TypeNode $rawTypeNode, string $className, string $propName): bool + { + if (isset(self::$propertyUsesTemplatesCache[$className][$propName])) { + return self::$propertyUsesTemplatesCache[$className][$propName]; + } + + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + + if ($allTemplates === []) { + return self::$propertyUsesTemplatesCache[$className][$propName] = false; + } + + return self::$propertyUsesTemplatesCache[$className][$propName] = DocblockParser::typeReferencesTemplate($rawTypeNode, $allTemplates); + } + + /** + * Checks if a class declares any methods containing @self-out or @this-out annotations with memoization. + */ + private static function classHasSelfOut(string $className): bool + { + if (isset(self::$classHasSelfOutCache[$className])) { + return self::$classHasSelfOutCache[$className]; + } + + if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) { + return self::$classHasSelfOutCache[$className] = false; + } + + try { + /** @var class-string $className */ + $ref = new \ReflectionClass($className); + foreach ($ref->getMethods() as $method) { + $doc = $method->getDocComment(); + if ($doc !== false && (str_contains($doc, 'self-out') || str_contains($doc, 'this-out'))) { + return self::$classHasSelfOutCache[$className] = true; + } + } + } catch (\Throwable $e) { + // Silently ignore reflection errors + } + + return self::$classHasSelfOutCache[$className] = false; + } + + /** + * Attempts a typestate transition when a property assignment to $this fails against current bindings. + */ + private static function trySelfOutTransition(mixed $value, object $object, string $propName, TypeValidatorRegistry $registry): bool + { + if (! Config::isSelfOutEnabled()) { + return false; + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 7); + $callerFrame = null; + $callerFunction = null; + $contract = null; + + foreach ($trace as $frame) { + if (isset($frame['object'], $frame['class']) && $frame['object'] === $object) { + if ($frame['class'] === 'TypePHP\Internal\RuntimeTypeChecker' || str_starts_with($frame['class'], 'TypePHP\\Internal\\')) { + continue; + } + + $candidateFunction = $frame['class'] . '::' . $frame['function']; + $candidateContract = DocblockParser::parse($candidateFunction); + + if (($candidateContract['hasSelfOutContract'] ?? false) && $candidateContract['selfOut'] !== null) { + $callerFrame = $frame; + $callerFunction = $candidateFunction; + $contract = $candidateContract; + + break; + } + } + } + + if ($callerFrame === null || $callerFunction === null || $contract === null) { + return false; + } + + $selfOutNode = $contract['selfOut']; + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $boundTemplates = (\count($allTemplates) > 0) + ? TemplateManager::getBoundTemplates($callerFunction, $object, $allTemplates) + : []; + + if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { + $selfOutNode = TemplateSubstitutor::substitute($selfOutNode, $boundTemplates, $allTemplates); + $selfOutNode = SpecialTypeResolver::resolve($selfOutNode, $callerFunction, $object); + } + + $vars = $callerFrame['args'] ?? []; + if ( + $selfOutNode instanceof ConditionalTypeForParameterNode || + $selfOutNode instanceof ConditionalTypeNode + ) { + $selfOutNode = ConditionalChecker::resolve($selfOutNode, $vars, $boundTemplates, $registry, $callerFunction); + } + + if (! ($selfOutNode instanceof GenericTypeNode)) { + return false; + } + + $previousBindings = TemplateManager::getBoundTemplatesForInstance($object); + + TemplateManager::bindInstanceFromNode($object, $selfOutNode, forceBind: true); + + $className = $object::class; + $propTypeNode = DocblockParser::parseProperty($className, $propName); + if ($propTypeNode !== null) { + $propTypeNode = self::substitutePropertyGenerics($propTypeNode, $object, $className); + } + + $testErr = $propTypeNode !== null + ? $registry->validate($value, $propTypeNode, 'Property ' . $className . '::$' . $propName) + : null; + + if ($testErr === null) { + return true; + } + + TemplateManager::restoreInstanceBindings($object, $previousBindings); + + return false; + } + /** * Resolves caller class or function context and applies templates & type aliases to the AST. */ diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 922b55f..5fe2ba8 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -104,7 +104,7 @@ public static function reset(): void */ public static function areAllParamsUnconstrained(string $effectiveFunction): bool { - if (str_contains($effectiveFunction, '__call')) { + if (str_contains($effectiveFunction, '__call') || str_ends_with($effectiveFunction, '::__get')) { return false; } @@ -149,12 +149,13 @@ public static function checkParams( } $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); + $isMagicGet = str_ends_with($effectiveFunction, '::__get'); - if (! $isMagicCall && isset(self::$noParamContractCache[$effectiveFunction])) { + if (! $isMagicCall && ! $isMagicGet && isset(self::$noParamContractCache[$effectiveFunction])) { return null; } - if ($vars === [] && ! $isMagicCall) { + if ($vars === [] && ! $isMagicCall && ! $isMagicGet) { return null; } @@ -162,7 +163,7 @@ public static function checkParams( if ($magicError !== null) { return $magicError; } - if ($isMagicCall) { + if ($isMagicCall || $isMagicGet) { return null; } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 59f7467..b5d5773 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -23,7 +23,7 @@ /** * @phpstan-import-type FunctionContract from DocblockParser * - * @internal Evaluates function and method return contract validations (including dynamic @method calls via __call / __callStatic). + * @internal Evaluates function and method return contract validations (including dynamic @method calls via __call / __callStatic and dynamic @property reads via __get). */ final class ReturnChecker { @@ -82,7 +82,7 @@ public static function reset(): void */ public static function isReturnUnconstrained(string $effectiveFunction): bool { - if (str_contains($effectiveFunction, '__call')) { + if (str_contains($effectiveFunction, '__call') || str_ends_with($effectiveFunction, '::__get')) { return false; } @@ -109,23 +109,23 @@ public static function checkReturn( return $value; } - if (isset(self::$noReturnContractCache[$function])) { - return $value; - } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; if ($effectiveFunction === '') { $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); } - if (isset(self::$noReturnContractCache[$effectiveFunction])) { - self::$noReturnContractCache[$function] = true; + $isMagicCall = str_contains($effectiveFunction, '__call'); + $isMagicGet = str_ends_with($effectiveFunction, '::__get'); - return $value; + if (! $isMagicCall && ! $isMagicGet) { + if (isset(self::$noReturnContractCache[$function]) || isset(self::$noReturnContractCache[$effectiveFunction])) { + self::$noReturnContractCache[$function] = true; + + return $value; + } } - $isMagicCall = str_contains($effectiveFunction, '__call'); $magicResult = self::handleMagicReturn( $effectiveFunction, $value, @@ -141,6 +141,17 @@ public static function checkReturn( return $value; } + if ($isMagicGet) { + return self::handleMagicPropertyRead( + $effectiveFunction, + $value, + $thisObj, + $vars, + $registry, + $wrapIterableCallback + ); + } + $contract ??= DocblockParser::parse($effectiveFunction); if (! ($contract['hasReturnContract'] ?? ($contract['return'] !== null))) { @@ -174,6 +185,75 @@ public static function checkReturn( ); } + /** + * Intercepts and evaluates return contracts for dynamic @property and @property-read accesses routed via __get. + * + * @param array $vars + */ + private static function handleMagicPropertyRead( + string $effectiveFunction, + mixed $value, + ?object $thisObj, + array $vars, + TypeValidatorRegistry $registry, + callable $wrapIterableCallback + ): mixed { + if (! Config::isMagicPropertyReadsEnabled()) { + return $value; + } + + $propName = array_values($vars)[0] ?? null; + if (! \is_string($propName)) { + return $value; + } + + $className = explode('::', $effectiveFunction, 2)[0]; + $typeNode = DocblockParser::parseProperty($className, $propName); + + if ($typeNode === null) { + return $value; + } + + if ($thisObj !== null) { + $constructorTarget = $className . '::__construct'; + $contract = DocblockParser::parse($constructorTarget); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $boundTemplates = TemplateManager::getBoundTemplates('none', $thisObj, $allTemplates); + + if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $allTemplates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); + } + } + + $context = 'Property ' . $className . '::$' . $propName; + $err = $registry->validate($value, $typeNode, $context); + + if ($err !== null) { + $msg = $err->getMessage(); + if (str_ends_with($msg, ' given')) { + $msg = substr($msg, 0, -\strlen(' given')) . ' returned'; + } + + return ErrorFactory::createError($msg); + } + + if ($value instanceof Traversable) { + $baseName = ''; + if ($typeNode instanceof IdentifierTypeNode) { + $baseName = strtolower(ltrim($typeNode->name, '\\')); + } elseif ($typeNode instanceof GenericTypeNode) { + $baseName = strtolower(ltrim($typeNode->type->name, '\\')); + } + + if (isset(self::GENERIC_ITERABLES[$baseName])) { + return $wrapIterableCallback($effectiveFunction, 'return', $value); + } + } + + return $value; + } + /** * Intercepts and evaluates return contracts for dynamic @method calls routed via __call and __callStatic. * diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index 23b6268..b553f5b 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -62,10 +62,10 @@ private static function getTemplate(): string | When enabled, all parameter and return types (generics, shapes, scalars) | are enforced uniformly to maintain type state consistency. */ - 'params' => true, - 'returns' => true, + 'params' => true, + 'returns' => true, 'params_out' => true, - 'self_out' => true, + 'self_out' => true, /* |-------------------------------------------------------------------------- @@ -98,8 +98,21 @@ private static function getTemplate(): string |-------------------------------------------------------------------------- | Enforces class-level annotations for dynamic properties and magic methods | routed through __get, __set, __call, and __callStatic. + | + | 'magic_properties' supports granular options: + | - 'write': (Default: true) Validates dynamic property assignments via __set() + | against @property and @property-write annotations. + | - 'read' : (Default: false) Validates dynamic property access via __get() + | against @property and @property-read annotations. Keep false + | when working with frameworks (e.g. Eloquent/Doctrine) where + | newly instantiated models return unpopulated null attributes. + | + | Alternatively, set 'magic_properties' => false to disable all checks. */ - 'magic_properties' => true, + 'magic_properties' => [ + 'write' => true, + 'read' => false, + ], 'magic_methods' => true, /* diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 7b489c4..7a490f0 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -393,6 +393,22 @@ public static function reset(): void self::$pendingInstantiations = []; } + /** + * Restores or clears instance template bindings in WeakMap. + * + * @param array $bindings + */ + public static function restoreInstanceBindings(object $instance, array $bindings): void + { + self::$instanceTemplateBindings ??= new WeakMap(); + + if ($bindings === []) { + unset(self::$instanceTemplateBindings[$instance]); + } else { + self::$instanceTemplateBindings[$instance] = $bindings; + } + } + /** * Pushes a pending generic instantiation for a constructor. */ diff --git a/src/Internal/Resolver/HierarchyResolver.php b/src/Internal/Resolver/HierarchyResolver.php index 2203e74..a2e6ee5 100644 --- a/src/Internal/Resolver/HierarchyResolver.php +++ b/src/Internal/Resolver/HierarchyResolver.php @@ -13,9 +13,10 @@ final class HierarchyResolver { /** - * In-memory cache for resolved ReflectionMethod hierarchy arrays. + * In-memory 2D cache for resolved ReflectionMethod hierarchy arrays: + * [$className][$methodName] => list. * - * @var array> + * @var array>> */ private static array $methodHierarchyCache = []; @@ -126,19 +127,18 @@ public static function getTraitAliases(string $className): array /** * Builds an array of ReflectionMethods representing the inheritance hierarchy from child to root. * - * @return array + * @return list */ public static function getMethodHierarchy(ReflectionMethod $ref): array { - $cacheKey = $ref->class . '::' . $ref->getName(); - if (isset(self::$methodHierarchyCache[$cacheKey])) { - return self::$methodHierarchyCache[$cacheKey]; + $targetClassName = $ref->class; + $methodName = $ref->getName(); + + if (isset(self::$methodHierarchyCache[$targetClassName][$methodName])) { + return self::$methodHierarchyCache[$targetClassName][$methodName]; } $hierarchy = [$ref]; - $methodName = $ref->getName(); - $targetClassName = $ref->class; - $targetClass = new ReflectionClass($targetClassName); $traitAliases = self::getTraitAliases($targetClassName); @@ -172,7 +172,7 @@ public static function getMethodHierarchy(ReflectionMethod $ref): array } } - return self::$methodHierarchyCache[$cacheKey] = $hierarchy; + return self::$methodHierarchyCache[$targetClassName][$methodName] = $hierarchy; } /** diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index a2f51d8..75929ac 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -116,7 +116,7 @@ final class SpecialTypeResolver private static array $reflectionContextCache = []; /** - * In-memory 2D cache for resolved FQCNs: [$contextKey][$name] => FQCN. + * In-memory 2D cache for resolved FQCNs of non-file entities: [$contextKey][$name] => FQCN. * * @var array> */ @@ -1030,7 +1030,7 @@ public static function getNamespaceFromFile(string $fileName): string /** * Resolves a short class name to its fully qualified class name (FQCN) using Reflection context. - * Memoizes resolved FQCNs in memory via zero-allocation 2D table to avoid autoloader search storms. + * Routes directly to 2D file cache when file metadata exists, avoiding string key concatenations. * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ @@ -1048,29 +1048,26 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun return $name; } - $contextKey = match (true) { - $ref instanceof \ReflectionClass => 'C:' . $ref->getName(), - $ref instanceof \ReflectionMethod => 'M:' . ($ref->getFileName() !== false ? $ref->getFileName() : $ref->getDeclaringClass()->getName()) . '::' . $ref->getName(), - $ref instanceof \ReflectionFunction => 'F:' . $ref->getName(), - }; + $fileName = $ref->getFileName(); + if ($fileName !== false && $fileName !== '') { + return self::resolveFqcnForFile($name, $fileName); + } + + $contextKey = $ref instanceof \ReflectionMethod + ? $ref->getDeclaringClass()->getName() + : $ref->getName(); if (isset(self::$fqcnCache[$contextKey][$name])) { return self::$fqcnCache[$contextKey][$name]; } - $imports = self::getUseImports($ref); - $fileName = $ref->getFileName(); - $fileNamespace = ($fileName !== false && $fileName !== '') ? self::getNamespaceFromFile($fileName) : ''; - - $namespace = ($fileNamespace !== '') - ? $fileNamespace - : match (true) { - $ref instanceof \ReflectionClass => $ref->getNamespaceName(), - $ref instanceof \ReflectionMethod => $ref->getDeclaringClass()->getNamespaceName(), - $ref instanceof \ReflectionFunction => $ref->getNamespaceName(), - }; + $namespace = match (true) { + $ref instanceof \ReflectionClass => $ref->getNamespaceName(), + $ref instanceof \ReflectionMethod => $ref->getDeclaringClass()->getNamespaceName(), + $ref instanceof \ReflectionFunction => $ref->getNamespaceName(), + }; - $resolved = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + $resolved = self::resolveNameFromImportsAndNamespace($name, [], $namespace); return self::$fqcnCache[$contextKey][$name] = $resolved; } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 485c30f..79f4ba3 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -213,6 +213,13 @@ public static function setupScope(string $function, array $vars, object|string|n TemplateManager::applyPendingInstantiation($thisObj); } + $isMagicCall = str_contains($effectiveFunction, '__call'); + $isMagicGet = str_ends_with($effectiveFunction, '::__get'); + + if ($isMagicGet) { + return null; + } + if ( isset(ParamChecker::$noParamContractCache[$function]) && ! (self::$hasMethodTemplatesCache[$function] ?? false) @@ -220,8 +227,6 @@ public static function setupScope(string $function, array $vars, object|string|n return null; } - $isMagicCall = str_contains($effectiveFunction, '__call'); - $contract = DocblockParser::parse($effectiveFunction); if (! $isMagicCall && ($contract['allParamsUnconstrained'] ?? false)) { return null; @@ -363,16 +368,17 @@ public static function checkReturn(string $function, mixed $value, object|string return $value; } - if (isset(ReturnChecker::$noReturnContractCache[$function]) || isset(ReturnChecker::$noReturnContractCache[$effectiveFunction])) { + $isMagicCall = str_contains($effectiveFunction, '__call'); + $isMagicGet = str_ends_with($effectiveFunction, '::__get'); + + if (! $isMagicCall && ! $isMagicGet && (isset(ReturnChecker::$noReturnContractCache[$function]) || isset(ReturnChecker::$noReturnContractCache[$effectiveFunction]))) { ReturnChecker::$noReturnContractCache[$function] = true; return $value; } - $isMagicCall = str_contains($effectiveFunction, '__call'); - $contract = DocblockParser::parse($effectiveFunction); - if (! $isMagicCall && ($contract['returnUnconstrained'] ?? false)) { + if (! $isMagicCall && ! $isMagicGet && ($contract['returnUnconstrained'] ?? false)) { return $value; } diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 4119826..e237116 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -48,6 +48,10 @@ final class Config private static bool $magicProperties = true; + private static bool $magicPropertyWrites = true; + + private static bool $magicPropertyReads = false; + private static bool $magicMethods = true; private static bool $respectIgnoreTags = true; @@ -226,6 +230,24 @@ public static function isMagicPropertiesEnabled(): bool return self::$magicProperties; } + public static function isMagicPropertyWritesEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$magicPropertyWrites; + } + + public static function isMagicPropertyReadsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$magicPropertyReads; + } + public static function isMagicMethodsEnabled(): bool { if (self::$cachedConfig === null) { @@ -353,7 +375,10 @@ public static function get(): array 'params_out' => true, 'self_out' => true, 'strict_return_generic_invariance' => true, - 'magic_properties' => true, + 'magic_properties' => [ + 'write' => true, + 'read' => false, + ], 'magic_methods' => true, 'respect_ignore_tags' => true, 'ignore_trace_depth' => 25, @@ -456,7 +481,7 @@ public static function set(array $config): void /** * Merges user configuration over base defaults: - * - Associative dictionaries (inline_vars) are merged recursively. + * - Associative dictionaries (inline_vars, magic_properties) are merged recursively. * - Sequential lists (include, exclude, extensions, stubs) are REPLACED wholesale when defined. * - Scalars / booleans / strings are overwritten. * @@ -476,6 +501,12 @@ private static function mergeConfig(array $base, array $overrides): array /** @var array $overrideInlineVars */ $overrideInlineVars = $value; $merged['inline_vars'] = [...$baseInlineVars, ...$overrideInlineVars]; + } elseif ($key === 'magic_properties' && \is_array($value) && isset($base['magic_properties']) && \is_array($base['magic_properties'])) { + /** @var array $baseMagicProps */ + $baseMagicProps = $base['magic_properties']; + /** @var array $overrideMagicProps */ + $overrideMagicProps = $value; + $merged['magic_properties'] = [...$baseMagicProps, ...$overrideMagicProps]; } elseif (\in_array($key, ['include', 'exclude', 'extensions', 'stubs'], true) && \is_array($value)) { $merged[$key] = array_values($value); } else { @@ -499,6 +530,8 @@ public static function reset(): void self::$selfOut = true; self::$strictReturnGenericInvariance = true; self::$magicProperties = true; + self::$magicPropertyWrites = true; + self::$magicPropertyReads = false; self::$magicMethods = true; self::$respectIgnoreTags = true; self::$ignoreTraceDepth = 25; @@ -543,7 +576,17 @@ private static function syncFlags(array $config): void self::$selfOut = (bool) ($config['self_out'] ?? true); self::$returns = (bool) ($config['returns'] ?? true); self::$strictReturnGenericInvariance = (bool) ($config['strict_return_generic_invariance'] ?? true); - self::$magicProperties = (bool) ($config['magic_properties'] ?? true); + + if (\is_array($config['magic_properties'] ?? null)) { + self::$magicPropertyWrites = (bool) ($config['magic_properties']['write'] ?? true); + self::$magicPropertyReads = (bool) ($config['magic_properties']['read'] ?? false); + } else { + $bool = (bool) ($config['magic_properties'] ?? true); + self::$magicPropertyWrites = $bool; + self::$magicPropertyReads = false; + } + self::$magicProperties = self::$magicPropertyWrites || self::$magicPropertyReads; + self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); self::$ignoreTraceDepth = isset($config['ignore_trace_depth']) && is_numeric($config['ignore_trace_depth']) && (int) $config['ignore_trace_depth'] > 0 diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php index b598de0..a3abcd1 100644 --- a/src/Internal/Util/IgnoreManager.php +++ b/src/Internal/Util/IgnoreManager.php @@ -13,11 +13,18 @@ final class IgnoreManager { /** - * In-memory cache for caller method ignore decisions (ClassName::method => bool). + * In-memory 2D cache for caller method ignore decisions: [$className][$methodName] => bool. + * + * @var array> + */ + private static array $callerMethodCache = []; + + /** + * In-memory cache for caller standalone function ignore decisions: [$functionName] => bool. * * @var array */ - private static array $callerCache = []; + private static array $callerFunctionCache = []; /** * In-memory registry of files marked with @typephp-ignore-file. @@ -31,7 +38,8 @@ final class IgnoreManager */ public static function reset(): void { - self::$callerCache = []; + self::$callerMethodCache = []; + self::$callerFunctionCache = []; self::$fileCache = []; } @@ -73,20 +81,19 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal } if ($callerClass !== null && $callerFunction !== null) { - $key = $callerClass . '::' . $callerFunction; - if (isset(self::$callerCache[$key])) { - return self::$callerCache[$key]; + if (isset(self::$callerMethodCache[$callerClass][$callerFunction])) { + return self::$callerMethodCache[$callerClass][$callerFunction]; } - return self::$callerCache[$key] = self::checkMethodIgnored($callerClass, $callerFunction); + return self::$callerMethodCache[$callerClass][$callerFunction] = self::checkMethodIgnored($callerClass, $callerFunction); } if ($callerFunction !== null) { - if (isset(self::$callerCache[$callerFunction])) { - return self::$callerCache[$callerFunction]; + if (isset(self::$callerFunctionCache[$callerFunction])) { + return self::$callerFunctionCache[$callerFunction]; } - return self::$callerCache[$callerFunction] = self::checkFunctionIgnored($callerFunction); + return self::$callerFunctionCache[$callerFunction] = self::checkFunctionIgnored($callerFunction); } $depth = Config::getIgnoreTraceDepth(); @@ -124,9 +131,8 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal } if ($class !== '') { - $key = $class . '::' . $function; - if (isset(self::$callerCache[$key])) { - if (self::$callerCache[$key]) { + if (isset(self::$callerMethodCache[$class][$function])) { + if (self::$callerMethodCache[$class][$function]) { return true; } @@ -134,13 +140,13 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal } if (self::checkMethodIgnored($class, $function)) { - return self::$callerCache[$key] = true; + return self::$callerMethodCache[$class][$function] = true; } - self::$callerCache[$key] = false; + self::$callerMethodCache[$class][$function] = false; } elseif (! str_contains($function, '{closure}')) { - if (isset(self::$callerCache[$function])) { - if (self::$callerCache[$function]) { + if (isset(self::$callerFunctionCache[$function])) { + if (self::$callerFunctionCache[$function]) { return true; } @@ -148,10 +154,10 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal } if (self::checkFunctionIgnored($function)) { - return self::$callerCache[$function] = true; + return self::$callerFunctionCache[$function] = true; } - self::$callerCache[$function] = false; + self::$callerFunctionCache[$function] = false; } } diff --git a/src/Internal/Util/StubManager.php b/src/Internal/Util/StubManager.php index 4d257df..2b0fab0 100644 --- a/src/Internal/Util/StubManager.php +++ b/src/Internal/Util/StubManager.php @@ -20,12 +20,16 @@ final class StubManager private static bool $initialized = false; /** - * @var array ClassName::methodName => DocCommentText + * In-memory 2D cache for method stubs: [$className][$methodName] => DocCommentText. + * + * @var array> */ private static array $methodStubs = []; /** - * @var array ClassName::$propertyName => DocCommentText + * In-memory 2D cache for property stubs: [$className][$propertyName] => DocCommentText. + * + * @var array> */ private static array $propertyStubs = []; @@ -78,14 +82,14 @@ public static function getMethodDoc(string $className, string $methodName): ?str { self::init(); - return self::$methodStubs[$className . '::' . $methodName] ?? null; + return self::$methodStubs[$className][$methodName] ?? null; } public static function getPropertyDoc(string $className, string $propertyName): ?string { self::init(); - return self::$propertyStubs[$className . '::$' . $propertyName] ?? null; + return self::$propertyStubs[$className][$propertyName] ?? null; } public static function getClassDoc(string $className): ?string @@ -106,14 +110,14 @@ public static function hasMethodStub(string $className, string $methodName): boo { self::init(); - return isset(self::$methodStubs[$className . '::' . $methodName]); + return isset(self::$methodStubs[$className][$methodName]); } public static function hasPropertyStub(string $className, string $propertyName): bool { self::init(); - return isset(self::$propertyStubs[$className . '::$' . $propertyName]); + return isset(self::$propertyStubs[$className][$propertyName]); } public static function hasClassStub(string $className): bool @@ -257,13 +261,13 @@ private static function extractStubsFromAst(array $stmts, string $namespace = '' if ($member instanceof Node\Stmt\ClassMethod) { $mDoc = $member->getDocComment(); if ($mDoc !== null) { - self::$methodStubs[$className . '::' . $member->name->toString()] = $mDoc->getText(); + self::$methodStubs[$className][$member->name->toString()] = $mDoc->getText(); } } elseif ($member instanceof Node\Stmt\Property) { $pDoc = $member->getDocComment(); if ($pDoc !== null) { foreach ($member->props as $prop) { - self::$propertyStubs[$className . '::$' . $prop->name->toString()] = $pDoc->getText(); + self::$propertyStubs[$className][$prop->name->toString()] = $pDoc->getText(); } } } diff --git a/tests/Internal/Ast/FunctionContractInjectorTest.php b/tests/Internal/Ast/FunctionContractInjectorTest.php index accfded..b39a162 100644 --- a/tests/Internal/Ast/FunctionContractInjectorTest.php +++ b/tests/Internal/Ast/FunctionContractInjectorTest.php @@ -306,6 +306,100 @@ public function dd(): never }); }); + describe('Magic Accessor Handling (__get and __call)', function () { + test('injects parameter scope and return check with $_typephpArgs into __get method', function () { + $getMethod = new Node\Stmt\ClassMethod('__get', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('name'), null, new Node\Identifier('string')), + ], + 'stmts' => [ + new Node\Stmt\Return_(new Node\Expr\ArrayDimFetch( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'data'), + new Node\Expr\Variable('name') + )), + ], + ]); + + FunctionContractInjector::inject($getMethod); + + expect($getMethod->stmts)->not()->toBeEmpty(); + + $firstStmt = $getMethod->stmts[0]; + expect($firstStmt)->toBeInstanceOf(Node\Stmt\If_::class) + ->and($firstStmt->getAttribute('typephp_injected'))->toBeTrue() + ; + + expect($firstStmt->cond)->toBeInstanceOf(Node\Expr\ConstFetch::class) + ->and($firstStmt->cond->name->toString())->toBe('true') + ; + + $innerStmts = $firstStmt->stmts; + expect($innerStmts[0])->toBeInstanceOf(Node\Stmt\Expression::class) + ->and($innerStmts[0]->expr)->toBeInstanceOf(Node\Expr\Assign::class) + ->and($innerStmts[0]->expr->var->name)->toBe('_typephpArgs') + ; + + $returnStmt = $getMethod->stmts[1]; + expect($returnStmt)->toBeInstanceOf(Node\Stmt\Return_::class) + ->and($returnStmt->expr)->toBeInstanceOf(Node\Expr\Ternary::class) + ; + + /** @var Node\Expr\Ternary $ternary */ + $ternary = $returnStmt->expr; + /** @var Node\Expr\Instanceof_ $instanceOf */ + $instanceOf = $ternary->cond; + /** @var Node\Expr\Assign $assign */ + $assign = $instanceOf->expr; + /** @var Node\Expr\FuncCall $checkCall */ + $checkCall = $assign->expr; + + expect($checkCall->name->toString())->toBe('TypePHP\Internal\RuntimeTypeChecker::checkReturn') + ->and($checkCall->args[3]->value)->toBeInstanceOf(Node\Expr\Variable::class) + ->and($checkCall->args[3]->value->name)->toBe('_typephpArgs') + ; + }); + + test('injects parameter scope and return check with $_typephpArgs into __call method', function () { + $callMethod = new Node\Stmt\ClassMethod('__call', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('name'), null, new Node\Identifier('string')), + new Node\Param(new Node\Expr\Variable('args'), null, new Node\Identifier('array')), + ], + 'stmts' => [ + new Node\Stmt\Return_(new Node\Expr\ConstFetch(new Node\Name('null'))), + ], + ]); + + FunctionContractInjector::inject($callMethod); + + expect($callMethod->stmts)->not()->toBeEmpty(); + + $firstStmt = $callMethod->stmts[0]; + expect($firstStmt)->toBeInstanceOf(Node\Stmt\If_::class) + ->and($firstStmt->cond)->toBeInstanceOf(Node\Expr\ConstFetch::class) + ->and($firstStmt->cond->name->toString())->toBe('true') + ; + + $returnStmt = $callMethod->stmts[1]; + expect($returnStmt)->toBeInstanceOf(Node\Stmt\Return_::class) + ->and($returnStmt->expr)->toBeInstanceOf(Node\Expr\Ternary::class) + ; + + /** @var Node\Expr\Ternary $ternary */ + $ternary = $returnStmt->expr; + /** @var Node\Expr\Instanceof_ $instanceOf */ + $instanceOf = $ternary->cond; + /** @var Node\Expr\Assign $assign */ + $assign = $instanceOf->expr; + /** @var Node\Expr\FuncCall $checkCall */ + $checkCall = $assign->expr; + + expect($checkCall->args[3]->value)->toBeInstanceOf(Node\Expr\Variable::class) + ->and($checkCall->args[3]->value->name)->toBe('_typephpArgs') + ; + }); + }); + describe('Ignore Tag Suppression (@typephp-ignore)', function () { test('injects setupScope hook so @typephp-ignore can be resolved dynamically at runtime by DocblockParser', function () { $doc = new Doc("/**\n * @typephp-ignore\n * @param positive-int \$id\n */"); diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php index 32d9808..8c6ea5d 100644 --- a/tests/Internal/Util/ConfigTest.php +++ b/tests/Internal/Util/ConfigTest.php @@ -17,6 +17,7 @@ ->and($config)->toHaveKey('cache') ->and($config)->toHaveKey('cache_dir') ->and($config['cache_dir'])->toBeNull() + ->and($config['magic_properties'])->toBe(['write' => true, 'read' => false]) ; }); @@ -58,6 +59,8 @@ 'isReturnsEnabled', 'isStrictReturnGenericInvarianceEnabled', 'isMagicPropertiesEnabled', + 'isMagicPropertyWritesEnabled', + 'isMagicPropertyReadsEnabled', 'isMagicMethodsEnabled', 'isRespectIgnoreTagsEnabled', 'isRespectNativeNullabilityEnabled', @@ -68,16 +71,46 @@ foreach ($getters as $getter) { Config::reset(); - // First call triggers: if (self::$cachedConfig === null) { self::get(); } $val1 = Config::$getter(); - - // Second call triggers the false branch (already cached) $val2 = Config::$getter(); expect($val1)->toBe($val2); } }); + test('supports granular and boolean magic_properties configuration', function () { + // 1. Default: write is true, read is false + expect(Config::isMagicPropertyWritesEnabled())->toBeTrue() + ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() + ->and(Config::isMagicPropertiesEnabled())->toBeTrue() + ; + + // 2. Partial array override (enable reads) + Config::set([ + 'magic_properties' => [ + 'read' => true, + ], + ]); + expect(Config::isMagicPropertyWritesEnabled())->toBeTrue() + ->and(Config::isMagicPropertyReadsEnabled())->toBeTrue() + ->and(Config::isMagicPropertiesEnabled())->toBeTrue() + ; + + // 3. Boolean false override (disables both writes and reads) + Config::set(['magic_properties' => false]); + expect(Config::isMagicPropertyWritesEnabled())->toBeFalse() + ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() + ->and(Config::isMagicPropertiesEnabled())->toBeFalse() + ; + + // 4. Boolean true override (enables writes, keeps reads false) + Config::set(['magic_properties' => true]); + expect(Config::isMagicPropertyWritesEnabled())->toBeTrue() + ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() + ->and(Config::isMagicPropertiesEnabled())->toBeTrue() + ; + }); + test('hasActiveInlineChecks returns false when all inline var checks are disabled', function () { Config::set([ 'inline_vars' => [ diff --git a/tests/TypeChecking/Boundaries/MagicPropertiesTest.php b/tests/TypeChecking/Boundaries/MagicPropertiesTest.php index 534a62b..5fe0896 100644 --- a/tests/TypeChecking/Boundaries/MagicPropertiesTest.php +++ b/tests/TypeChecking/Boundaries/MagicPropertiesTest.php @@ -2,13 +2,9 @@ declare(strict_types=1); +use TypePHP\Exception\TypeError; use TypePHP\Internal\Util\Config; -use TypePHP\Tests\Fixtures\Domain\Car; -use TypePHP\Tests\Fixtures\Domain\Dog; -use TypePHP\Tests\Fixtures\Generics\Producer; -use TypePHP\Tests\Fixtures\Types\CountableArrayAccess; -use TypePHP\Tests\Fixtures\Types\CountableOnly; -use TypePHP\Tests\Fixtures\Types\MagicMethodFixture; +use TypePHP\Tests\Fixtures\Types\MagicPropertyFixture; beforeEach(function () { Config::reset(); @@ -18,96 +14,159 @@ Config::reset(); }); -describe('Class-Level Magic Methods (@method) with Complex Types', function () { - describe('Basic Parameters & Variadics', function () { - test('validates arguments passed into dynamic instance method', function () { - $fixture = new MagicMethodFixture(); +/** + * Fixture testing dynamic property reads and writes + * + * @property-read string $status + * @property-write string $name + * @property positive-int $score + */ +class DynamicModelFixture +{ + public array $data = []; + + public function __get(string $name): mixed + { + return $this->data[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + $this->data[$name] = $value; + } +} + +/** + * Class with ignore tag on __get + * + * @property-read positive-int $code + */ +class IgnoredGetModelFixture +{ + /** + * @typephp-ignore + */ + public function __get(string $name): mixed + { + return -999; + } +} + +describe('Class-Level Magic Properties (@property, @property-read, @property-write)', function () { + describe('Property Writes (__set) [Default: Enabled]', function () { + test('validates incoming values on property assignment via __set', function () { + $fixture = new MagicPropertyFixture(); + + $fixture->magicScore = 100; + expect($fixture->data['magicScore'])->toBe(100); + + expect(fn () => $fixture->magicScore = -5) + ->toThrow(TypeError::class, 'Property TypePHP\Tests\Fixtures\Types\MagicPropertyFixture::$magicScore must be of type positive-int') + ; + }); - expect($fixture->processId(42, 'Alice'))->toBe(42); + test('validates @property-write on property assignment', function () { + $fixture = new MagicPropertyFixture(); - expect(fn () => $fixture->processId(-5, 'Alice')) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument $id must be of type positive-int, negative int (-5) given') - ; + $fixture->magicName = 'Alice'; + expect($fixture->data['magicName'])->toBe('Alice'); - expect(fn () => $fixture->processId(42, '')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::processId(): Argument \$name must be of type non-empty-string, empty string ('') given") + expect(fn () => $fixture->magicName = '') + ->toThrow(TypeError::class, 'Property TypePHP\Tests\Fixtures\Types\MagicPropertyFixture::$magicName must be of type non-empty-string') ; }); - test('validates variadic arguments passed into dynamic static method', function () { - expect(MagicMethodFixture::fetchList(1, 2, 3))->toBe([1, 2, 3]); + test('bypasses property write validation when write is disabled in config', function () { + Config::set([ + 'magic_properties' => [ + 'write' => false, + ], + ]); - expect(fn () => MagicMethodFixture::fetchList(1, 2, 'hello')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::fetchList(): Argument \$items[2] must be of type int, string 'hello' given") - ; + $fixture = new MagicPropertyFixture(); + $fixture->magicScore = -999; + + expect($fixture->data['magicScore'])->toBe(-999); }); }); - describe('Array Shapes & Lists in @method', function () { - test('validates list arguments and array shape returns on dynamic method', function () { - $fixture = new MagicMethodFixture(); + describe('Property Reads (__get) [Default: Disabled to prevent false positives]', function () { + test('by default, reading an unpopulated property returning null passes without error', function () { + $model = new DynamicModelFixture(); - $result = $fixture->buildPayload([10, 20], 'active'); - expect($result)->toBe(['id' => 10, 'tags' => ['php', 'typephp']]); + $status = $model->status; + expect($status)->toBeNull(); + }); - expect(fn () => $fixture->buildPayload([10, -5], 'active')) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument $ids[1] must be of type positive-int') - ; + test('by default, reading a mismatched type via __get passes without error', function () { + $model = new DynamicModelFixture(); + $model->data['status'] = 12345; - expect(fn () => $fixture->buildPayload([10, 20], 'archived')) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')") - ; + expect($model->status)->toBe(12345); }); }); - describe('Generics in @method', function () { - test('validates generic object instances passed to dynamic method', function () { - $fixture = new MagicMethodFixture(); - $dogProducer = new Producer(new Dog()); + describe('Property Reads (__get) [Opt-in via magic_properties.read => true]', function () { + beforeEach(function () { + Config::set([ + 'magic_properties' => [ + 'write' => true, + 'read' => true, + ], + ]); + }); + + afterEach(function () { + Config::reset(); + }); - expect($fixture->getProducer($dogProducer))->toBe($dogProducer); + test('accepts valid property read matching @property-read string type', function () { + $model = new DynamicModelFixture(); + $model->data['status'] = 'active'; - $carProducer = new Producer(new Car()); - expect(fn () => $fixture->getProducer($carProducer)) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::getProducer(): Argument $producer expects TypePHP\\Tests\\Fixtures\\Generics\\Producer') + expect($model->status)->toBe('active'); + }); + + test('throws TypeError when dynamic property read returns null for non-nullable @property-read', function () { + $model = new DynamicModelFixture(); + + expect(fn () => $model->status) + ->toThrow(TypeError::class, 'Property DynamicModelFixture::$status must be of type string, null returned') ; }); - }); - describe('Intersections & Nullable Types in @method', function () { - test('validates intersection types and nullable null on dynamic method', function () { - $fixture = new MagicMethodFixture(); + test('throws TypeError when dynamic property read returns invalid integer', function () { + $model = new DynamicModelFixture(); + $model->data['status'] = 12345; - expect($fixture->checkCollection(null))->toBeTrue(); - expect($fixture->checkCollection(new CountableArrayAccess()))->toBeTrue(); - expect(fn () => $fixture->checkCollection(new CountableOnly())) - ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::checkCollection(): Argument $collection must be of type ((Countable & ArrayAccess) | null)') + expect(fn () => $model->status) + ->toThrow(TypeError::class, 'Property DynamicModelFixture::$status must be of type string, int (12345) returned') ; }); - }); - describe('Type Aliases (@phpstan-type) in @method', function () { - test('resolves local class-level type aliases inside @method definitions', function () { - $fixture = new MagicMethodFixture(); + test('passes cleanly when reading dynamic property with no docblock annotation', function () { + $model = new DynamicModelFixture(); + $model->data['unannotatedCustomProp'] = [1, 2, 3]; - $validUser = ['id' => 10, 'role' => 'admin']; - expect($fixture->saveUser($validUser))->toBe($validUser); + expect($model->unannotatedCustomProp)->toBe([1, 2, 3]); + }); + + test('validates read on standard @property annotation as well', function () { + $model = new DynamicModelFixture(); + $model->data['score'] = 50; + + expect($model->score)->toBe(50); - $badUser = ['id' => 10, 'role' => 'superadmin']; - expect(fn () => $fixture->saveUser($badUser)) - ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::saveUser(): Argument \$user['role'] must be of type ('admin' | 'user')") + $model->data['score'] = -10; + expect(fn () => $model->score) + ->toThrow(TypeError::class, 'Property DynamicModelFixture::$score must be of type positive-int, negative int (-10) returned') ; }); - }); - - describe('Configuration Control', function () { - test('ignores magic method validation when magic_methods config is false', function () { - Config::set(['magic_methods' => false]); - $fixture = new MagicMethodFixture(); + test('suppresses read validation when __get is annotated with @typephp-ignore', function () { + $ignored = new IgnoredGetModelFixture(); - $result = $fixture->processId(-5, ''); - expect($result)->toBe(-5); + expect($ignored->code)->toBe(-999); }); }); }); diff --git a/tests/TypeChecking/Generics/TypestateSelfOutPropertyTest.php b/tests/TypeChecking/Generics/TypestateSelfOutPropertyTest.php new file mode 100644 index 0000000..0a4951a --- /dev/null +++ b/tests/TypeChecking/Generics/TypestateSelfOutPropertyTest.php @@ -0,0 +1,113 @@ + + * + * @return static + */ + public function login(string $username, string $password): static + { + $this->username = $username; + $this->state = 'authenticated'; + + return $this; + } + + /** + * @self-out FixtureTypestateAuthSession<'unauthenticated'> + * + * @return static + */ + public function logout(): static + { + $this->username = null; + $this->state = 'unauthenticated'; + + return $this; + } + + /** + * @return non-empty-string + */ + public function getDashboard(): string + { + return "Welcome, {$this->username}!"; + } +} + +describe('Typestate Properties with @self-out Transitions', function () { + test('transitions property typed with template T during logout() (User Test 3)', function () { + $session = new FixtureTypestateAuthSession(); + + $session->login('alice', 'hunter2'); + expect($session->state)->toBe('authenticated') + ->and(TypePHP::getGenericType($session))->toBe("'authenticated'") + ; + + $session->logout(); + + expect($session->state)->toBe('unauthenticated') + ->and(TypePHP::getGenericType($session))->toBe("'unauthenticated'") + ; + }); + + test('supports multi-step workflow transitioning property back and forth (User Test 6)', function () { + $session = new FixtureTypestateAuthSession(); + + $session->login('bob', 'secret'); + expect(TypePHP::getGenericType($session))->toBe("'authenticated'"); + + $session->logout(); + expect(TypePHP::getGenericType($session))->toBe("'unauthenticated'"); + + $session->login('charlie', 'password'); + expect(TypePHP::getGenericType($session))->toBe("'authenticated'"); + }); + + test('allows login() transition when instance is explicitly pre-bound to initial state', function () { + /** @var FixtureTypestateAuthSession<'unauthenticated'> $session */ + $session = new FixtureTypestateAuthSession(); + + expect(TypePHP::getGenericType($session))->toBe("'unauthenticated'"); + + $session->login('alice', 'hunter2'); + + expect($session->state)->toBe('authenticated') + ->and(TypePHP::getGenericType($session))->toBe("'authenticated'") + ; + }); + + test('still protects against direct external property mutations', function () { + $session = new FixtureTypestateAuthSession(); + $session->login('alice', 'hunter2'); + + expect(fn () => $session->state = 'hacked') + ->toThrow(TypeError::class, 'must be literal') + ; + }); +});