diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 68bc05b..9d56b0a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -30,6 +30,7 @@ export default defineConfig({ { text: 'Function Contracts', link: '/core-concepts/function-contracts' }, { text: 'Property Validation', link: '/core-concepts/property-validation' }, { text: 'Inline Variables', link: '/core-concepts/inline-variables' }, + { text: 'Magic Annotations', link: '/core-concepts/magic-annotations' }, ] }, { diff --git a/docs/core-concepts/magic-annotations.md b/docs/core-concepts/magic-annotations.md new file mode 100644 index 0000000..98d8ac3 --- /dev/null +++ b/docs/core-concepts/magic-annotations.md @@ -0,0 +1,148 @@ +# Magic Annotations (`@property` & `@method`) + +Dynamic properties and magic methods are widely used across modern PHP frameworks (such as Laravel Eloquent models, DTOs, and dynamic service repositories). TypePHP provides transparent, runtime enforcement for class-level `@property`, `@property-read`, `@property-write`, and `@method` annotations. + +--- + +## Class-Level Magic Properties (`@property`, `@property-read`, `@property-write`) + +When a property does not physically exist on a class, PHP routes property writes through `__set()`. TypePHP intercepts these dynamic assignments and validates incoming values against class-level `@property`, `@property-read`, and `@property-write` annotations declared on the class, parent classes, interfaces, or traits: + +```php + $tags + */ +class UserDTO +{ + private array $storage = []; + + public function __set(string $name, mixed $value): void + { + $this->storage[$name] = $value; + } + + public function __get(string $name): mixed + { + return $this->storage[$name] ?? null; + } +} + +$user = new UserDTO(); + +// Valid dynamic property assignment +$user->score = 100; +$user->username = 'Alice'; + +// Invalid dynamic property assignment ($score = -50 violates positive-int) +$user->score = -50; +// Throws: TypeError: Property UserDTO::$score must be of type positive-int, negative int (-50) given +``` + +> **Read/Write Mechanics:** Assigning to a `@property-write` or `@property-read` annotation will validate the incoming value against the declared type constraint. + +--- + +## Class-Level Magic Methods (`@method`) + +When a method is called dynamically via `__call()` or `__callStatic()`, TypePHP intercepts the invocation and validates both incoming arguments and returned values against class-level `@method` annotations: + +```php + fetchBatch(int ...$ids) + * @method bool updateStatus(StatusUnion $status) + */ +class OrderService +{ + public function __call(string $name, array $arguments): mixed + { + return $arguments[0] ?? null; + } + + public static function __callStatic(string $name, array $arguments): mixed + { + return $arguments; + } +} + +$service = new OrderService(); + +// Valid Dynamic Call +$service->processOrder(42, 'SKU-99'); + +// Invalid Argument ($id = -5 violates positive-int) +$service->processOrder(-5, 'SKU-99'); +// Throws: TypeError: OrderService::processOrder(): Argument $id must be of type positive-int + +// Invalid Static Variadic Argument ('invalid' violates int) +OrderService::fetchBatch(1, 2, 'invalid'); +// Throws: TypeError: OrderService::fetchBatch(): Argument $ids[2] must be of type int +``` + +--- + +## DocBlock Inheritance for Magic Annotations + +Child classes automatically inherit magic property and method annotations declared across their entire object hierarchy: + +* **Parent Classes:** A child class extending a parent inherits all parent `@property` and `@method` annotations. +* **Interfaces:** A class implementing an interface inherits magic annotations declared on the interface. +* **Traits:** A class using a trait inherits all magic annotations declared on the trait. +* **Overriding:** If a child class redeclares an `@property` or `@method` annotation, the child's annotation takes precedence. + +--- + +## Best Practice: Quoted Literals in `@method` Signatures + +`phpdoc-parser`'s grammar for `@method` parameter signatures can encounter ambiguity when parsing unparenthesized single quotes directly inside parameter types (such as `@method bool setStatus('active'|'pending' $status)`). When `phpdoc-parser` encounters this grammar ambiguity, it drops that specific `@method` tag. + +**Recommended Best Practice:** Define complex union string literals or array shapes using a local `@phpstan-type` alias, and reference the alias in your `@method` annotation: + +```php +/** + * Recommended: Clean & Grammar-Safe via @phpstan-type + * + * @phpstan-type StatusUnion 'active'|'pending' + * + * @method bool setStatus(StatusUnion $status) + */ +class OrderService +{ + public function __call(string $name, array $arguments) { ... } +} +``` + +--- + +## Configuration Toggles + +Magic property and magic method validations are enabled by default. You can fine-tune or disable them in your `typephp.php` configuration file: + +```php +// typephp.php +return [ + /* + |-------------------------------------------------------------------------- + | Magic Annotations (@property & @method) + |-------------------------------------------------------------------------- + */ + 'magic_properties' => true, // Set to false to disable dynamic @property checks + 'magic_methods' => true, // Set to false to disable dynamic @method checks +]; +``` \ No newline at end of file diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 183387d..5e1f5f8 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -34,6 +34,16 @@ return [ 'params' => true, 'returns' => true, + /* + |-------------------------------------------------------------------------- + | Magic Annotations (@property & @method) + |-------------------------------------------------------------------------- + | Enforces class-level annotations for dynamic properties and magic methods + | routed through __get, __set, __call, and __callStatic. + */ + 'magic_properties' => true, + 'magic_methods' => true, + /* |-------------------------------------------------------------------------- | Respect Ignore Docblock Tags @@ -107,6 +117,22 @@ return [ --- +## Configuration Reference + +Key options explained: + +| Configuration Option | Default | Description | +| :--- | :--- | :--- | +| **`'enabled'`** | `true` | Global master switch for runtime type enforcement. | +| **`'params'`** | `true` | Enforces parameter `@param` contracts on physical functions and methods. | +| **`'returns'`** | `true` | Enforces return `@return` contracts on physical functions and methods. | +| **`'magic_properties'`** | `true` | Enforces class-level `@property`, `@property-read`, and `@property-write` annotations on dynamic assignments (`__set`). | +| **`'magic_methods'`** | `true` | Enforces class-level `@method` annotations on dynamic method calls (`__call` / `__callStatic`). | +| **`'respect_ignore_tags'`** | `true` | Respects `@typephp-ignore` and `@typephp-ignore-file` tags. Set to `false` in CI/CD to force audit checks. | +| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk (`typephp-cache/`) for OPcache optimization. | + +--- + ## Inline Variable Categories Reference (`inline_vars`) How each `inline_vars` toggle maps to PHPDoc type annotations: @@ -116,7 +142,7 @@ How each `inline_vars` toggle maps to PHPDoc type annotations: | **`'scalars'`** | Primitive & Refined Scalars | `int`, `string`, `bool`, `positive-int`, `non-empty-string`, `truthy` | | **`'objects'`** | Class Instances & Bare Class References | `User`, `stdClass`, `class-string`, `interface-string`, `enum-string` | | **`'generics'`** | Template & Bound Types | `Collection`, `Producer`, `class-string` | -| **` illegible 'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list`, `array` | +| **`'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list`, `array` | | **`'callables'`** | Callables & Closures | `callable`, `Closure`, `callable(int): string`, `static-closure` | | **`'properties'`** | Class Property Writes | `$this->id = 1`, `UserProfile::$username = 'Alice'` | diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index b8701f9..7dbdd12 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -6,7 +6,7 @@ TypePHP enforces PHPDoc type contracts at runtime during execution. Below is an ## What is TypePHP? -TypePHP is a transparent, pure-PHP runtime type checker that enforces extended PHPDoc type contracts (`@param`, `@return`, `@var`, `@template`, array shapes, integer ranges, and scalar refinements) during actual execution. +TypePHP is a transparent, pure-PHP runtime type checker that enforces extended PHPDoc type contracts (`@param`, `@return`, `@var`, `@template`, `@property`, `@method`, array shapes, integer ranges, and scalar refinements) during actual execution. Unlike traditional assertion libraries that force you to write repetitive manual check calls inside every function, or validation frameworks that require custom PHP attributes and base classes, TypePHP requires **zero manual checks** and **zero new syntax**. It works transparently using your existing PHPDoc annotations. @@ -29,7 +29,7 @@ TypePHP does not force you into an "all-or-nothing" paradigm. You do not have to 1. **Path-Level Whitelisting:** Use `include` patterns in `typephp.php` to target specific mission-critical domain modules (such as `app/Domain/Billing/**`) while completely bypassing legacy directories. 2. **Method-Level Suppression:** Add `@typephp-ignore` to specific legacy methods or un-refactored functions without removing their PHPDoc annotations. -3. **Category-Level Feature Toggles:** Granularly enable or disable specific check categories (`inline_vars.scalars`, `inline_vars.arrays`, `params`, `returns`) in `typephp.php` depending on performance or migration needs. +3. **Category-Level Feature Toggles:** Granularly enable or disable specific check categories (`inline_vars.scalars`, `inline_vars.arrays`, `params`, `returns`, `magic_properties`, `magic_methods`) in `typephp.php` depending on performance or migration needs. --- @@ -192,6 +192,45 @@ $users->add(new Product('SKU-100')); --- +## Class-Level Magic Annotations (`@property` & `@method`) + +TypePHP validates dynamic property writes (`__set`) and dynamic method calls (`__call`) against class-level `@property` and `@method` annotations: + +```php +/** + * @phpstan-type StatusUnion 'active'|'pending' + * + * @property positive-int $score + * @method bool updateStatus(StatusUnion $status) + */ +class DynamicModel +{ + private array $storage = []; + + public function __set(string $name, mixed $value): void + { + $this->storage[$name] = $value; + } + + public function __call(string $name, array $arguments): mixed + { + return true; + } +} + +$model = new DynamicModel(); + +// Invalid Dynamic Property Assignment ($score = -50 violates positive-int) +$model->score = -50; +// Throws: TypeError: Property DynamicModel::$score must be of type positive-int + +// Invalid Dynamic Method Argument ($status = 'archived' violates StatusUnion) +$model->updateStatus('archived'); +// Throws: TypeError: DynamicModel::updateStatus(): Argument $status must be of type ('active' | 'pending') +``` + +--- + ## PHP 8.4 Property Hooks & Asymmetric Visibility TypePHP validates incoming and returned values on PHP 8.4 Property Hooks: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fec8673..1be29dc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,6 +55,28 @@ TypePHP injects guard rails at the call site where assignments happen. --- +### Why is my `@method` annotation with quoted literals like `'active'|'pending'` not being enforced? + +`phpdoc-parser`'s grammar engine for `@method` parameter lists can encounter ambiguity when parsing unparenthesized single or double quotes directly inside parameter type signatures (such as `@method bool updateStatus('active'|'pending' $status)`). When `phpdoc-parser` encounters this grammar ambiguity, it drops that specific `@method` tag during DocBlock parsing. + +**Solution:** Use a local `@phpstan-type` alias to define the union string literal or shape, and reference the alias in your `@method` annotation: + +```php +/** + * Best Practice: Clean & Grammar-Safe via @phpstan-type + * + * @phpstan-type StatusUnion 'active'|'pending' + * + * @method bool updateStatus(StatusUnion $status) + */ +class OrderService +{ + public function __call(string $name, array $arguments) { ... } +} +``` + +--- + ### Why is my Pest or PHPUnit test suite running slower with JIT enabled? During CLI test execution, a single short-lived PHP process runs your tests. diff --git a/src/Command/ConfigInitCommand.php b/src/Command/ConfigInitCommand.php index d18441d..46c4779 100644 --- a/src/Command/ConfigInitCommand.php +++ b/src/Command/ConfigInitCommand.php @@ -64,6 +64,16 @@ private static function getTemplate(): string 'params' => true, 'returns' => true, + /* + |-------------------------------------------------------------------------- + | Magic Annotations (@property & @method) + |-------------------------------------------------------------------------- + | Enforces class-level annotations for dynamic properties and magic methods + | routed through __get, __set, __call, and __callStatic. + */ + 'magic_properties' => true, + 'magic_methods' => true, + /* |-------------------------------------------------------------------------- | Respect Ignore Docblock Tags diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 88bf37a..1f75c85 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -37,6 +37,23 @@ final class ContractParser */ private static array $propertyCache = []; + /** + * Cache for resolved magic method contracts. + * + * @var array, aliases: array, templates: array}> + */ + private static array $magicMethodCache = []; + + /** + * Resets the contract and property caches. Useful for test isolation or config changes. + */ + public static function reset(): void + { + self::$cache = []; + self::$propertyCache = []; + self::$magicMethodCache = []; + } + /** * Parses PHPDoc contracts for a function or class method. * @@ -84,7 +101,13 @@ public static function parse(string $function): array } /** - * Parses and resolves the @var docblock for a given class property (including PHP 8.4 interface properties). + * Parses and resolves the @var or @property docblock for a given class property. + * + * Resolution Steps: + * 1. Search class and parent class hierarchy for physical properties. + * 2. Search implemented interfaces (PHP 8.4 interface properties). + * 3. Fall back to class-level magic @property tags if enabled and physical property is not found. + * 4. Parse physical @var tags if not already resolved as a magic property. */ public static function parseProperty(string $className, string $propertyName): ?TypeNode { @@ -103,8 +126,9 @@ public static function parseProperty(string $className, string $propertyName): ? $doc = false; $declaringClass = null; + $typeNode = null; + $isMagicProperty = false; - // Search Class and Parent Class Hierarchy $current = $refClass; while ($current !== false) { if ($current->hasProperty($propertyName)) { @@ -120,7 +144,6 @@ public static function parseProperty(string $className, string $propertyName): ? $current = $current->getParentClass(); } - // Search Implemented Interfaces (PHP 8.4 Interface Properties) if ($doc === false) { foreach ($refClass->getInterfaces() as $interface) { if ($interface->hasProperty($propertyName)) { @@ -136,30 +159,61 @@ public static function parseProperty(string $className, string $propertyName): ? } } + if ($doc === false && (bool) (Config::get()['magic_properties'] ?? true)) { + $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); + foreach ($classHierarchy as $hierClass) { + $fileName = $hierClass->getFileName(); + if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { + continue; + } + + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $extractedType = DocblockExtractor::extractTypeFromClassPropertyDoc($classDoc, $propertyName); + if ($extractedType !== null) { + $doc = $classDoc; + $declaringClass = $hierClass; + $typeNode = $extractedType; + $isMagicProperty = true; + + break; + } + } + } + } + if ($doc === false || $declaringClass === null) { return self::$propertyCache[$cacheKey] = null; } - // Skip property type checks if docblock contains @typephp-ignore $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) { return self::$propertyCache[$cacheKey] = null; } - $phpDocNode = DocblockExtractor::parseDocString($doc); - $varTags = $phpDocNode->getVarTagValues(); + if (! $isMagicProperty) { + $phpDocNode = DocblockExtractor::parseDocString($doc); + $varTags = $phpDocNode->getVarTagValues(); - if (\count($varTags) === 0) { - return self::$propertyCache[$cacheKey] = null; + if (\count($varTags) === 0) { + return self::$propertyCache[$cacheKey] = null; + } + + $typeNode = $varTags[0]->type; } - $typeNode = $varTags[0]->type; + if ($typeNode === null) { + return self::$propertyCache[$cacheKey] = null; + } $aliases = []; $templates = []; self::parseClassLevelDocs($declaringClass, $templates, $aliases); - DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); + if (! $isMagicProperty) { + $phpDocNode = DocblockExtractor::parseDocString($doc); + DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); + } $typeNode = self::substituteAliases($typeNode, $aliases); $resolvedNode = SpecialTypeResolver::resolve($typeNode, $declaringClass); @@ -170,6 +224,124 @@ public static function parseProperty(string $className, string $propertyName): ? } } + /** + * Parses and resolves a class-level @method docblock for __call / __callStatic. + * + * Resolution Steps: + * 1. Search class, parent, interface, and trait hierarchy for @method tags (excluding vendor files). + * 2. Substitute type aliases and resolve FQCNs for parameters and return types. + * + * @return array{return: ?TypeNode, parameters: array, aliases: array, templates: array}|null + */ + public static function parseMagicMethod(string $className, string $methodName): ?array + { + $cacheKey = $className . '::' . $methodName; + if (\array_key_exists($cacheKey, self::$magicMethodCache)) { + return self::$magicMethodCache[$cacheKey]; + } + + if (! class_exists($className) && ! trait_exists($className) && ! interface_exists($className)) { + return self::$magicMethodCache[$cacheKey] = null; + } + + try { + /** @var class-string $className */ + $refClass = new \ReflectionClass($className); + $doc = false; + $declaringClass = null; + $methodTag = null; + + $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); + foreach ($classHierarchy as $hierClass) { + $fileName = $hierClass->getFileName(); + if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { + continue; + } + + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $tag = DocblockExtractor::extractMagicMethodContract($classDoc, $methodName); + if ($tag !== null) { + $doc = $classDoc; + $declaringClass = $hierClass; + $methodTag = $tag; + + break; + } + } + } + + if ($methodTag === null || $declaringClass === null || $doc === false) { + return self::$magicMethodCache[$cacheKey] = null; + } + + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) { + return self::$magicMethodCache[$cacheKey] = null; + } + + $aliases = []; + $templates = []; + self::parseClassLevelDocs($declaringClass, $templates, $aliases); + + $phpDocNode = DocblockExtractor::parseDocString($doc); + DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); + + $resolvedReturn = null; + if ($methodTag->returnType !== null) { + $subReturn = self::substituteAliases($methodTag->returnType, $aliases); + $resolvedReturn = SpecialTypeResolver::resolve($subReturn, $declaringClass); + } + + $resolvedParams = []; + foreach ($methodTag->parameters as $p) { + $pType = $p->type ?? null; + if ($pType !== null) { + $subType = self::substituteAliases($pType, $aliases); + $pType = SpecialTypeResolver::resolve($subType, $declaringClass); + } + + $rawParamName = ''; + $pVars = get_object_vars($p); + foreach ($pVars as $key => $val) { + if (\is_string($val) && str_starts_with($val, '$')) { + $rawParamName = $val; + + break; + } + } + if ($rawParamName === '') { + foreach (['parameterName', 'name', 'paramName', 'varName'] as $key) { + if (isset($pVars[$key]) && \is_string($pVars[$key])) { + $rawParamName = $pVars[$key]; + + break; + } + } + } + + $pName = ltrim($rawParamName, '$'); + $isOptional = (isset($pVars['isOptional']) && (bool) $pVars['isOptional']) || (($p->defaultValue ?? null) !== null); + + $resolvedParams[] = [ + 'name' => $pName, + 'type' => $pType, + 'isVariadic' => $p->isVariadic, + 'isOptional' => $isOptional, + ]; + } + + return self::$magicMethodCache[$cacheKey] = [ + 'return' => $resolvedReturn, + 'parameters' => $resolvedParams, + 'aliases' => $aliases, + 'templates' => $templates, + ]; + } catch (\Throwable $e) { + return self::$magicMethodCache[$cacheKey] = null; + } + } + /** * Extracts and returns all class-level type aliases for a given class. * @@ -461,7 +633,7 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -478,14 +650,14 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN if ($node instanceof UnionTypeNode) { return new UnionTypeNode(array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types )); } if ($node instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types )); } diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index b54f973..63df167 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -4,6 +4,7 @@ namespace TypePHP\Contract; +use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -114,14 +115,14 @@ public static function extractAliases( \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref ): void { foreach ($phpDocNode->getTypeAliasTagValues() as $aliasTag) { - if (!isset($aliases[$aliasTag->alias])) { + if (! isset($aliases[$aliasTag->alias])) { $aliases[$aliasTag->alias] = $aliasTag->type; } } foreach ($phpDocNode->getTypeAliasImportTagValues() as $importTag) { $localName = $importTag->importedAs ?? $importTag->importedAlias; - if (!isset($aliases[$localName])) { + if (! isset($aliases[$localName])) { $fqcnSource = SpecialTypeResolver::resolveFqcn($importTag->importedFrom->name, $ref); $resolvedType = self::resolveImportedTypeAlias($fqcnSource, $importTag->importedAlias); if ($resolvedType !== null) { @@ -168,4 +169,55 @@ public static function resolveImportedTypeAlias(string $fqcn, string $importedAl return null; } + + /** + * Extracts a TypeNode from a class-level @property, @property-read, or @property-write docblock. + */ + public static function extractTypeFromClassPropertyDoc(string $doc, string $propName): ?TypeNode + { + try { + $phpDocNode = self::parseDocString($doc); + + foreach ($phpDocNode->getPropertyTagValues() as $tag) { + if (ltrim($tag->propertyName, '$') === $propName) { + return $tag->type; + } + } + + foreach ($phpDocNode->getPropertyWriteTagValues() as $tag) { + if (ltrim($tag->propertyName, '$') === $propName) { + return $tag->type; + } + } + + foreach ($phpDocNode->getPropertyReadTagValues() as $tag) { + if (ltrim($tag->propertyName, '$') === $propName) { + return $tag->type; + } + } + } catch (\Throwable $e) { + // Silently ignore malformed class docblocks + } + + return null; + } + + /** + * Extracts a MethodTagValueNode from a class-level @method docblock. + */ + public static function extractMagicMethodContract(string $doc, string $methodName): ?MethodTagValueNode + { + try { + $phpDocNode = self::parseDocString($doc); + foreach ($phpDocNode->getMethodTagValues() as $tag) { + if ($tag->methodName === $methodName) { + return $tag; + } + } + } catch (\Throwable $e) { + // Silently ignore malformed class docblocks + } + + return null; + } } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 1668ed1..da212dd 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -20,7 +20,7 @@ use TypePHP\Validator\TypeValidatorRegistry; /** - * @internal Evaluates function and method parameter contract validations. + * @internal Evaluates function and method parameter contract validations (including dynamic @method calls via __call / __callStatic). */ final class ParamChecker { @@ -42,6 +42,25 @@ public static function checkParams(string $function, array $vars, ?object $thisO } } + $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); + if ($isMagicCall && (bool) (Config::get()['magic_methods'] ?? true)) { + $magicMethodName = array_values($vars)[0] ?? null; + $magicArgs = array_values($vars)[1] ?? []; + + if (\is_string($magicMethodName) && \is_array($magicArgs)) { + $className = explode('::', $effectiveFunction, 2)[0]; + $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); + + if ($magicContract !== null) { + $magicFunction = $className . '::' . $magicMethodName; + $err = self::validateMagicArguments($magicContract, $magicArgs, $magicFunction, $thisObj, $registry); + if ($err !== null) { + return $err; + } + } + } + } + $contract = ContractParser::parse($effectiveFunction); if (\count($contract['types']) === 0) { return null; @@ -100,6 +119,102 @@ public static function checkParams(string $function, array $vars, ?object $thisO return null; } + /** + * @param array{return: ?TypeNode, parameters: array, aliases: array, templates: array} $magicContract + * @param array $args + */ + private static function validateMagicArguments(array $magicContract, array $args, string $function, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage + { + $templates = $magicContract['templates']; + $aliases = $magicContract['aliases']; + $parameters = $magicContract['parameters']; + + if ($thisObj !== null) { + $declaringClass = explode('::', $function, 2)[0]; + TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); + } else { + TemplateManager::clearCallBindings($function, $templates); + } + + $argValues = array_values($args); + $argKeys = array_keys($args); + + foreach ($parameters as $index => $p) { + $paramName = $p['name']; + $typeNode = $p['type']; + $isVariadic = $p['isVariadic']; + + if ($typeNode === null) { + continue; + } + + $val = null; + $hasVal = false; + + if ($isVariadic) { + if (\array_key_exists($paramName, $args)) { + $val = [$args[$paramName]]; + $hasVal = true; + } else { + $val = []; + for ($i = $index; $i < \count($argValues); $i++) { + if (\is_int($argKeys[$i])) { + $val[] = $argValues[$i]; + $hasVal = true; + } + } + } + $typeNode = new ArrayTypeNode($typeNode); + } else { + if (\array_key_exists($paramName, $args)) { + $val = $args[$paramName]; + $hasVal = true; + } elseif (\array_key_exists($index, $argValues)) { + $val = $argValues[$index]; + $hasVal = true; + } + } + + if (! $hasVal) { + continue; + } + + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } + $typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj); + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } + + if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { + $sampleVal = $isVariadic && \is_array($val) ? ($val[0] ?? null) : $val; + $err = self::resolveClassStringTemplate($typeNode, $sampleVal, $paramName, $function, $thisObj, $templates); + if ($err !== null) { + return $err; + } + + continue; + } + + if (self::getTemplateName($typeNode, $templates) !== null) { + $err = self::resolveTemplateParam($typeNode, $val, $paramName, $function, $thisObj, $templates, $registry); + if ($err !== null) { + return $err; + } + + continue; + } + + $err = $registry->validate($val, $typeNode, $function . '(): Argument $' . $paramName); + if ($err !== null) { + return $err; + } + } + + return null; + } + /** * @param array $templates */ @@ -129,7 +244,9 @@ private static function resolveClassStringTemplate(GenericTypeNode $typeNode, mi if ($templateNode->bound !== null) { $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); $boundName = $resolvedBound instanceof IdentifierTypeNode ? $resolvedBound->name : (string) $resolvedBound; - if (! is_a($val, $boundName, true)) { + $lowerBound = strtolower($boundName); + + if ($lowerBound !== 'object' && $lowerBound !== 'mixed' && ! is_a($val, $boundName, true)) { return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundName . ", '" . $val . "' given"); } } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index de285e6..89b2d74 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -18,7 +18,7 @@ use TypePHP\Validator\TypeValidatorRegistry; /** - * @internal Evaluates function and method return contract validations. + * @internal Evaluates function and method return contract validations (including dynamic @method calls via __call / __callStatic). */ final class ReturnChecker { @@ -40,6 +40,51 @@ public static function checkReturn(string $function, mixed $value, ?object $this } } + $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); + if ($isMagicCall && (bool) (Config::get()['magic_methods'] ?? true)) { + $magicMethodName = array_values($vars)[0] ?? null; + $rawMagicArgs = array_values($vars)[1] ?? []; + /** @var array $magicArgs */ + $magicArgs = \is_array($rawMagicArgs) ? $rawMagicArgs : []; + + if (\is_string($magicMethodName)) { + $className = explode('::', $effectiveFunction, 2)[0]; + $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); + + if ($magicContract !== null && $magicContract['return'] !== null) { + $returnTypeNode = $magicContract['return']; + $magicFunction = $className . '::' . $magicMethodName; + + $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $magicFunction); + if ($err !== null) { + return $err; + } + + $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $magicFunction, $thisObj); + + $aliases = $magicContract['aliases'] ?? []; + if ($returnTypeNode instanceof IdentifierTypeNode && isset($aliases[$returnTypeNode->name])) { + $returnTypeNode = $aliases[$returnTypeNode->name]; + } + + $boundTemplates = TemplateManager::getBoundTemplates($magicFunction, $thisObj, $magicContract['templates']); + $declaredTemplates = $magicContract['templates']; + + if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { + $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $declaredTemplates); + $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $magicFunction, $thisObj); + } + + $returnTypeNode = self::resolveConditionalReturnType($returnTypeNode, $magicArgs, $boundTemplates, $registry); + + $err = $registry->validate($value, $returnTypeNode, $magicFunction . '(): Return value'); + if ($err !== null) { + return $err; + } + } + } + } + $contract = ContractParser::parse($effectiveFunction); $returnTypeNode = $contract['return'] ?? null; @@ -92,7 +137,7 @@ public static function checkReturn(string $function, mixed $value, ?object $this } /** - * @param array $vars + * @param array $vars * @param array $boundTemplates */ private static function resolveConditionalReturnType( diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 1cb396f..af89a5f 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal; +use TypePHP\Contract\ContractParser; use TypePHP\Extension\ExtensionInterface; use TypePHP\Extension\ExtensionManager; @@ -34,6 +35,8 @@ public static function get(): array 'enabled' => true, 'params' => true, 'returns' => true, + 'magic_properties' => true, + 'magic_methods' => true, 'respect_ignore_tags' => true, 'cache' => true, 'inline_vars' => [ @@ -83,6 +86,8 @@ public static function set(array $config): void $mergedConfig = array_replace_recursive(self::get(), $config); self::$cachedConfig = $mergedConfig; + + ContractParser::reset(); } /** @@ -91,5 +96,7 @@ public static function set(array $config): void public static function reset(): void { self::$cachedConfig = null; + + ContractParser::reset(); } } diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 8da48d2..5213cde 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -256,7 +256,19 @@ private static function getReflectionContext(\ReflectionClass|\ReflectionFunctio if (str_contains($context, '::')) { [$className, $methodName] = explode('::', $context, 2); - return new \ReflectionMethod($className, $methodName); + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + /** @var class-string $className */ + try { + return new \ReflectionMethod($className, $methodName); + } catch (\ReflectionException $e) { + return new \ReflectionClass($className); + } + } + + /** @var class-string $fallbackClass */ + $fallbackClass = \stdClass::class; + + return new \ReflectionClass($fallbackClass); } return new \ReflectionFunction($context); diff --git a/src/Validator/UnionValidator.php b/src/Validator/UnionValidator.php index 59af4d1..3c766d9 100644 --- a/src/Validator/UnionValidator.php +++ b/src/Validator/UnionValidator.php @@ -29,7 +29,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } $msg = $err->getMessage(); - + if ( str_starts_with($msg, $context . '[') || str_starts_with($msg, $context . '->') || @@ -50,4 +50,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } -} \ No newline at end of file +} diff --git a/tests/Contract/DocblockExtractorTest.php b/tests/Contract/DocblockExtractorTest.php index 2c010c4..47fbaf2 100644 --- a/tests/Contract/DocblockExtractorTest.php +++ b/tests/Contract/DocblockExtractorTest.php @@ -56,4 +56,20 @@ expect($aliases)->toHaveKey('LocalUserShape'); }); + + test('extracts type from class-level @property, @property-read, and @property-write docblocks', function () { + $doc = "/**\n * @property positive-int \$score\n * @property-read non-empty-string \$title\n * @property-write list \$tags\n */"; + + $scoreType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'score'); + expect((string) $scoreType)->toBe('positive-int'); + + $titleType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'title'); + expect((string) $titleType)->toBe('non-empty-string'); + + $tagsType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'tags'); + expect((string) $tagsType)->toBe('list'); + + $missingType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'missing'); + expect($missingType)->toBeNull(); + }); }); diff --git a/tests/Fixtures/Types/BaseMagicMethodClass.php b/tests/Fixtures/Types/BaseMagicMethodClass.php new file mode 100644 index 0000000..075cc17 --- /dev/null +++ b/tests/Fixtures/Types/BaseMagicMethodClass.php @@ -0,0 +1,14 @@ + 3, 'strict' => true]; @@ -31,4 +32,4 @@ public function loadBad(): void ['name' => 'SwagPayPal', 'active' => 'yes'], ]; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/Imported/KernelPluginLoader.php b/tests/Fixtures/Types/Imported/KernelPluginLoader.php index 8906059..b2f9202 100644 --- a/tests/Fixtures/Types/Imported/KernelPluginLoader.php +++ b/tests/Fixtures/Types/Imported/KernelPluginLoader.php @@ -10,6 +10,8 @@ */ abstract class KernelPluginLoader { - /** @var list */ + /** + * @var list + */ public array $pluginInfos = []; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/Imported/TraitWithAlias.php b/tests/Fixtures/Types/Imported/TraitWithAlias.php index 80c8a83..69879fd 100644 --- a/tests/Fixtures/Types/Imported/TraitWithAlias.php +++ b/tests/Fixtures/Types/Imported/TraitWithAlias.php @@ -9,6 +9,8 @@ */ trait TraitWithAlias { - /** @var TraitShape */ + /** + * @var TraitShape + */ public array $coordinates = ['x' => 0, 'y' => 0]; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/MagicMethodFixture.php b/tests/Fixtures/Types/MagicMethodFixture.php new file mode 100644 index 0000000..89133b0 --- /dev/null +++ b/tests/Fixtures/Types/MagicMethodFixture.php @@ -0,0 +1,62 @@ +} + * @phpstan-type StatusUnion 'active'|'pending' + * + * @method positive-int processId(positive-int $id, non-empty-string $name) + * @method static list fetchList(int ...$items) + * @method PayloadShape buildPayload(list $ids, StatusUnion $status) + * @method Producer getProducer(Producer $producer) + * @method bool checkCollection((\Countable&\ArrayAccess)|null $collection) + * @method LocalUserShape saveUser(LocalUserShape $user) + */ +class MagicMethodFixture +{ + public function __call(string $name, array $arguments): mixed + { + if ($name === 'processId') { + return $arguments[0] ?? null; + } + + if ($name === 'buildPayload') { + $ids = $arguments[0] ?? []; + + return [ + 'id' => $ids[0] ?? 1, + 'tags' => ['php', 'typephp'], + ]; + } + + if ($name === 'getProducer') { + return $arguments[0] ?? null; + } + + if ($name === 'checkCollection') { + return true; + } + + if ($name === 'saveUser') { + return $arguments[0] ?? null; + } + + return null; + } + + public static function __callStatic(string $name, array $arguments): mixed + { + if ($name === 'fetchList') { + return $arguments; + } + + return null; + } +} diff --git a/tests/Fixtures/Types/MagicMethodInterface.php b/tests/Fixtures/Types/MagicMethodInterface.php new file mode 100644 index 0000000..6c368ce --- /dev/null +++ b/tests/Fixtures/Types/MagicMethodInterface.php @@ -0,0 +1,14 @@ + $magicTags + */ +class MagicPropertyFixture +{ + public array $data = []; + + public function __set(string $name, mixed $value): void + { + $this->data[$name] = $value; + } + + public function __get(string $name): mixed + { + return $this->data[$name] ?? null; + } +} diff --git a/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php index e748c21..6e8114e 100644 --- a/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ImportedTypePropertyTest.php @@ -2,34 +2,36 @@ declare(strict_types=1); -use TypePHP\Tests\Fixtures\Types\Imported\DbalKernelPluginLoader; use TypePHP\Tests\Fixtures\Types\Imported\ClassUsingTraitWithAlias; +use TypePHP\Tests\Fixtures\Types\Imported\DbalKernelPluginLoader; describe('Class-Level Imported Types on Properties', function () { - + test('resolves @phpstan-type and @phpstan-import-type on class properties', function () { $loader = new DbalKernelPluginLoader(); $loader->load(); - + expect($loader->pluginInfos)->toHaveCount(1); expect($loader->pluginInfos[0]['name'])->toBe('SwagPayPal'); }); - + test('fails correctly when the imported array shape is actually violated', function () { $loader = new DbalKernelPluginLoader(); - - expect(fn() => $loader->loadBad()) - ->toThrow(\TypeError::class, "['active']"); + + expect(fn () => $loader->loadBad()) + ->toThrow(TypeError::class, "['active']") + ; }); test('resolves overridden aliases in child class without breaking parent inheritance', function () { $loader = new DbalKernelPluginLoader(); - + $loader->config = ['retries' => 5, 'strict' => false]; expect($loader->config)->toBe(['retries' => 5, 'strict' => false]); - - expect(fn() => $loader->config = ['retries' => -1, 'strict' => false]) - ->toThrow(\TypeError::class, "['retries'] must be of type positive-int"); + + expect(fn () => $loader->config = ['retries' => -1, 'strict' => false]) + ->toThrow(TypeError::class, "['retries'] must be of type positive-int") + ; }); test('resolves aliases defined on traits applied to properties inside the trait', function () { @@ -40,7 +42,8 @@ $instance->coordinates = ['x' => 10, 'y' => 20]; expect($instance->coordinates['x'])->toBe(10); - expect(fn() => $instance->coordinates = ['x' => 10, 'y' => 'invalid']) - ->toThrow(\TypeError::class, "['y'] must be of type int"); + expect(fn () => $instance->coordinates = ['x' => 10, 'y' => 'invalid']) + ->toThrow(TypeError::class, "['y'] must be of type int") + ; }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php index 5454634..de3fa58 100644 --- a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -17,7 +17,7 @@ function testAttributeCompilerSim(): ?array { return [ 'name' => 'Field', - 'args' => ['column', 'property', new \stdClass()], + 'args' => ['column', 'property', new stdClass()], ]; } @@ -63,73 +63,83 @@ class PropertyUnionFixture describe('Union Deep Error Bubbling', function () { test('surfaces deep array shape error instead of generic union error', function () { - expect(fn() => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]])) - ->toThrow(\TypeError::class, "Argument \$payload['tags'][1] must be of type (string | int)"); + expect(fn () => testDeepUnionError(['id' => 10, 'tags' => ['hello', false]])) + ->toThrow(TypeError::class, "Argument \$payload['tags'][1] must be of type (string | int)") + ; }); test('surfaces deep return shape error mimicking AttributeEntityCompiler', function () { - expect(fn() => testAttributeCompilerSim()) - ->toThrow(\TypeError::class, "Return value['args'][2] must be of type (string | int | false)"); + expect(fn () => testAttributeCompilerSim()) + ->toThrow(TypeError::class, "Return value['args'][2] must be of type (string | int | false)") + ; }); - + test('surfaces missing key error from array shape inside union', function () { - expect(fn() => testDeepUnionError(['id' => 10])) - ->toThrow(\TypeError::class, "Argument \$payload is missing required key 'tags'"); + expect(fn () => testDeepUnionError(['id' => 10])) + ->toThrow(TypeError::class, "Argument \$payload is missing required key 'tags'") + ; }); test('surfaces deep object shape error inside union using anonymous class', function () { - $user = new class { + $user = new class () { public int $id = 10; + public object $profile; - public function __construct() { - $this->profile = new class { - public string $name = ''; + public function __construct() + { + $this->profile = new class () { + public string $name = ''; }; } }; - expect(fn() => testDeepObjectShapeUnion($user)) - ->toThrow(\TypeError::class, "Argument \$user->profile->name must be of type non-empty-string"); + expect(fn () => testDeepObjectShapeUnion($user)) + ->toThrow(TypeError::class, 'Argument $user->profile->name must be of type non-empty-string') + ; }); test('surfaces missing property error on object shape inside union using anonymous class', function () { - $obj = new class { - public int $id = 10; + $obj = new class () { + public int $id = 10; }; - expect(fn() => testMissingObjectPropertyUnion($obj)) - ->toThrow(\TypeError::class, "Argument \$data is missing required property 'role'"); + expect(fn () => testMissingObjectPropertyUnion($obj)) + ->toThrow(TypeError::class, "Argument \$data is missing required property 'role'") + ; }); test('surfaces uninitialized property error on object shape inside union using anonymous class', function () { - $obj = new class { - public string $name; + $obj = new class () { + public string $name; }; - expect(fn() => testUninitializedObjectPropertyUnion($obj)) - ->toThrow(\TypeError::class, "Argument \$data property 'name' is uninitialized"); + expect(fn () => testUninitializedObjectPropertyUnion($obj)) + ->toThrow(TypeError::class, "Argument \$data property 'name' is uninitialized") + ; }); test('surfaces deep error in nested discriminated union shape', function () { $payload = [ 'type' => 'A', - 'data' => ['score' => -5], + 'data' => ['score' => -5], ]; - expect(fn() => testDiscriminatedUnionDeepError($payload)) - ->toThrow(\TypeError::class, "['score'] must be of type positive-int"); + expect(fn () => testDiscriminatedUnionDeepError($payload)) + ->toThrow(TypeError::class, "['score'] must be of type positive-int") + ; }); test('surfaces deep error on class property with union shape', function () { $fixture = new PropertyUnionFixture(); - expect(fn() => $fixture->settings = ['config' => ['enabled' => 'not_a_bool']]) - ->toThrow(\TypeError::class, "Property PropertyUnionFixture::\$settings['config']['enabled'] must be of type bool"); + expect(fn () => $fixture->settings = ['config' => ['enabled' => 'not_a_bool']]) + ->toThrow(TypeError::class, "Property PropertyUnionFixture::\$settings['config']['enabled'] must be of type bool") + ; }); test('falls back gracefully to generic union error when no deep branch matches structure', function () { - expect(fn() => testDeepUnionError('string_instead_of_array')) - ->toThrow(\TypeError::class, "must be of type (array{id: int, tags: list<(string | int)>} | null)"); + expect(fn () => testDeepUnionError('string_instead_of_array')) + ->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)'); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/MagicMethodsTest.php b/tests/TypeChecking/Boundaries/MagicMethodsTest.php new file mode 100644 index 0000000..789933a --- /dev/null +++ b/tests/TypeChecking/Boundaries/MagicMethodsTest.php @@ -0,0 +1,136 @@ +processId(42, 'Alice'))->toBe(42); + + 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') + ; + + 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") + ; + }); + + test('validates variadic arguments passed into dynamic static method', function () { + expect(MagicMethodFixture::fetchList(1, 2, 3))->toBe([1, 2, 3]); + + 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") + ; + }); + }); + + describe('Array Shapes & Lists in @method', function () { + test('validates list arguments and array shape returns on dynamic method', function () { + $fixture = new MagicMethodFixture(); + + $result = $fixture->buildPayload([10, 20], 'active'); + expect($result)->toBe(['id' => 10, 'tags' => ['php', 'typephp']]); + + 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') + ; + + expect(fn () => $fixture->buildPayload([10, 20], 'archived')) + ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')") + ; + }); + }); + + describe('Generics in @method', function () { + test('validates generic object instances passed to dynamic method', function () { + $fixture = new MagicMethodFixture(); + $dogProducer = new Producer(new Dog()); + + expect($fixture->getProducer($dogProducer))->toBe($dogProducer); + + $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') + ; + }); + }); + + describe('Intersections & Nullable Types in @method', function () { + test('validates intersection types and nullable null on dynamic method', function () { + $fixture = new MagicMethodFixture(); + + 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)') + ; + }); + }); + + describe('Type Aliases (@phpstan-type) in @method', function () { + test('resolves local class-level type aliases inside @method definitions', function () { + $fixture = new MagicMethodFixture(); + + $validUser = ['id' => 10, 'role' => 'admin']; + expect($fixture->saveUser($validUser))->toBe($validUser); + + $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')") + ; + }); + }); + + describe('Inheritance with @method (Classes, Interfaces & Traits)', function () { + test('inherits @method contracts from parent classes, interfaces, and traits', function () { + $fixture = new ChildInheritedMagicMethodFixture(); + + expect($fixture->parentMethod(100))->toBe(100); + expect(fn () => $fixture->parentMethod(-5)) + ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::parentMethod(): Argument $id must be of type positive-int') + ; + + expect($fixture->interfaceMethod('hello'))->toBe('hello'); + expect(fn () => $fixture->interfaceMethod('')) + ->toThrow(TypeError::class, 'TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::interfaceMethod(): Argument $title must be of type non-empty-string') + ; + + expect($fixture->traitMethod('admin'))->toBeTrue(); + expect(fn () => $fixture->traitMethod('guest')) + ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\ChildInheritedMagicMethodFixture::traitMethod(): Argument \$role must be of type ('admin' | 'user')") + ; + }); + }); + + describe('Configuration Control', function () { + test('ignores magic method validation when magic_methods config is false', function () { + Config::set(['magic_methods' => false]); + + $fixture = new MagicMethodFixture(); + + $result = $fixture->processId(-5, ''); + expect($result)->toBe(-5); + }); + }); +}); diff --git a/tests/TypeChecking/Boundaries/MagicPropertiesTest.php b/tests/TypeChecking/Boundaries/MagicPropertiesTest.php new file mode 100644 index 0000000..ed15793 --- /dev/null +++ b/tests/TypeChecking/Boundaries/MagicPropertiesTest.php @@ -0,0 +1,113 @@ +processId(42, 'Alice'))->toBe(42); + + 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') + ; + + 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") + ; + }); + + test('validates variadic arguments passed into dynamic static method', function () { + expect(MagicMethodFixture::fetchList(1, 2, 3))->toBe([1, 2, 3]); + + 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") + ; + }); + }); + + describe('Array Shapes & Lists in @method', function () { + test('validates list arguments and array shape returns on dynamic method', function () { + $fixture = new MagicMethodFixture(); + + $result = $fixture->buildPayload([10, 20], 'active'); + expect($result)->toBe(['id' => 10, 'tags' => ['php', 'typephp']]); + + 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') + ; + + expect(fn () => $fixture->buildPayload([10, 20], 'archived')) + ->toThrow(TypeError::class, "TypePHP\\Tests\\Fixtures\\Types\\MagicMethodFixture::buildPayload(): Argument \$status must be of type ('active' | 'pending')") + ; + }); + }); + + describe('Generics in @method', function () { + test('validates generic object instances passed to dynamic method', function () { + $fixture = new MagicMethodFixture(); + $dogProducer = new Producer(new Dog()); + + expect($fixture->getProducer($dogProducer))->toBe($dogProducer); + + $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') + ; + }); + }); + + describe('Intersections & Nullable Types in @method', function () { + test('validates intersection types and nullable null on dynamic method', function () { + $fixture = new MagicMethodFixture(); + + 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)') + ; + }); + }); + + describe('Type Aliases (@phpstan-type) in @method', function () { + test('resolves local class-level type aliases inside @method definitions', function () { + $fixture = new MagicMethodFixture(); + + $validUser = ['id' => 10, 'role' => 'admin']; + expect($fixture->saveUser($validUser))->toBe($validUser); + + $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')") + ; + }); + }); + + describe('Configuration Control', function () { + test('ignores magic method validation when magic_methods config is false', function () { + Config::set(['magic_methods' => false]); + + $fixture = new MagicMethodFixture(); + + $result = $fixture->processId(-5, ''); + expect($result)->toBe(-5); + }); + }); +}); diff --git a/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php b/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php new file mode 100644 index 0000000..66f0c9b --- /dev/null +++ b/tests/TypeChecking/Generics/ClassStringTemplateObjectBoundTest.php @@ -0,0 +1,62 @@ + $attributeClass + */ +function resolveObjectAttributeSim(string $class, string $attributeClass): bool +{ + return true; +} + +/** + * @template TMixed of mixed + * + * @param class-string $class + */ +function resolveMixedClassStringSim(string $class): bool +{ + return true; +} + +/** + * @template TDate of DateTimeInterface + * + * @param class-string $class + */ +function resolveBoundedClassStringSim(string $class): bool +{ + return true; +} + +describe('class-string Template Bounds', function () { + test('accepts class-string when template T is bounded by pseudo-type object', function () { + expect(resolveObjectAttributeSim(stdClass::class, stdClass::class))->toBeTrue(); + expect(resolveObjectAttributeSim(DateTime::class, DateTimeImmutable::class))->toBeTrue(); + }); + + test('accepts class-string when template T is bounded by pseudo-type mixed', function () { + expect(resolveMixedClassStringSim(stdClass::class))->toBeTrue(); + expect(resolveMixedClassStringSim(DateTime::class))->toBeTrue(); + }); + + test('accepts class-string matching concrete class or interface bound', function () { + expect(resolveBoundedClassStringSim(DateTimeImmutable::class))->toBeTrue(); + expect(resolveBoundedClassStringSim(DateTime::class))->toBeTrue(); + }); + + test('rejects non-existent class-string when template T is bounded by object', function () { + expect(fn () => resolveObjectAttributeSim(stdClass::class, 'NonExistentClass12345')) + ->toThrow(TypeError::class, 'must be a valid class-string') + ; + }); + + test('rejects class-string that does not implement specific interface bound', function () { + expect(fn () => resolveBoundedClassStringSim(stdClass::class)) + ->toThrow(TypeError::class, 'must be a class-string of DateTimeInterface') + ; + }); +}); diff --git a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php new file mode 100644 index 0000000..16d64c5 --- /dev/null +++ b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php @@ -0,0 +1,231 @@ + + * + * @param T $items + * + * @return T + */ +function testListBound(mixed $items): mixed +{ + return $items; +} + +/** + * @template T of int<1, 100> + * + * @param T $percentage + * + * @return T + */ +function testIntRangeBound(mixed $percentage): mixed +{ + return $percentage; +} + +/** + * @template T of 'active'|'pending' + * + * @param T $status + * + * @return T + */ +function testLiteralUnionBound(mixed $status): mixed +{ + return $status; +} + +/** + * @template T of Countable + * + * @param class-string $class + */ +function testClassStringInterfaceBound(string $class): bool +{ + return true; +} + +/** + * Default template with object upper bound (@template T of object = stdClass) + * + * @template T of object = stdClass + * + * @param mixed $value + * + * @return T + */ +function testDefaultObjectBound(mixed $value): mixed +{ + return $value; +} + +/** + * Default template with int-range upper bound (@template T of int<1, 100> = 50) + * + * @template T of int<1, 100> = 50 + * + * @param mixed $value + * + * @return T + */ +function testDefaultIntRangeBound(mixed $value): mixed +{ + return $value; +} + +/** + * Template where T is inferred from $input, overriding default stdClass + * + * @template T of object = stdClass + * + * @param T $input + * @param mixed $valueToReturn + * + * @return T + */ +function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed +{ + return $valueToReturn; +} + +describe('Generic Template Bounds Stress Test', function () { + test('validates positive-int scalar bound', function () { + expect(testPositiveIntBound(42))->toBe(42); + + expect(fn () => testPositiveIntBound(-10)) + ->toThrow(TypeError::class, 'positive-int') + ; + expect(fn () => testPositiveIntBound(0)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('validates non-empty-string scalar bound', function () { + expect(testNonEmptyStringBound('hello'))->toBe('hello'); + + expect(fn () => testNonEmptyStringBound('')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + + test('validates array shape template bound', function () { + $valid = ['id' => 10, 'role' => 'admin']; + expect(testArrayShapeBound($valid))->toBe($valid); + expect(fn () => testArrayShapeBound(['id' => -5, 'role' => 'admin'])) + ->toThrow(TypeError::class, "['id']") + ; + + expect(fn () => testArrayShapeBound(['id' => 10, 'role' => 'superadmin'])) + ->toThrow(TypeError::class, "['role']") + ; + + expect(fn () => testArrayShapeBound(['id' => 10])) + ->toThrow(TypeError::class, "missing required key 'role'") + ; + }); + + test('validates list template bound', function () { + expect(testListBound([10, 20, 30]))->toBe([10, 20, 30]); + + expect(fn () => testListBound([10, -5, 30])) + ->toThrow(TypeError::class, '[1]') + ; + + expect(fn () => testListBound(['key' => 10])) + ->toThrow(TypeError::class, 'must be a list') + ; + }); + + test('validates int range template bound', function () { + expect(testIntRangeBound(50))->toBe(50); + + expect(fn () => testIntRangeBound(150)) + ->toThrow(TypeError::class, '<= 100') + ; + expect(fn () => testIntRangeBound(0)) + ->toThrow(TypeError::class, '>= 1') + ; + }); + + test('validates literal union enum template bound', function () { + expect(testLiteralUnionBound('active'))->toBe('active'); + expect(testLiteralUnionBound('pending'))->toBe('pending'); + + expect(fn () => testLiteralUnionBound('archived')) + ->toThrow(TypeError::class, "('active' | 'pending')") + ; + }); + + test('validates class-string interface bound', function () { + expect(testClassStringInterfaceBound(ArrayObject::class))->toBeTrue(); + + expect(fn () => testClassStringInterfaceBound(stdClass::class)) + ->toThrow(TypeError::class, 'must be a class-string of Countable') + ; + }); + + test('uses default template type when template T is unbound', function () { + $std = new stdClass(); + expect(testDefaultObjectBound($std))->toBe($std); + + expect(fn () => testDefaultObjectBound(new DateTime())) + ->toThrow(TypeError::class, 'Return value') + ; + }); + + test('uses default scalar literal type when template T is unbound', function () { + expect(testDefaultIntRangeBound(50))->toBe(50); + + expect(fn () => testDefaultIntRangeBound(99)) + ->toThrow(TypeError::class, 'Return value') + ; + }); + + test('inferred template parameter from argument overrides default template type', function () { + $dt = new DateTime(); + + expect(testInferredOverridesDefault($dt, $dt))->toBe($dt); + + expect(fn () => testInferredOverridesDefault($dt, new stdClass())) + ->toThrow(TypeError::class, 'Return value'); + }); +});