diff --git a/docs/advanced/how-it-works.md b/docs/advanced/how-it-works.md index ec33b90..ee5558c 100644 --- a/docs/advanced/how-it-works.md +++ b/docs/advanced/how-it-works.md @@ -38,6 +38,14 @@ Whenever your application loads a PHP file via `require`, `include`, or Composer --- +## Project Root Resolution & Web Server Independence + +To ensure consistent configuration loading across CLI commands, test runners (Pest, PHPUnit), and production web servers (where `getcwd()` points to `public/`), `Config::getProjectRoot()` searches upwards from the library's directory to locate `vendor/autoload.php` or `composer.json`. + +Once located, the project root path is memoized in static memory. All relative `include`, `exclude`, and `cache_dir` configuration globs resolve reliably against the true application root directory across all PHP SAPIs (`cli`, `fpm`, `frankenphp`, `swoole`). + +--- + ## Stream Interception TypePHP registers a custom stream wrapper for PHP's native `file://` protocol using `stream_wrapper_register()`. @@ -83,6 +91,18 @@ If the file is included, TypePHP parses the source code into an AST using `nikic --- +## Tooling Annotation Normalization & Tag Priority Hierarchy + +Third-party packages often define both broad IDE docblocks and strict tool-specific contracts on the same signature (such as `@param mixed $element` alongside `@phpstan-param T $element` in Doctrine Collections). + +`DocblockExtractor` normalizes and evaluates tag definitions using a **3-Tier Priority System**: + +1. **Tool-Specific Annotations Take Precedence:** `@phpstan-param` and `@psalm-param` override `@param`; `@phpstan-return` and `@psalm-return` override `@return`; `@phpstan-var` overrides `@var`. +2. **Inherited Template Extraction:** Collects class, interface, and trait template mappings across all recognized variations (`@extends`, `@template-extends`, `@phpstan-extends`, `@psalm-extends`, `@implements`, `@template-implements`, `@use`, `@template-use`). +3. **Variance Modifiers:** Extracts class-level `@template-covariant` and `@template-contravariant` tags to configure the runtime variance engine. + +--- + ## Zero Line-Drift Formatting and Caching A common issue with AST code injection is that adding new statements pushes subsequent code down, causing line numbers in error stack traces to drift. @@ -226,3 +246,4 @@ TypePHP gives you granular control so you can choose where and when to pay the p * **Selective Path Whitelisting:** Type-check only mission-critical domain logic (`app/Domain/**`) while bypassing non-critical files completely. * **Granular Toggles:** Turn off array checking (`inline_vars.arrays => false`) or scalar checking (`inline_vars.scalars => false`) on high-frequency internal loops while maintaining strict parameter and return boundaries (`params => true`, `returns => true`). * **Environment Master Switch:** Disable TypePHP completely in environment builds (`enabled => false`) for 100% un-transformed, native PHP execution speed. +``` \ No newline at end of file diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md index 57e0ee1..ce1e633 100644 --- a/docs/core-concepts/function-contracts.md +++ b/docs/core-concepts/function-contracts.md @@ -37,6 +37,39 @@ registerUser(-5, 'Alice', 'admin'); --- +## Tooling Annotation Priority Hierarchy (`@phpstan-*` > `@psalm-*` > `@*`) + +Modern PHP packages and frameworks (such as **Doctrine Collections**, **Symfony**, and **Laravel**) frequently declare both broad IDE-fallback annotations and strict static analysis contracts on the exact same method signature: + +```php +/** + * @param mixed $element // Broad fallback for standard IDEs + * @phpstan-param positive-int $element // Refined contract for static analyzers + * + * @return mixed + * @phpstan-return list + */ +public function add(mixed $element): mixed; +``` + +When multiple tool annotations are declared on the same parameter or return value, TypePHP resolves the active contract using a deterministic **3-Tier Priority Hierarchy**: + +$$\text{1. } \mathbf{@phpstan\text{-}*} \quad \longrightarrow \quad \text{2. } \mathbf{@psalm\text{-}*} \quad \longrightarrow \quad \text{3. } \mathbf{@* \text{ (Standard)}}$$ + +### Why Priority Matters in Real-World Codebases + +1. **Refined Contracts Take Precedence:** Tool-specific annotations (`@phpstan-param`, `@psalm-return`) contain specific type constraints (such as generic templates, array shapes, or integer bounds) that standard `@param mixed` omits. TypePHP always enforces the tighter, intended contract. +2. **Third-Party Framework Compatibility:** Libraries like Doctrine Collections declare `@phpstan-param T $element` on `Collection::add` alongside native `mixed $element`. TypePHP automatically prioritizes `@phpstan-param`, making generic collections enforce types at runtime without manual wrapper code. + +### Tooling Priority Matrix Across Boundary Contracts + +| Boundary Type | Priority 1 (Highest) | Priority 2 | Priority 3 (Fallback) | +| :--- | :--- | :--- | :--- | +| **Parameters** | `@phpstan-param` | `@psalm-param` | `@param` | +| **Return Values** | `@phpstan-return` | `@psalm-return` | `@return` | + +--- + ## PHP 8.0+ Named Arguments TypePHP natively supports PHP 8.0+ Named Arguments. Because parameter contracts are mapped by parameter name rather than argument position index, you can pass named arguments in any order, and TypePHP will accurately validate each parameter: @@ -257,7 +290,7 @@ TypePHP fully validates class constructor arguments, supporting both standard co ### Promoted Properties (PHP 8.0+) -Annotate promoted properties in the constructor's docblock using standard `@param` tags: +Annotate promoted properties in the constructor's docblock using standard `@param` or `@phpstan-param` tags: ```php class Order @@ -284,7 +317,7 @@ new Order(-1, 'SKU-99', 5); ### Property `@var` Fallback for Un-Annotated Constructors -If a constructor parameter is un-annotated (or lacks a `@param` tag), TypePHP automatically inspects the corresponding class property's `@var` docblock to infer the parameter contract: +If a constructor parameter is un-annotated (or lacks a `@param` tag), TypePHP automatically inspects the corresponding class property's `@var` / `@phpstan-var` docblock to infer the parameter contract: ```php class User @@ -329,8 +362,6 @@ getUserStatus(-10); // Throws: TypeError: getUserStatus(): Return value['id'] must be of type positive-int ``` -> **PHPStan and Psalm Compatibility:** TypePHP also recognizes `@phpstan-param`, `@phpstan-return`, `@psalm-param`, and `@psalm-return` annotations. - --- ## Fluent `$this` Identity Returns diff --git a/docs/generics/generics-and-bounds.md b/docs/generics/generics-and-bounds.md index 08d63ab..02f04d8 100644 --- a/docs/generics/generics-and-bounds.md +++ b/docs/generics/generics-and-bounds.md @@ -74,6 +74,43 @@ collectSameType(10, 20, 'invalid'); --- +## Tooling Template Priority Hierarchy (`@phpstan-template-*` > `@psalm-template-*` > `@template-*`) + +When third-party packages or framework classes declare both general IDE template docblocks and strict tool-specific annotations on the same class, TypePHP resolves the active generic contract using a deterministic **3-Tier Priority Hierarchy**: + +$$\begin{aligned} +\mathbf{Priority\ 1\ (Highest):} & \quad \text{@phpstan-template-covariant} \ > \ \text{@phpstan-template-contravariant} \ > \ \text{@phpstan-template} \\ +\mathbf{Priority\ 2:} & \quad \text{@psalm-template-covariant} \ > \ \text{@psalm-template-contravariant} \ > \ \text{@psalm-template} \\ +\mathbf{Priority\ 3\ (Base):} & \quad \text{@template-covariant} \ > \ \text{@template-contravariant} \ > \ \text{@template} +\end{aligned}$$ + +### Why Priority Matters for Templates + +Authors often write a broad `@template T` for generic IDE docblocks, and then declare `@phpstan-template T of Animal` to specify strict upper bounds for static analyzers. TypePHP always extracts the tool-specific annotation so that runtime bound enforcement matches the author's intended contract: + +```php +/** + * Standard tag has no bound, but @phpstan-template enforces Animal bound: + * + * @template T + * @phpstan-template T of Animal + * @phpstan-template-covariant T + */ +class BoundedProducer +{ + public function __construct(public mixed $item) {} +} + +// Valid: Dog extends Animal +new BoundedProducer(new Dog()); + +// Invalid: Car does not extend Animal +new BoundedProducer(new Car()); +// Throws: TypeError: BoundedProducer::__construct(): Argument $item (template T) must be of type Animal, Car given +``` + +--- + ## Multiple Generic Templates (`@template T`, `@template U`) Functions and classes are not limited to a single template parameter. You can declare multiple independent generic templates (such as `T`, `U`, `K`, `V`): @@ -163,10 +200,10 @@ $users = new Collection(); /** @var Dictionary $catalog */ $catalog = new Dictionary(); -// Single-Template Smart Fallback (No template name needed!) +// Single-Template Smart Fallback (No template name needed!) $userType = TypePHP::getGenericType(object: $users); // Returns 'App\Models\User' -// Multi-Template Explicit Inspection +// Multi-Template Explicit Inspection $keyType = TypePHP::getGenericType(object: $catalog, template: 'K'); // Returns 'string' $valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Returns 'App\Models\Product' @@ -174,13 +211,13 @@ $valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Return $userRepo = new UserRepository(); $repoType = TypePHP::getGenericType(object: $userRepo); // Returns 'App\Models\User' -// Inspect all bound template parameters as an array +// Inspect all bound template parameters as an array $types = TypePHP::getGenericTypes(object: $catalog); // Returns ['K' => 'string', 'V' => 'App\Models\Product'] -// Inspect Declared Variance ('covariant', 'contravariant', or 'invariant') +// Inspect Declared Variance ('covariant', 'contravariant', or 'invariant') $variance = TypePHP::getGenericVariance(object: $producer); // Returns 'covariant' -// Inspect All Bound Variances as Arrays +// Inspect All Bound Variances as Arrays $variances = TypePHP::getGenericVariances(object: $producer); // Returns ['T' => 'covariant'] ``` @@ -626,9 +663,17 @@ $producers->add(new Producer(new Car())); --- -## Class Inheritance (`@extends` and `@implements`) +## Class, Interface, & Trait Inheritance (`@extends`, `@implements`, `@use`) + +When a child class extends a generic parent class, implements a generic interface, or uses a generic trait, declare the template mapping using any of the recognized inherited template annotations: + +| Inheritance Context | Supported Tag Variations | +| :--- | :--- | +| **Class Inheritance** | `@extends`, `@template-extends`, `@phpstan-extends`, `@psalm-extends` | +| **Interface Implementation** | `@implements`, `@template-implements`, `@phpstan-implements`, `@psalm-implements` | +| **Trait Usage** | `@use`, `@template-use`, `@phpstan-use` | -When a child class extends a generic parent class or implements a generic interface, declare the template mapping using `@extends` or `@implements` (also recognized as `@template-extends` and `@template-implements`): +### 1. Interface Implementation (`@implements` / `@template-implements`) ```php /** @@ -646,9 +691,9 @@ interface ProcessorInterface } /** - * Fulfills T = Cat via @implements + * Fulfills T = Cat via @template-implements * - * @implements ProcessorInterface + * @template-implements ProcessorInterface */ class CatProcessor implements ProcessorInterface { @@ -668,6 +713,106 @@ $processor->process(new Dog()); // Throws: TypeError: CatProcessor::process(): Argument $item (template T = Cat) must be of type Cat ``` +### 2. Class Extension (`@extends` / `@template-extends`) + +```php +/** + * @template T + */ +abstract class BaseRepository +{ + /** + * @param T $entity + */ + public function save(mixed $entity): void + { + // ... + } +} + +/** + * Fulfills T = User via @template-extends + * + * @template-extends BaseRepository + */ +class UserRepository extends BaseRepository +{ +} + +$userRepo = new UserRepository(); + +// Valid Save +$userRepo->save(new User('Alice')); + +// Invalid Save +$userRepo->save(new Product('SKU-100')); +// Throws: TypeError: UserRepository::save(): Argument $entity (template T = User) must be of type User +``` + +### 3. Generic Traits (`@use` / `@template-use` / `@phpstan-use`) + +When a class uses a generic Trait, declare the template binding either at the **class level** or **directly above the inline `use Trait;` statement**: + +#### Generic Trait Definition (`ItemLoggerTrait.php`) + +```php +/** + * @template T + */ +trait ItemLoggerTrait +{ + /** + * @param T $item + */ + public function logItem(mixed $item): bool + { + return true; + } +} +``` + +#### Option A: Class-Level Trait Annotation (`@use` / `@template-use`) + +```php +/** + * Class docblock binds T = Dog for the trait + * + * @use ItemLoggerTrait + */ +class ClassLevelLogService +{ + use ItemLoggerTrait; +} + +$service = new ClassLevelLogService(); + +$service->logItem(new Dog()); // Valid + +$service->logItem(new Car()); +// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given +``` + +#### Option B: Inline Statement Trait Annotation (`/** @use */ use Trait;`) + +```php +class InlineLogService +{ + /** + * Inline statement docblock binds T = Dog + * + * @use ItemLoggerTrait + */ + use ItemLoggerTrait; +} + +$service = new InlineLogService(); + +$service->logItem(new Dog()); // Valid + +$service->logItem(new Car()); +// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given +``` + --- ## Real-World Example 1: Generic Collections (`Collection`) @@ -722,7 +867,7 @@ $users->add(new Product('SKU-999')); ## Real-World Example 2: Generic Repositories (`Repository`) -When a class extends a generic parent class (`@extends BaseRepository`), TypePHP automatically resolves and inherits the parent's generic template bindings: +When a class extends a generic parent class (`@extends BaseRepository` or `@template-extends BaseRepository`), TypePHP automatically resolves and inherits the parent's generic template bindings: ```php namespace App\Repositories; @@ -744,9 +889,9 @@ abstract class BaseRepository } /** - * Fulfills T = User via @extends + * Fulfills T = User via @template-extends * - * @extends BaseRepository + * @template-extends BaseRepository */ class UserRepository extends BaseRepository { @@ -951,3 +1096,4 @@ processCovariantConsumer(new Consumer(new Dog())); processCovariantConsumer(new Consumer(new Car())); // Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given ``` +``` \ No newline at end of file diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 0914f8f..9dd4af2 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -189,7 +189,7 @@ public static function parseProperty(string $className, string $propertyName): ? if (! $isMagicProperty) { $phpDocNode = DocblockExtractor::parseDocString($doc); - $varTags = $phpDocNode->getVarTagValues(); + $varTags = DocblockExtractor::getVarTags($phpDocNode); if (\count($varTags) === 0) { return self::$propertyCache[$cacheKey] = null; @@ -420,8 +420,7 @@ private static function parseFunction(\ReflectionFunction $ref): array $baseParamVariadic[$p->getName()] = $p->isVariadic(); } - foreach ($phpDocNode->getParamTagValues() as $paramTag) { - $paramName = ltrim($paramTag->parameterName, '$'); + foreach (DocblockExtractor::getParamTags($phpDocNode) as $paramName => $paramTag) { $type = $paramTag->type; $isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$paramName] ?? false); if ($isVariadic) { @@ -431,9 +430,9 @@ private static function parseFunction(\ReflectionFunction $ref): array $types[$paramName] = SpecialTypeResolver::resolve($substitutedType, $ref); } - $returnTags = $phpDocNode->getReturnTagValues(); - if (\count($returnTags) > 0) { - $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases); + $returnTag = DocblockExtractor::getReturnTag($phpDocNode); + if ($returnTag !== null) { + $substitutedReturn = self::substituteAliases($returnTag->type, $aliases); $returnType = SpecialTypeResolver::resolve($substitutedReturn, $ref); } @@ -478,6 +477,7 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar /** * Resolves method-level docblocks (@param, @return, @template, aliases) up the method hierarchy. + * Prioritizes @phpstan-param and @psalm-param over standard @param tags. * * @param \ReflectionMethod $ref * @param array $types @@ -532,9 +532,9 @@ private static function parseMethodHierarchyDocs( $hierNameToIndex[$p->getName()] = $idx; } - foreach ($phpDocNode->getParamTagValues() as $paramTag) { - $paramName = ltrim($paramTag->parameterName, '$'); + $paramTags = DocblockExtractor::getParamTags($phpDocNode); + foreach ($paramTags as $paramName => $paramTag) { if (isset($baseParamSet[$paramName])) { $targetParamName = $paramName; } else { @@ -563,9 +563,9 @@ private static function parseMethodHierarchyDocs( } if ($returnType === null) { - $returnTags = $phpDocNode->getReturnTagValues(); - if (\count($returnTags) > 0) { - $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases); + $returnTag = DocblockExtractor::getReturnTag($phpDocNode); + if ($returnTag !== null) { + $substitutedReturn = self::substituteAliases($returnTag->type, $aliases); $returnType = SpecialTypeResolver::resolve($substitutedReturn, $hierRef); } } @@ -707,4 +707,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} \ No newline at end of file +} diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index fd2e505..0e8116a 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -5,8 +5,11 @@ namespace TypePHP\Contract; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\ConstExprParser; @@ -59,16 +62,171 @@ public static function parseDocString(string $doc): PhpDocNode } /** - * Extracts all @template tag values from a parsed PHPDoc node. + * Extracts parameter tags with priority: @phpstan-param > @psalm-param > @param. + * + * @return array + */ + public static function getParamTags(PhpDocNode $node): array + { + $tags = []; + + foreach ($node->getParamTagValues('@param') as $tag) { + $pName = ltrim($tag->parameterName, '$'); + $tags[$pName] = $tag; + } + + foreach ($node->getParamTagValues('@psalm-param') as $tag) { + $pName = ltrim($tag->parameterName, '$'); + $tags[$pName] = $tag; + } + + foreach ($node->getParamTagValues('@phpstan-param') as $tag) { + $pName = ltrim($tag->parameterName, '$'); + $tags[$pName] = $tag; + } + + return $tags; + } + + /** + * Extracts return tag with priority: @phpstan-return > @psalm-return > @return. + */ + public static function getReturnTag(PhpDocNode $node): ?ReturnTagValueNode + { + $phpstanReturns = $node->getReturnTagValues('@phpstan-return'); + if (\count($phpstanReturns) > 0) { + return $phpstanReturns[0]; + } + + $psalmReturns = $node->getReturnTagValues('@psalm-return'); + if (\count($psalmReturns) > 0) { + return $psalmReturns[0]; + } + + $returns = $node->getReturnTagValues('@return'); + if (\count($returns) > 0) { + return $returns[0]; + } + + return null; + } + + /** + * Extracts @var tags with priority: @phpstan-var > @psalm-var > @var. + * + * @return array + */ + public static function getVarTags(PhpDocNode $node): array + { + $phpstanVars = $node->getVarTagValues('@phpstan-var'); + if (\count($phpstanVars) > 0) { + return $phpstanVars; + } + + $psalmVars = $node->getVarTagValues('@psalm-var'); + if (\count($psalmVars) > 0) { + return $psalmVars; + } + + return $node->getVarTagValues('@var'); + } + + /** + * Extracts all @template tags with priority: @phpstan-template-* > @psalm-template-* > @template-*. * * @return array */ public static function extractTemplates(PhpDocNode $node): array + { + $templates = []; + + $priorityGroups = [ + ['@template', '@template-covariant', '@template-contravariant'], + ['@psalm-template', '@psalm-template-covariant', '@psalm-template-contravariant'], + ['@phpstan-template', '@phpstan-template-covariant', '@phpstan-template-contravariant'], + ]; + + foreach ($priorityGroups as $tagNames) { + foreach ($tagNames as $tagName) { + foreach ($node->getTagsByName($tagName) as $tagNode) { + if ($tagNode->value instanceof TemplateTagValueNode) { + $templates[$tagNode->value->name] = $tagNode->value; + } + } + } + } + + return $templates; + } + + /** + * Extracts declared template variances ('covariant', 'contravariant', or 'invariant') per template name. + * + * @return array + */ + public static function extractTemplateVariances(PhpDocNode $node): array + { + $variances = []; + + $priorityGroups = [ + ['@template', '@template-covariant', '@template-contravariant'], + ['@psalm-template', '@psalm-template-covariant', '@psalm-template-contravariant'], + ['@phpstan-template', '@phpstan-template-covariant', '@phpstan-template-contravariant'], + ]; + + foreach ($priorityGroups as $tagNames) { + foreach ($tagNames as $tagName) { + foreach ($node->getTagsByName($tagName) as $tagNode) { + if ($tagNode->value instanceof TemplateTagValueNode) { + $tName = $tagNode->value->name; + $lowerTag = strtolower($tagNode->name); + + if (str_contains($lowerTag, 'covariant')) { + $variances[$tName] = 'covariant'; + } elseif (str_contains($lowerTag, 'contravariant')) { + $variances[$tName] = 'contravariant'; + } else { + $variances[$tName] = 'invariant'; + } + } + } + } + } + + return $variances; + } + + /** + * Extracts all inherited template type tags (@extends, @implements, @use and their @template-*, @phpstan-*, @psalm-* variants). + * + * @return array + */ + public static function getInheritedTags(PhpDocNode $node): array { $tags = []; - foreach ($node->getTags() as $tagNode) { - if ($tagNode->value instanceof TemplateTagValueNode) { - $tags[$tagNode->value->name] = $tagNode->value; + $tagNames = [ + '@extends', + '@template-extends', + '@phpstan-extends', + '@psalm-extends', + '@implements', + '@template-implements', + '@phpstan-implements', + '@psalm-implements', + '@use', + '@template-use', + '@phpstan-use', + ]; + + foreach ($tagNames as $name) { + foreach ($node->getTagsByName($name) as $tagNode) { + if ( + $tagNode->value instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ExtendsTagValueNode || + $tagNode->value instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ImplementsTagValueNode || + $tagNode->value instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\UsesTagValueNode + ) { + $tags[] = $tagNode->value; + } } } @@ -76,22 +234,21 @@ public static function extractTemplates(PhpDocNode $node): array } /** - * Extracts a TypeNode from a property's @var or @param docblock. + * Extracts a TypeNode from a property's @var, @param, @phpstan-param, or @psalm-param docblock. */ public static function extractTypeFromPropertyDoc(string $doc, string $propName): ?TypeNode { try { $phpDocNode = self::parseDocString($doc); - foreach ($phpDocNode->getVarTagValues() as $varTag) { + foreach (self::getVarTags($phpDocNode) as $varTag) { $tagVarName = ltrim($varTag->variableName, '$'); if ($tagVarName === '' || $tagVarName === $propName) { return $varTag->type; } } - foreach ($phpDocNode->getParamTagValues() as $paramTag) { - $tagParamName = ltrim($paramTag->parameterName, '$'); + foreach (self::getParamTags($phpDocNode) as $tagParamName => $paramTag) { if ($tagParamName === '' || $tagParamName === $propName) { return $paramTag->type; } @@ -112,7 +269,7 @@ public static function extractVarTagFromDoc(string $doc): ?array { try { $phpDocNode = self::parseDocString($doc); - $varTags = $phpDocNode->getVarTagValues(); + $varTags = self::getVarTags($phpDocNode); if (\count($varTags) > 0) { $typeString = (string) $varTags[0]->type; $varName = ltrim($varTags[0]->variableName, '$'); @@ -154,7 +311,6 @@ public static function extractAliases( } } - // Expand nested/imported alias references inside extracted local aliases foreach ($aliases as $name => $type) { $aliases[$name] = ContractParser::substituteAliases($type, $aliases); } @@ -249,4 +405,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam return null; } -} \ No newline at end of file +} diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index a1b6d54..78f4248 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -40,8 +40,7 @@ public static function isFileExcluded(string|false|null $fileName): bool /** @var array $excludes */ $excludes = \is_array($config['exclude'] ?? null) ? $config['exclude'] : ['vendor/**', 'storage/**', 'var/**', 'cache/**']; - $cwd = getcwd(); - $baseDir = $cwd !== false ? rtrim(str_replace('\\', '/', $cwd), '/') : ''; + $baseDir = Config::getProjectRoot(); $longestIncludeMatch = 0; foreach ($includes as $pattern) { diff --git a/src/Contract/HierarchyResolver.php b/src/Contract/HierarchyResolver.php index 703fb2d..e14b714 100644 --- a/src/Contract/HierarchyResolver.php +++ b/src/Contract/HierarchyResolver.php @@ -164,4 +164,4 @@ public static function getClassHierarchy(ReflectionClass $ref): array return self::$classHierarchyCache[$cacheKey] = $hierarchy; } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index fd01bc9..6ddc7a0 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -102,4 +102,4 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty return $value; } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 9a1268f..ac0a50a 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -309,4 +309,4 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return (bool) ($config['scalars'] ?? false); } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index daed729..0f9df53 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -377,4 +377,4 @@ private static function resolveTemplateParam(TypeNode $typeNode, mixed $val, str return null; } -} \ No newline at end of file +} diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index f628541..e48be82 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -228,4 +228,4 @@ private static function resolveConditionalReturnType( return $returnTypeNode; } -} \ No newline at end of file +} diff --git a/src/Internal/Config.php b/src/Internal/Config.php index af89a5f..90f6e55 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -20,6 +20,55 @@ final class Config */ private static ?array $cachedConfig = null; + /** + * Cached absolute project root path. + */ + private static ?string $projectRoot = null; + + /** + * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. + * Caches the result in memory so the search happens exactly once. + */ + public static function getProjectRoot(): string + { + if (self::$projectRoot !== null) { + return self::$projectRoot; + } + + // Search upwards from this file (vendor/typephp/typephp/src/Internal -> project root) + $dir = __DIR__; + for ($i = 0; $i < 10; $i++) { + if (file_exists($dir . '/vendor/autoload.php')) { + return self::$projectRoot = rtrim(str_replace('\\', '/', $dir), '/'); + } + + $parent = \dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; + } + + // Fallback: Search upwards from getcwd() (for monorepos or test runners) + $cwd = getcwd(); + if ($cwd !== false) { + $dir = $cwd; + for ($i = 0; $i < 10; $i++) { + if (file_exists($dir . '/vendor/autoload.php') || file_exists($dir . '/composer.json') || file_exists($dir . '/typephp.php')) { + return self::$projectRoot = rtrim(str_replace('\\', '/', $dir), '/'); + } + + $parent = \dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; + } + } + + return self::$projectRoot = rtrim(str_replace('\\', '/', $cwd !== false ? $cwd : '.'), '/'); + } + /** * Loads and caches global configuration from 'typephp.php', explicitly registered extensions, and base defaults. * @@ -39,6 +88,7 @@ public static function get(): array 'magic_methods' => true, 'respect_ignore_tags' => true, 'cache' => true, + 'cache_dir' => null, 'inline_vars' => [ 'properties' => true, 'generics' => true, @@ -52,11 +102,11 @@ public static function get(): array 'extensions' => [], ]; - $cwd = getcwd(); - $configFile = $cwd !== false ? $cwd . '/typephp.php' : ''; + $projectRoot = self::getProjectRoot(); + $configFile = $projectRoot . '/typephp.php'; $userConfig = []; - if ($configFile !== '' && file_exists($configFile)) { + if (file_exists($configFile)) { $loadedConfig = require $configFile; if (\is_array($loadedConfig)) { /** @var array $userConfig */ @@ -96,6 +146,7 @@ public static function set(array $config): void public static function reset(): void { self::$cachedConfig = null; + self::$projectRoot = null; ContractParser::reset(); } diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 1598b44..17c469a 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -228,4 +228,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} \ No newline at end of file +} diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 9a69056..913e5e6 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -61,9 +61,7 @@ public static function register(array $config = []): void $resolvedConfig = array_replace_recursive(Config::get(), $config); if (! self::$isInitialized || \count($config) > 0) { - $cwd = getcwd(); - $base = $cwd !== false ? $cwd : ''; - self::$baseDir = rtrim(str_replace('\\', '/', $base), '/'); + self::$baseDir = Config::getProjectRoot(); /** @var array $includes */ $includes = \is_array($resolvedConfig['include'] ?? null) ? $resolvedConfig['include'] : ['**']; @@ -505,8 +503,8 @@ private static function isReadOnlyCall(): bool } /** - * Determines whether a target PHP file path should be intercepted using Pattern Specificity. - */ + * Determines whether a target PHP file path should be intercepted using Pattern Specificity. + */ private static function isApplicationFile(string $path, string|false $resolvedPath): bool { if (! (bool) (Config::get()['enabled'] ?? true)) { @@ -601,7 +599,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool } /** - * Scans top-level AST statements for namespace and use import declarations to seed SpecialTypeResolver. + * Scans top-level AST statements for namespace, use imports, and trait use declarations to seed SpecialTypeResolver. * * @param array<\PhpParser\Node\Stmt> $stmts */ @@ -613,6 +611,7 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat $namespace = ''; $imports = []; + $classTraitUseDocs = []; $nodesToScan = $stmts; foreach ($stmts as $stmt) { @@ -647,9 +646,19 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat $alias = $use->getAlias()->toString(); $imports[$alias] = $fqcn; } + } elseif ($stmt instanceof \PhpParser\Node\Stmt\Class_ && $stmt->name !== null) { + $className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString(); + foreach ($stmt->stmts as $classStmt) { + if ($classStmt instanceof \PhpParser\Node\Stmt\TraitUse) { + $doc = $classStmt->getDocComment(); + if ($doc !== null) { + $classTraitUseDocs[$className][] = $doc->getText(); + } + } + } } } - SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports); + SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index e3f8af8..d8b800a 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -74,7 +74,7 @@ private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $n return false; } - $visitor = new class() extends NodeVisitorAbstract { + $visitor = new class () extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -217,8 +217,10 @@ private static function buildParamInjections( private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) {} + $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) + { + } public function enterNode(Node $n): int|Node|null { @@ -342,11 +344,12 @@ public function enterNode(Node $n): int|Node|null private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg, $isNativeVoid) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid) extends NodeVisitorAbstract { public function __construct( private Node\Expr $thisArg, private bool $isNativeVoid - ) {} + ) { + } public function enterNode(Node $n): int|array|null { diff --git a/src/Internal/Visitor/ScopeManager.php b/src/Internal/Visitor/ScopeManager.php index dc7c2f0..d6b3187 100644 --- a/src/Internal/Visitor/ScopeManager.php +++ b/src/Internal/Visitor/ScopeManager.php @@ -40,6 +40,7 @@ public function popScope(): void /** * Extracts all @var tags from a docblock comment and registers them in the current scope frame. + * Prioritizes @phpstan-var > @psalm-var > @var. */ public function extractVarDocblock(string $docText, ?Node\Expr $expr = null): void { @@ -49,7 +50,7 @@ public function extractVarDocblock(string $docText, ?Node\Expr $expr = null): vo $tokens = new TokenIterator($lexer->tokenize($docText)); $phpDocNode = $phpDocParser->parse($tokens); - $varTags = $phpDocNode->getVarTagValues(); + $varTags = DocblockExtractor::getVarTags($phpDocNode); foreach ($varTags as $varTag) { $typeString = (string) $varTag->type; diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index c85f804..bb3b5f9 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -52,6 +52,13 @@ final class SpecialTypeResolver */ private static array $fileNamespaces = []; + /** + * In-memory cache of class trait use statement docblocks keyed by class FQCN. + * + * @var array> + */ + private static array $classTraitUseDocs = []; + /** * Validates strict object identity ($value === $thisObj) when the return type node specifies $this. */ @@ -692,19 +699,54 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName) } /** - * Seeds the in-memory cache directly from StreamWrapper to prevent double file reads and re-parsing. + * Seeds file metadata directly from StreamWrapper. * * @param array $imports + * @param array> $classTraitUseDocs */ - public static function seedFileMetadata(string $fileName, string $namespace, array $imports): void + public static function seedFileMetadata(string $fileName, string $namespace, array $imports, array $classTraitUseDocs = []): void { if ($fileName !== '') { $fileName = str_replace('\\', '/', $fileName); self::$fileNamespaces[$fileName] = $namespace; self::$fileUseImports[$fileName] = $imports; + foreach ($classTraitUseDocs as $className => $docs) { + self::$classTraitUseDocs[$className] = $docs; + } } } + /** + * Returns all inline trait use statement docblock strings declared inside a class. + * + * @return array + */ + public static function getClassTraitUseDocs(string $className): array + { + if (isset(self::$classTraitUseDocs[$className])) { + return self::$classTraitUseDocs[$className]; + } + + if (! class_exists($className) && ! trait_exists($className)) { + return self::$classTraitUseDocs[$className] = []; + } + + try { + $ref = new \ReflectionClass($className); + $fileName = $ref->getFileName(); + if ($fileName !== false && file_exists($fileName)) { + $source = file_get_contents($fileName); + if ($source !== false) { + self::parseFileMetadata($fileName, $source); + } + } + } catch (\Throwable $e) { + // Silently ignore reflection errors + } + + return self::$classTraitUseDocs[$className] ?? []; + } + /** * Returns use imports for the declaring file of a Reflection object. * @@ -939,7 +981,7 @@ private static function isBuiltInTypeKeyword(string $name): bool } /** - * Parses the AST of a PHP file once to extract both namespace and use import statements. + * Parses the AST of a PHP file once to extract namespace, use imports, and class trait use docblocks. */ private static function parseFileMetadata(string $fileName, string $source): void { @@ -995,6 +1037,16 @@ private static function parseFileMetadata(string $fileName, string $source): voi $alias = $use->getAlias()->toString(); $imports[$alias] = $fqcn; } + } elseif ($stmt instanceof Stmt\Class_ && $stmt->name !== null) { + $className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString(); + foreach ($stmt->stmts as $classStmt) { + if ($classStmt instanceof Stmt\TraitUse) { + $doc = $classStmt->getDocComment(); + if ($doc !== null) { + self::$classTraitUseDocs[$className][] = $doc->getText(); + } + } + } } } diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 9b7233f..f3fb4a9 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -18,6 +18,8 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; +use TypePHP\Contract\DocblockExtractor; +use TypePHP\Contract\FileFilter; use TypePHP\Contract\HierarchyResolver; use TypePHP\Internal\ClassNameValidator; use TypePHP\Internal\ErrorFactory; @@ -160,22 +162,7 @@ public static function getTemplateVariances(object $instance): array $classTokens = new TokenIterator($lexer->tokenize($classDoc)); $classPhpDocNode = $phpDocParser->parse($classTokens); - $variances = []; - foreach ($classPhpDocNode->getTags() as $tagNode) { - if ($tagNode->value instanceof TemplateTagValueNode) { - $tagName = strtolower($tagNode->name); - - if (str_contains($tagName, 'covariant')) { - $variances[$tagNode->value->name] = 'covariant'; - } elseif (str_contains($tagName, 'contravariant')) { - $variances[$tagNode->value->name] = 'contravariant'; - } else { - $variances[$tagNode->value->name] = 'invariant'; - } - } - } - - return $variances; + return DocblockExtractor::extractTemplateVariances($classPhpDocNode); } } catch (\Throwable $e) { // Silently ignore reflection errors @@ -286,28 +273,24 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t $templates = []; $classVariances = []; - // Collect template parameters across the entire class/interface hierarchy! + // Collect template parameters across the entire class/interface hierarchy with priority! foreach ($classHierarchy as $hierClass) { $classDoc = $hierClass->getDocComment(); if ($classDoc !== false) { $classTokens = new TokenIterator($lexer->tokenize($classDoc)); $classPhpDocNode = $phpDocParser->parse($classTokens); - foreach ($classPhpDocNode->getTags() as $tagNode) { - if ($tagNode->value instanceof TemplateTagValueNode) { - $tName = $tagNode->value->name; - if (! isset($templates[$tName])) { - $templates[$tName] = $tagNode->value; - $tagName = strtolower($tagNode->name); - - if (str_contains($tagName, 'covariant')) { - $classVariances[$tName] = GenericTypeNode::VARIANCE_COVARIANT; - } elseif (str_contains($tagName, 'contravariant')) { - $classVariances[$tName] = GenericTypeNode::VARIANCE_CONTRAVARIANT; - } else { - $classVariances[$tName] = GenericTypeNode::VARIANCE_INVARIANT; - } - } + $hierTemplates = DocblockExtractor::extractTemplates($classPhpDocNode); + $hierVariances = DocblockExtractor::extractTemplateVariances($classPhpDocNode); + + foreach ($hierTemplates as $tName => $tagNode) { + if (! isset($templates[$tName])) { + $templates[$tName] = $tagNode; + $classVariances[$tName] = match ($hierVariances[$tName] ?? 'invariant') { + 'covariant' => GenericTypeNode::VARIANCE_COVARIANT, + 'contravariant' => GenericTypeNode::VARIANCE_CONTRAVARIANT, + default => GenericTypeNode::VARIANCE_INVARIANT, + }; } } } @@ -360,7 +343,8 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t } /** - * Resolves and binds parent class (@extends) and interface (@implements) template mappings. + * Resolves and binds parent class (@extends), interface (@implements), and trait (@use) template mappings. + * Respects Vendor Isolation by skipping docblocks from excluded vendor ancestor files. */ public static function resolveInheritedTemplates(object $instance, string $targetClassName): void { @@ -373,10 +357,26 @@ public static function resolveInheritedTemplates(object $instance, string $targe [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); foreach ($classHierarchy as $hierClass) { - $classDoc = $hierClass->getDocComment(); + $fileName = $hierClass->getFileName(); + + if ($hierClass->getName() !== $actualClassName && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { + continue; + } + $docsToInspect = []; + + $classDoc = $hierClass->getDocComment(); if ($classDoc !== false) { - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); + $docsToInspect[] = $classDoc; + } + + $traitDocs = SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName()); + foreach ($traitDocs as $tDoc) { + $docsToInspect[] = $tDoc; + } + + foreach ($docsToInspect as $rawDoc) { + $classTokens = new TokenIterator($lexer->tokenize($rawDoc)); $classPhpDocNode = $phpDocParser->parse($classTokens); $declaredTemplateNames = []; @@ -386,18 +386,18 @@ public static function resolveInheritedTemplates(object $instance, string $targe } } - $inheritedTags = array_merge( - $classPhpDocNode->getExtendsTagValues(), - $classPhpDocNode->getImplementsTagValues() - ); + // Extract all @extends, @implements, @use and their @template-*, @phpstan-*, @psalm-* variations + $inheritedTags = DocblockExtractor::getInheritedTags($classPhpDocNode); foreach ($inheritedTags as $inheritedTag) { $genericTypeNode = $inheritedTag->type; if ($genericTypeNode instanceof GenericTypeNode) { $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); - if (ClassNameValidator::isValid($parentName) && is_a($actualClassName, $parentName, true)) { - if (! class_exists($parentName) && ! interface_exists($parentName)) { + $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); + + if (ClassNameValidator::isValid($parentName) && $isHierarchyMember) { + if (! class_exists($parentName) && ! interface_exists($parentName) && ! trait_exists($parentName)) { continue; } diff --git a/src/Resolver/TemplateSubstitutor.php b/src/Resolver/TemplateSubstitutor.php index 62df620..527ee40 100644 --- a/src/Resolver/TemplateSubstitutor.php +++ b/src/Resolver/TemplateSubstitutor.php @@ -152,4 +152,4 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ return $node; } -} \ No newline at end of file +} diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index a76eba2..58d13fb 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -473,4 +473,4 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } -} \ No newline at end of file +} diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index c707884..6bb9fa6 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -169,4 +169,4 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar } } } -} \ No newline at end of file +} diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 3776fa0..8ae2fa0 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -155,4 +155,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} \ No newline at end of file +} diff --git a/tests/Contract/DocblockExtractorTest.php b/tests/Contract/DocblockExtractorTest.php index 4fba1d4..ff35a1b 100644 --- a/tests/Contract/DocblockExtractorTest.php +++ b/tests/Contract/DocblockExtractorTest.php @@ -108,4 +108,148 @@ $missingType = DocblockExtractor::extractTypeFromClassPropertyDoc($doc, 'missing'); expect($missingType)->toBeNull(); }); + + describe('Prioritized Tag Extractions (@phpstan-* > @psalm-* > standard)', function () { + test('prioritizes @phpstan-param over @psalm-param and @param', function () { + $doc = <<<'DOC' +/** + * @param mixed $element + * @psalm-param int $element + * @phpstan-param positive-int $element + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $paramTags = DocblockExtractor::getParamTags($node); + + expect($paramTags)->toHaveKey('element') + ->and((string) $paramTags['element']->type)->toBe('positive-int') + ; + }); + + test('prioritizes @psalm-param over @param when @phpstan-param is absent', function () { + $doc = <<<'DOC' +/** + * @param mixed $element + * @psalm-param non-empty-string $element + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $paramTags = DocblockExtractor::getParamTags($node); + + expect($paramTags)->toHaveKey('element') + ->and((string) $paramTags['element']->type)->toBe('non-empty-string') + ; + }); + + test('prioritizes @phpstan-return over @psalm-return and @return', function () { + $doc = <<<'DOC' +/** + * @return mixed + * @psalm-return array + * @phpstan-return list + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $returnTag = DocblockExtractor::getReturnTag($node); + + expect($returnTag)->not()->toBeNull() + ->and((string) $returnTag->type)->toBe('list') + ; + }); + + test('prioritizes @psalm-return over @return when @phpstan-return is absent', function () { + $doc = <<<'DOC' +/** + * @return mixed + * @psalm-return array{id: positive-int} + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $returnTag = DocblockExtractor::getReturnTag($node); + + expect($returnTag)->not()->toBeNull() + ->and((string) $returnTag->type)->toBe('array{id: positive-int}') + ; + }); + + test('prioritizes @phpstan-var over @psalm-var and @var', function () { + $doc = <<<'DOC' +/** + * @var mixed $item + * @psalm-var int $item + * @phpstan-var positive-int $item + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $varTags = DocblockExtractor::getVarTags($node); + + expect($varTags)->toHaveCount(1) + ->and((string) $varTags[0]->type)->toBe('positive-int') + ; + }); + + test('prioritizes @psalm-var over @var when @phpstan-var is absent', function () { + $doc = <<<'DOC' +/** + * @var mixed $item + * @psalm-var non-empty-string $item + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $varTags = DocblockExtractor::getVarTags($node); + + expect($varTags)->toHaveCount(1) + ->and((string) $varTags[0]->type)->toBe('non-empty-string') + ; + }); + }); +}); + +describe('@template Priority and Variance Extractions', function () { + test('prioritizes @phpstan-template with bound over basic @template', function () { + $doc = <<<'DOC' +/** + * @template T + * @phpstan-template T of \TypePHP\Tests\Fixtures\Domain\Animal + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $templates = DocblockExtractor::extractTemplates($node); + + expect($templates)->toHaveKey('T') + ->and($templates['T']->bound)->not()->toBeNull() + ->and((string) $templates['T']->bound)->toBe('\TypePHP\Tests\Fixtures\Domain\Animal') + ; + }); + + test('extracts declared template variances with @phpstan-template-covariant priority', function () { + $doc = <<<'DOC' +/** + * @template T + * @phpstan-template-covariant T + * @psalm-template-contravariant K + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $variances = DocblockExtractor::extractTemplateVariances($node); + + expect($variances)->toBe([ + 'T' => 'covariant', + 'K' => 'contravariant', + ]); + }); + + test('extracts all inherited template tag variations via getInheritedTags', function () { + $doc = <<<'DOC' +/** + * @template-extends BaseRepository + * @phpstan-implements ProcessorInterface + * @use LoggerTrait + */ +DOC; + $node = DocblockExtractor::parseDocString($doc); + $inherited = DocblockExtractor::getInheritedTags($node); + + expect($inherited)->toHaveCount(3); + }); }); diff --git a/tests/Fixtures/Anonymous/AnonymousContractInterface.php b/tests/Fixtures/Anonymous/AnonymousContractInterface.php index 9d02973..4f576c1 100644 --- a/tests/Fixtures/Anonymous/AnonymousContractInterface.php +++ b/tests/Fixtures/Anonymous/AnonymousContractInterface.php @@ -13,4 +13,4 @@ interface AnonymousContractInterface * @return array{id: positive-int, name: non-empty-string} */ public function formatUser(int $id, string $name): array; -} \ No newline at end of file +} diff --git a/tests/Fixtures/ByRef/ByRefService.php b/tests/Fixtures/ByRef/ByRefService.php index d3cb6d9..1ed5a48 100644 --- a/tests/Fixtures/ByRef/ByRefService.php +++ b/tests/Fixtures/ByRef/ByRefService.php @@ -17,4 +17,4 @@ public function incrementCode(int &$statusCode): void { $statusCode += 100; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/ByRef/ByRefServiceInterface.php b/tests/Fixtures/ByRef/ByRefServiceInterface.php index 3c7da7d..befa583 100644 --- a/tests/Fixtures/ByRef/ByRefServiceInterface.php +++ b/tests/Fixtures/ByRef/ByRefServiceInterface.php @@ -15,4 +15,4 @@ public function updateStatus(string &$status): void; * @param positive-int &$code */ public function incrementCode(int &$code): void; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Callables/CurriedPipelineService.php b/tests/Fixtures/Callables/CurriedPipelineService.php index 5d4e7f8..37778f0 100644 --- a/tests/Fixtures/Callables/CurriedPipelineService.php +++ b/tests/Fixtures/Callables/CurriedPipelineService.php @@ -15,7 +15,7 @@ public function createValidatorFactory(): callable { return function (int $minLen): callable { return function (string $text) use ($minLen): bool { - return strlen($text) >= $minLen; + return \strlen($text) >= $minLen; }; }; } @@ -33,4 +33,4 @@ public function createBadReturnFactory(): callable }; }; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Callables/FirstClassCallableService.php b/tests/Fixtures/Callables/FirstClassCallableService.php index b2cb408..fb52c73 100644 --- a/tests/Fixtures/Callables/FirstClassCallableService.php +++ b/tests/Fixtures/Callables/FirstClassCallableService.php @@ -42,4 +42,4 @@ public function badReturnMethod(int $id): string { return ''; // Violates non-empty-string! } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Callables/GenericCallableService.php b/tests/Fixtures/Callables/GenericCallableService.php index 8abfe9d..c8dc613 100644 --- a/tests/Fixtures/Callables/GenericCallableService.php +++ b/tests/Fixtures/Callables/GenericCallableService.php @@ -5,7 +5,6 @@ namespace TypePHP\Tests\Fixtures\Callables; use TypePHP\Tests\Fixtures\Domain\Animal; -use TypePHP\Tests\Fixtures\Domain\Dog; class GenericCallableService { @@ -38,4 +37,4 @@ public function formatAnimal(callable $formatter, Animal $animal): string { return $formatter($animal); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Collections/BoundedRepository.php b/tests/Fixtures/Collections/BoundedRepository.php new file mode 100644 index 0000000..f7c722c --- /dev/null +++ b/tests/Fixtures/Collections/BoundedRepository.php @@ -0,0 +1,22 @@ + + */ +class CovariantProducer implements CovariantProducerInterface +{ + /** + * @param T $item + */ + public function __construct(private mixed $item) + { + } + + public function get(): mixed + { + return $this->item; + } +} diff --git a/tests/Fixtures/Collections/CovariantProducerInterface.php b/tests/Fixtures/Collections/CovariantProducerInterface.php new file mode 100644 index 0000000..1f8ffe4 --- /dev/null +++ b/tests/Fixtures/Collections/CovariantProducerInterface.php @@ -0,0 +1,16 @@ + + */ +class DoctrineCollection implements DoctrineCollectionInterface +{ + /** + * @var array + */ + private array $elements = []; + + public function add(mixed $element): bool + { + $this->elements[] = $element; + + return true; + } +} diff --git a/tests/Fixtures/Collections/DoctrineCollectionInterface.php b/tests/Fixtures/Collections/DoctrineCollectionInterface.php new file mode 100644 index 0000000..28d9a9f --- /dev/null +++ b/tests/Fixtures/Collections/DoctrineCollectionInterface.php @@ -0,0 +1,18 @@ + $this->_username; - set => $this->_username = trim($value); - } - - private string $_username = 'Alice'; - - public function updateProfile(int $newId, string $newUsername): void - { - $this->id = $newId; // Validated against @var positive-int! - $this->username = $newUsername; // Validated in set hook against @var non-empty-string! + public function __construct( + public string $name = 'Alice', + public int $id = 1 + ) { } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Enums/Suit.php b/tests/Fixtures/Enums/Suit.php index 9c9c054..3aeba8a 100644 --- a/tests/Fixtures/Enums/Suit.php +++ b/tests/Fixtures/Enums/Suit.php @@ -10,4 +10,4 @@ enum Suit case Diamonds; case Clubs; case Spades; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Enums/TransactionStatus.php b/tests/Fixtures/Enums/TransactionStatus.php index d7439dc..3046acc 100644 --- a/tests/Fixtures/Enums/TransactionStatus.php +++ b/tests/Fixtures/Enums/TransactionStatus.php @@ -9,4 +9,4 @@ enum TransactionStatus: int case PENDING = 1; case COMPLETED = 2; case FAILED = 3; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/ChildSingleAbstractService.php b/tests/Fixtures/Generics/ChildSingleAbstractService.php index 9b963a7..da797ec 100644 --- a/tests/Fixtures/Generics/ChildSingleAbstractService.php +++ b/tests/Fixtures/Generics/ChildSingleAbstractService.php @@ -6,4 +6,4 @@ class ChildSingleAbstractService extends SingleAbstractGenericParent { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/ClassLevelTraitService.php b/tests/Fixtures/Generics/ClassLevelTraitService.php new file mode 100644 index 0000000..252dfc5 --- /dev/null +++ b/tests/Fixtures/Generics/ClassLevelTraitService.php @@ -0,0 +1,15 @@ + + */ +class ClassLevelTraitService +{ + use GenericItemLoggerTrait; +} diff --git a/tests/Fixtures/Generics/DeepGenericChildService.php b/tests/Fixtures/Generics/DeepGenericChildService.php index e26b7a0..734bd67 100644 --- a/tests/Fixtures/Generics/DeepGenericChildService.php +++ b/tests/Fixtures/Generics/DeepGenericChildService.php @@ -6,4 +6,4 @@ class DeepGenericChildService extends DeepGenericMidParent { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/DeepGenericMidParent.php b/tests/Fixtures/Generics/DeepGenericMidParent.php index 306ec7a..cc569a2 100644 --- a/tests/Fixtures/Generics/DeepGenericMidParent.php +++ b/tests/Fixtures/Generics/DeepGenericMidParent.php @@ -6,4 +6,4 @@ abstract class DeepGenericMidParent extends DeepGenericRootParent { -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/DeepGenericRootParent.php b/tests/Fixtures/Generics/DeepGenericRootParent.php index 2f7f036..f540d3a 100644 --- a/tests/Fixtures/Generics/DeepGenericRootParent.php +++ b/tests/Fixtures/Generics/DeepGenericRootParent.php @@ -16,4 +16,4 @@ public function processElement(mixed $element): bool { return true; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/GenericItemLoggerTrait.php b/tests/Fixtures/Generics/GenericItemLoggerTrait.php new file mode 100644 index 0000000..229c9a7 --- /dev/null +++ b/tests/Fixtures/Generics/GenericItemLoggerTrait.php @@ -0,0 +1,19 @@ + + */ + use GenericItemLoggerTrait; +} diff --git a/tests/Fixtures/Generics/MultiTemplateBag.php b/tests/Fixtures/Generics/MultiTemplateBag.php index 9f4dbe9..0b73fde 100644 --- a/tests/Fixtures/Generics/MultiTemplateBag.php +++ b/tests/Fixtures/Generics/MultiTemplateBag.php @@ -41,4 +41,4 @@ public function all(): array { return $this->storage; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/SingleAbstractGenericParent.php b/tests/Fixtures/Generics/SingleAbstractGenericParent.php index 8c4c392..de521d4 100644 --- a/tests/Fixtures/Generics/SingleAbstractGenericParent.php +++ b/tests/Fixtures/Generics/SingleAbstractGenericParent.php @@ -16,4 +16,4 @@ public function setItem(mixed $item): bool { return true; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/SingleLineInlineTraitUseService.php b/tests/Fixtures/Generics/SingleLineInlineTraitUseService.php new file mode 100644 index 0000000..2c8ce5a --- /dev/null +++ b/tests/Fixtures/Generics/SingleLineInlineTraitUseService.php @@ -0,0 +1,13 @@ + */ + use GenericItemLoggerTrait; +} diff --git a/tests/Fixtures/Iterators/GenericStreamService.php b/tests/Fixtures/Iterators/GenericStreamService.php index bc48580..b99249d 100644 --- a/tests/Fixtures/Iterators/GenericStreamService.php +++ b/tests/Fixtures/Iterators/GenericStreamService.php @@ -4,11 +4,9 @@ namespace TypePHP\Tests\Fixtures\Iterators; -use ArrayIterator; use Generator; use Traversable; use TypePHP\Tests\Fixtures\Domain\Animal; -use TypePHP\Tests\Fixtures\Domain\Dog; class GenericStreamService { @@ -87,4 +85,4 @@ public function streamInteractive(mixed $initial): Generator } } } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Pipes/NativePipeRunner.php b/tests/Fixtures/Pipes/NativePipeRunner.php index 0bf7f6b..f15d46f 100644 --- a/tests/Fixtures/Pipes/NativePipeRunner.php +++ b/tests/Fixtures/Pipes/NativePipeRunner.php @@ -40,4 +40,4 @@ public function stepTwo(int $id): string { return "piped_user_{$id}"; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Pipes/PipePipelineService.php b/tests/Fixtures/Pipes/PipePipelineService.php index f8f2234..eb61027 100644 --- a/tests/Fixtures/Pipes/PipePipelineService.php +++ b/tests/Fixtures/Pipes/PipePipelineService.php @@ -35,4 +35,4 @@ public function prefixTag(string $tag): string { return "[TAG] {$tag}"; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Readonly/ReadonlyOrder.php b/tests/Fixtures/Readonly/ReadonlyOrder.php index 85d8d66..60d5fd6 100644 --- a/tests/Fixtures/Readonly/ReadonlyOrder.php +++ b/tests/Fixtures/Readonly/ReadonlyOrder.php @@ -19,4 +19,4 @@ public function __construct( public readonly int $quantity = 1 ) { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Readonly/ReadonlyUser.php b/tests/Fixtures/Readonly/ReadonlyUser.php index e0eec31..1ccfb0d 100644 --- a/tests/Fixtures/Readonly/ReadonlyUser.php +++ b/tests/Fixtures/Readonly/ReadonlyUser.php @@ -21,4 +21,4 @@ public function __construct(int $id, string $username) $this->id = $id; $this->username = $username; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php b/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php index 3cb37ce..7b87c42 100644 --- a/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php +++ b/tests/Fixtures/Readonly/UninitializedReadonlyContainer.php @@ -26,4 +26,4 @@ public function initialize(int $id, string $name): void $this->id = $id; $this->name = $name; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Shapes/UnsealedPayloadService.php b/tests/Fixtures/Shapes/UnsealedPayloadService.php index 7af5e67..d4990df 100644 --- a/tests/Fixtures/Shapes/UnsealedPayloadService.php +++ b/tests/Fixtures/Shapes/UnsealedPayloadService.php @@ -13,7 +13,7 @@ class UnsealedPayloadService */ public function processBatchOptions(array $payload): int { - return count($payload); + return \count($payload); } /** @@ -23,6 +23,6 @@ public function processBatchOptions(array $payload): int */ public function processPlayerStats(array $data): int { - return count($data); + return \count($data); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Traits/CollisionService.php b/tests/Fixtures/Traits/CollisionService.php index 0a7bb35..fbb5350 100644 --- a/tests/Fixtures/Traits/CollisionService.php +++ b/tests/Fixtures/Traits/CollisionService.php @@ -10,4 +10,4 @@ class CollisionService FirstLogger::log insteadof SecondLogger; SecondLogger::log as backupLog; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Traits/FirstLogger.php b/tests/Fixtures/Traits/FirstLogger.php index 0d250b5..425890e 100644 --- a/tests/Fixtures/Traits/FirstLogger.php +++ b/tests/Fixtures/Traits/FirstLogger.php @@ -16,4 +16,4 @@ public function log(int $level, string $message): string { return "first: {$level} - {$message}"; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Traits/SecondLogger.php b/tests/Fixtures/Traits/SecondLogger.php index 377f245..9f98b81 100644 --- a/tests/Fixtures/Traits/SecondLogger.php +++ b/tests/Fixtures/Traits/SecondLogger.php @@ -16,4 +16,4 @@ public function log(int $level, string $message): string { return "second: {$level} - {$message}"; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/DeepOffsetContainer.php b/tests/Fixtures/Types/DeepOffsetContainer.php index b25f83f..86475d8 100644 --- a/tests/Fixtures/Types/DeepOffsetContainer.php +++ b/tests/Fixtures/Types/DeepOffsetContainer.php @@ -25,4 +25,4 @@ public function configureDatabase(int $port, string $driver): array 'driver' => $driver, ]; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/HookedUser.php b/tests/Fixtures/Types/HookedUser.php new file mode 100644 index 0000000..f3a0590 --- /dev/null +++ b/tests/Fixtures/Types/HookedUser.php @@ -0,0 +1,33 @@ + $this->_username; + set => $this->_username = trim($value); + } + + private string $_username = 'Alice'; + + public function updateProfile(int $newId, string $newUsername): void + { + $this->id = $newId; + $this->username = $newUsername; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Unpack/UnpackService.php b/tests/Fixtures/Unpack/UnpackService.php index 5e1764d..91a54b6 100644 --- a/tests/Fixtures/Unpack/UnpackService.php +++ b/tests/Fixtures/Unpack/UnpackService.php @@ -29,4 +29,4 @@ public function sumScores(int ...$scores): int { return array_sum($scores); } -} \ No newline at end of file +} diff --git a/tests/Internal/ConfigTest.php b/tests/Internal/ConfigTest.php index aff6bb9..428fa20 100644 --- a/tests/Internal/ConfigTest.php +++ b/tests/Internal/ConfigTest.php @@ -12,7 +12,12 @@ test('loads default configuration array', function () { $config = Config::get(); - expect($config)->toBeArray(); + expect($config)->toBeArray() + ->and($config)->toHaveKey('enabled') + ->and($config)->toHaveKey('cache') + ->and($config)->toHaveKey('cache_dir') + ->and($config['cache_dir'])->toBeNull() + ; }); test('dynamically overrides configuration settings with set', function () { @@ -34,4 +39,28 @@ Config::reset(); expect(Config::get())->toBeArray(); }); + + test('resolves and memoizes project root path via getProjectRoot', function () { + $root1 = Config::getProjectRoot(); + $root2 = Config::getProjectRoot(); + + expect($root1)->toBeString() + ->and($root1)->not()->toBeEmpty() + ->and(is_dir($root1))->toBeTrue() + ->and($root1)->toBe($root2) + ->and(file_exists($root1 . '/composer.json') || file_exists($root1 . '/vendor/autoload.php'))->toBeTrue() + ; + }); + + test('loads typephp.php from project root directory', function () { + $projectRoot = Config::getProjectRoot(); + $configFile = $projectRoot . '/typephp.php'; + + if (file_exists($configFile)) { + $config = Config::get(); + expect($config)->toBeArray() + ->and($config['include'])->toBeArray() + ; + } + }); }); diff --git a/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php b/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php index c4da18a..27811a2 100644 --- a/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php +++ b/tests/TypeChecking/ArraysAndShapes/ComplexUnsealedShapesTest.php @@ -42,7 +42,8 @@ function testComplexUnsealedFunction(array $config): bool ]; expect(fn () => $service->processBatchOptions($badPayload)) - ->toThrow(TypeError::class, "['odd_scores'][1] must be of type positive-int"); + ->toThrow(TypeError::class, "['odd_scores'][1] must be of type positive-int") + ; }); test('throws TypeError when an extra key is not a list', function () { @@ -53,7 +54,8 @@ function testComplexUnsealedFunction(array $config): bool ]; expect(fn () => $service->processBatchOptions($badPayload)) - ->toThrow(TypeError::class, "['extra_info'] must be a list"); + ->toThrow(TypeError::class, "['extra_info'] must be a list") + ; }); }); @@ -78,7 +80,8 @@ function testComplexUnsealedFunction(array $config): bool ]; expect(fn () => $service->processPlayerStats($badData)) - ->toThrow(TypeError::class, "['player_two']['score'] must be of type positive-int"); + ->toThrow(TypeError::class, "['player_two']['score'] must be of type positive-int") + ; }); test('throws TypeError when an extra sub-shape is missing a required inner property', function () { @@ -89,7 +92,8 @@ function testComplexUnsealedFunction(array $config): bool ]; expect(fn () => $service->processPlayerStats($badData)) - ->toThrow(TypeError::class, "['player_one'] is missing required key 'active'"); + ->toThrow(TypeError::class, "['player_one'] is missing required key 'active'") + ; }); }); @@ -111,7 +115,8 @@ function testComplexUnsealedFunction(array $config): bool ]; expect(fn () => testComplexUnsealedFunction($config)) - ->toThrow(TypeError::class, "['tags'][1] must be of type non-empty-string"); + ->toThrow(TypeError::class, "['tags'][1] must be of type non-empty-string") + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php b/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php index 8dc2867..83d7f14 100644 --- a/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php +++ b/tests/TypeChecking/ArraysAndShapes/DeepOffsetAccessTest.php @@ -29,19 +29,22 @@ function testDirectNestedOffsetAccess(string $host, bool $ssl): array test('throws TypeError when port exceeds integer bounds from nested offset access', function () { $container = new DeepOffsetContainer(); - + expect(fn () => $container->configureDatabase(70000, 'mysql')) - ->toThrow(TypeError::class, 'Argument $port'); + ->toThrow(TypeError::class, 'Argument $port') + ; expect(fn () => $container->configureDatabase(0, 'mysql')) - ->toThrow(TypeError::class, 'Argument $port'); + ->toThrow(TypeError::class, 'Argument $port') + ; }); test('throws TypeError when driver violates literal union from nested offset access', function () { $container = new DeepOffsetContainer(); expect(fn () => $container->configureDatabase(3306, 'sqlite')) - ->toThrow(TypeError::class, "Argument \$driver must be of type ('mysql' | 'pgsql')"); + ->toThrow(TypeError::class, "Argument \$driver must be of type ('mysql' | 'pgsql')") + ; }); }); @@ -57,7 +60,8 @@ function testDirectNestedOffsetAccess(string $host, bool $ssl): array test('throws TypeError when host is empty string', function () { expect(fn () => testDirectNestedOffsetAccess('', true)) - ->toThrow(TypeError::class, 'Argument $host must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $host must be of type non-empty-string') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/DnfAndComplexIntersectionsTest.php b/tests/TypeChecking/ArraysAndShapes/DnfAndComplexIntersectionsTest.php index 1c7e536..d3ab226 100644 --- a/tests/TypeChecking/ArraysAndShapes/DnfAndComplexIntersectionsTest.php +++ b/tests/TypeChecking/ArraysAndShapes/DnfAndComplexIntersectionsTest.php @@ -27,14 +27,16 @@ $service = new DnfService(); expect(fn () => $service->processNullableIntersection(new CountableOnly())) - ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)'); + ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)') + ; }); test('throws TypeError when object only implements ArrayAccess', function () { $service = new DnfService(); expect(fn () => $service->processNullableIntersection(new ArrayAccessOnly())) - ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)'); + ->toThrow(TypeError::class, 'must be of type ((Countable & ArrayAccess) | null)') + ; }); }); @@ -57,7 +59,8 @@ ]; expect(fn () => $service->processShapeWithIntersection($badData)) - ->toThrow(TypeError::class, "['collection'] must be of type ArrayAccess"); + ->toThrow(TypeError::class, "['collection'] must be of type ArrayAccess") + ; }); test('throws TypeError when shape scalar property is invalid', function () { @@ -68,7 +71,8 @@ ]; expect(fn () => $service->processShapeWithIntersection($badData)) - ->toThrow(TypeError::class, "['id'] must be of type positive-int"); + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; }); }); @@ -89,7 +93,8 @@ $service = new DnfService(); expect(fn () => $service->processDnfAlias(new CountableOnly())) - ->toThrow(TypeError::class); + ->toThrow(TypeError::class) + ; }); }); @@ -109,7 +114,8 @@ $collection = new GenericCollection(); expect(fn () => $collection->add(new CountableOnly())) - ->toThrow(TypeError::class); + ->toThrow(TypeError::class) + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/EnumKeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/EnumKeyOfValueOfTest.php index 4e81679..29f7460 100644 --- a/tests/TypeChecking/ArraysAndShapes/EnumKeyOfValueOfTest.php +++ b/tests/TypeChecking/ArraysAndShapes/EnumKeyOfValueOfTest.php @@ -46,18 +46,22 @@ function testIntBackedEnumValueOf(int $statusCode): int test('key-of rejects invalid case names and lowercase names', function () { expect(fn () => testUnitEnumKeyOf('hearts')) - ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit'); + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit') + ; expect(fn () => testUnitEnumKeyOf('InvalidSuit')) - ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit'); + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\Suit') + ; }); test('value-of throws TypeError because UnitEnums have no backing values', function () { expect(fn () => testUnitEnumValueOf('Hearts')) - ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit'); + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit') + ; expect(fn () => testUnitEnumValueOf(1)) - ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit'); + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\Suit') + ; }); }); @@ -69,7 +73,8 @@ function testIntBackedEnumValueOf(int $statusCode): int test('key-of rejects invalid case names', function () { expect(fn () => testIntBackedEnumKeyOf('pending')) - ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus'); + ->toThrow(TypeError::class, 'must be a key of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus') + ; }); test('value-of accepts backing integers', function () { @@ -79,7 +84,8 @@ function testIntBackedEnumValueOf(int $statusCode): int test('value-of rejects non-existent integers and string numbers', function () { expect(fn () => testIntBackedEnumValueOf(99)) - ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus'); + ->toThrow(TypeError::class, 'must be a value of enum TypePHP\Tests\Fixtures\Enums\TransactionStatus') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php index d668852..ffd4e25 100644 --- a/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php +++ b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php @@ -25,10 +25,12 @@ public function generateCode(int $id, string $sku): string expect($service->generateCode(42, 'ITEM'))->toBe('ITEM_42'); expect(fn () => $service->generateCode(-5, 'ITEM')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $service->generateCode(42, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('validates return contracts when anonymous class method returns invalid value', function () { @@ -40,12 +42,13 @@ public function generateCode(int $id, string $sku): string */ public function badReturn(int $id): string { - return ''; + return ''; } }; expect(fn () => $service->badReturn(10)) - ->toThrow(TypeError::class, 'Return value'); + ->toThrow(TypeError::class, 'Return value') + ; }); }); @@ -61,10 +64,12 @@ public function formatUser(int $id, string $name): array expect($service->formatUser(100, 'Alice'))->toBe(['id' => 100, 'name' => 'Alice']); expect(fn () => $service->formatUser(-1, 'Alice')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $service->formatUser(100, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('inherits by-reference parameter contracts on anonymous class implementing interface', function () { @@ -86,7 +91,8 @@ public function incrementCode(int &$code): void $badStatus = ''; expect(fn () => $service->updateStatus($badStatus)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); @@ -102,7 +108,8 @@ public function find(int $id): array expect($service->find(10))->toBe(['id' => 10, 'name' => 'Alice']); expect(fn () => $service->find(-5)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); }); @@ -124,10 +131,11 @@ public function find(int $id): array expect($container->count)->toBe(50); expect(fn () => $container->count = -10) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $container->title = '') ->toThrow(TypeError::class, 'non-empty-string'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php b/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php index 4157e89..c57b013 100644 --- a/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php +++ b/tests/TypeChecking/Boundaries/ArgumentUnpackingTest.php @@ -19,7 +19,7 @@ function testUnpackFunction(int $id, string $name, int $age): array */ function testVariadicUnpackFunction(int ...$ids): int { - return count($ids); + return \count($ids); } describe('PHP 8.0+ Argument Unpacking / Spread (...$args)', function () { @@ -63,7 +63,8 @@ function testVariadicUnpackFunction(int ...$ids): int ]; expect(fn () => testUnpackFunction(...$badPayload)) - ->toThrow(TypeError::class, 'Argument $id must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $id must be of type positive-int') + ; }); test('throws TypeError when unpacked int range argument exceeds max bound', function () { @@ -74,7 +75,8 @@ function testVariadicUnpackFunction(int ...$ids): int ]; expect(fn () => testUnpackFunction(...$badAgePayload)) - ->toThrow(TypeError::class, 'Argument $age'); + ->toThrow(TypeError::class, 'Argument $age') + ; }); test('throws TypeError when unpacked string argument is empty', function () { @@ -85,7 +87,8 @@ function testVariadicUnpackFunction(int ...$ids): int ]; expect(fn () => testUnpackFunction(...$badNamePayload)) - ->toThrow(TypeError::class, 'Argument $name must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $name must be of type non-empty-string') + ; }); }); @@ -100,7 +103,8 @@ function testVariadicUnpackFunction(int ...$ids): int $ids = [10, 20, -5, 40]; expect(fn () => testVariadicUnpackFunction(...$ids)) - ->toThrow(TypeError::class, 'Argument $ids[2] must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $ids[2] must be of type positive-int') + ; }); }); @@ -133,7 +137,8 @@ function testVariadicUnpackFunction(int ...$ids): int ]; expect(fn () => $service->configureUser(...$params)) - ->toThrow(TypeError::class, "Argument \$role must be of type ('admin' | 'editor' | 'viewer')"); + ->toThrow(TypeError::class, "Argument \$role must be of type ('admin' | 'editor' | 'viewer')") + ; }); test('accepts unpacked variadic integers on class method', function () { @@ -144,7 +149,8 @@ function testVariadicUnpackFunction(int ...$ids): int $badScores = [100, -50, 300]; expect(fn () => $service->sumScores(...$badScores)) - ->toThrow(TypeError::class, 'Argument $scores[1] must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $scores[1] must be of type positive-int') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/ByReferenceParametersTest.php b/tests/TypeChecking/Boundaries/ByReferenceParametersTest.php index 3382f22..7875650 100644 --- a/tests/TypeChecking/Boundaries/ByReferenceParametersTest.php +++ b/tests/TypeChecking/Boundaries/ByReferenceParametersTest.php @@ -69,7 +69,8 @@ function testByRefString(string &$name): void $value = -50; expect(fn () => testByRefScalar($value)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; // Value must remain untouched in caller scope expect($value)->toBe(-50); @@ -83,7 +84,8 @@ function testByRefString(string &$name): void $invalidCounter = -10; expect(fn () => testByRefNativeOnly($invalidCounter)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect($invalidCounter)->toBe(-10); }); @@ -96,7 +98,8 @@ function testByRefString(string &$name): void $emptyName = ''; expect(fn () => testByRefString($emptyName)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; expect($emptyName)->toBe(''); }); @@ -114,7 +117,8 @@ function testByRefString(string &$name): void $list = [10, -20, 30]; expect(fn () => testByRefList($list)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; // Original array remains untouched expect($list)->toBe([10, -20, 30]); @@ -131,7 +135,8 @@ function testByRefString(string &$name): void expect($a)->toBe(11) ->and($b)->toBe(12) - ->and($c)->toBe(13); + ->and($c)->toBe(13) + ; }); test('throws TypeError when any variadic by-reference argument is invalid on entry', function () { @@ -140,12 +145,14 @@ function testByRefString(string &$name): void $c = 3; expect(fn () => testByRefVariadic($a, $b, $c)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; // None of the variables should have mutated expect($a)->toBe(1) ->and($b)->toBe(-5) - ->and($c)->toBe(3); + ->and($c)->toBe(3) + ; }); }); @@ -164,7 +171,8 @@ function testByRefString(string &$name): void $status = ''; expect(fn () => $service->updateStatus($status)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; expect($status)->toBe(''); }); @@ -178,9 +186,10 @@ function testByRefString(string &$name): void $badCode = -10; expect(fn () => $service->incrementCode($badCode)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect($badCode)->toBe(-10); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php index 51489b5..541ceb2 100644 --- a/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php +++ b/tests/TypeChecking/Boundaries/CrlfLineDriftTest.php @@ -32,19 +32,21 @@ $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); // 1. Total line counts must be 100% identical - expect(count($transLines))->toBe(count($origLines)); + expect(\count($transLines))->toBe(\count($origLines)); // 2. Call site line must be on the exact same line index $origCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $origLines), true); $transCallLine = array_search("formatUserData(-5, 'Alice');", array_map('trim', $transLines), true); expect($transCallLine)->toBe($origCallLine) - ->and($origCallLine)->toBe(14); // Line index 14 (Line 15 in file) + ->and($origCallLine)->toBe(14) // Line index 14 (Line 15 in file) + ; // 3. Return statement must remain on the exact same line index (Line 11) $origReturnLine = array_search('return "user_{$id}_{$name}";', array_map('trim', $origLines), true); expect($origReturnLine)->toBe(11) - ->and($transLines[11])->toContain('RuntimeTypeChecker::checkReturn'); + ->and($transLines[11])->toContain('RuntimeTypeChecker::checkReturn') + ; }); test('transforms CRLF (\r\n) constructor property promotion with zero line-drift', function () { @@ -70,13 +72,14 @@ $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); - expect(count($transLines))->toBe(count($origLines)); + expect(\count($transLines))->toBe(\count($origLines)); $origCallLine = array_search("new CrlfOrder(-1, 'SKU-100');", array_map('trim', $origLines), true); $transCallLine = array_search("new CrlfOrder(-1, 'SKU-100');", array_map('trim', $transLines), true); expect($transCallLine)->toBe($origCallLine) - ->and($origCallLine)->toBe(15); + ->and($origCallLine)->toBe(15) + ; }); test('transforms CRLF (\r\n) multi-line inline @var destructuring with zero line-drift', function () { @@ -95,18 +98,19 @@ $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); - expect(count($transLines))->toBe(count($origLines)); + expect(\count($transLines))->toBe(\count($origLines)); $origTargetLine = array_search('$targetLine = true;', array_map('trim', $origLines), true); $transTargetLine = array_search('$targetLine = true;', array_map('trim', $transLines), true); expect($transTargetLine)->toBe($origTargetLine) - ->and($origTargetLine)->toBe(8); + ->and($origTargetLine)->toBe(8) + ; }); test('preserves exact line numbers in actual TypeError exceptions thrown from CRLF files', function () { $tempDir = sys_get_temp_dir() . '/typephp_crlf_test'; - if (!is_dir($tempDir)) { + if (! is_dir($tempDir)) { mkdir($tempDir, 0777, true); } @@ -139,7 +143,8 @@ $actualPath = realpath($e->getFile()) !== false ? realpath($e->getFile()) : $e->getFile(); expect(strtolower(str_replace('\\', '/', (string) $actualPath))) - ->toBe(strtolower(str_replace('\\', '/', (string) $expectedPath))); + ->toBe(strtolower(str_replace('\\', '/', (string) $expectedPath))) + ; } finally { @unlink($crlfScriptPath); @rmdir($tempDir); @@ -147,4 +152,4 @@ expect($caught)->toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php b/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php index 968977a..1a66975 100644 --- a/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php +++ b/tests/TypeChecking/Boundaries/InlineReturnValidationTest.php @@ -8,7 +8,7 @@ function testInlineVarOnReturnArray(): array { /** @var list */ - return [1, 2, 3]; + return [1, 2, 3]; } /** @@ -36,7 +36,7 @@ function testInlineVarOnReturnInClosure(): array { $closure = function (): array { /** @var array{id: positive-int, name: non-empty-string} */ - return ['id' => -10, 'name' => 'Alice']; + return ['id' => -10, 'name' => 'Alice']; }; return $closure(); @@ -49,16 +49,19 @@ function testInlineVarOnReturnInClosure(): array test('throws TypeError when direct return expression violates unnamed inline @var contract', function () { expect(fn () => testInlineVarOnReturnArray()) - ->toThrow(TypeError::class, 'must be of type string'); + ->toThrow(TypeError::class, 'must be of type string') + ; }); test('throws TypeError when direct return expression violates named inline @var contract', function () { expect(fn () => testNamedInlineVarOnReturn(-5)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); test('throws TypeError when closure return expression violates inline @var array shape', function () { expect(fn () => testInlineVarOnReturnInClosure()) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php index cab4b29..419df11 100644 --- a/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php +++ b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php @@ -13,7 +13,8 @@ expect($service->formatByParameter('int', 42))->toBe(42); expect(fn () => $service->formatByParameter('int', -10)) - ->toThrow(TypeError::class, 'Return value must be of type positive-int'); + ->toThrow(TypeError::class, 'Return value must be of type positive-int') + ; }); test('evaluates float branch (positive-float) when format is "float"', function () { @@ -22,7 +23,8 @@ expect($service->formatByParameter('float', 3.14))->toBe(3.14); expect(fn () => $service->formatByParameter('float', -2.5)) - ->toThrow(TypeError::class, 'Return value must be of type positive-float'); + ->toThrow(TypeError::class, 'Return value must be of type positive-float') + ; }); test('evaluates bool branch when format is "bool"', function () { @@ -31,7 +33,8 @@ expect($service->formatByParameter('bool', true))->toBeTrue(); expect(fn () => $service->formatByParameter('bool', 'not_a_bool')) - ->toThrow(TypeError::class, 'Return value must be of type bool'); + ->toThrow(TypeError::class, 'Return value must be of type bool') + ; }); test('evaluates list branch (list) when format is "list"', function () { @@ -40,7 +43,8 @@ expect($service->formatByParameter('list', [10, 20, 30]))->toBe([10, 20, 30]); expect(fn () => $service->formatByParameter('list', [10, -5, 30])) - ->toThrow(TypeError::class, "Return value[1] must be of type positive-int"); + ->toThrow(TypeError::class, 'Return value[1] must be of type positive-int') + ; }); test('evaluates final fallback branch (non-empty-string) when format is any other string', function () { @@ -49,7 +53,8 @@ expect($service->formatByParameter('text', 'hello_world'))->toBe('hello_world'); expect(fn () => $service->formatByParameter('text', '')) - ->toThrow(TypeError::class, 'Return value must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string') + ; }); }); @@ -68,7 +73,8 @@ $dog = new Dog(); expect(fn () => $service->wrapOrReturn(true, $dog, $dog)) - ->toThrow(TypeError::class, 'must be a list'); + ->toThrow(TypeError::class, 'must be a list') + ; }); test('returns single Dog instance when wrapInList is false', function () { @@ -87,7 +93,8 @@ expect($service->formatByNegation(false, 'active_status'))->toBe('active_status'); expect(fn () => $service->formatByNegation(false, '')) - ->toThrow(TypeError::class, 'Return value must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Return value must be of type non-empty-string') + ; }); test('evaluates positive-int branch when flag is true', function () { @@ -99,4 +106,4 @@ ->toThrow(TypeError::class, 'Return value must be of type positive-int'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php b/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php new file mode 100644 index 0000000..2591ab6 --- /dev/null +++ b/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php @@ -0,0 +1,172 @@ + + * + * @return mixed + * + * @phpstan-return list + */ +function testPhpstanReturnPriority(mixed $val): mixed +{ + return $val; +} + +/** + * Function with broad @return mixed, but stricter @psalm-return array{id: positive-int, name: non-empty-string} + * + * @return mixed + * + * @psalm-return array{id: positive-int, name: non-empty-string} + */ +function testPsalmReturnPriority(mixed $val): mixed +{ + return $val; +} + +/** + * @param CovariantProducer $producer + */ +function handleCovariantProducer(CovariantProducer $producer): mixed +{ + return $producer->get(); +} + +describe('Tooling Annotation Priorities (@phpstan-* and @psalm-*)', function () { + describe('Parameter Contract Priority (@phpstan-param & @psalm-param)', function () { + test('enforces @phpstan-param positive-int over broad @param mixed', function () { + expect(testPhpstanParamPriority(42))->toBeTrue(); + + expect(fn () => testPhpstanParamPriority(-5)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('enforces @psalm-param non-empty-string over broad @param mixed', function () { + expect(testPsalmParamPriority('valid_code'))->toBeTrue(); + + expect(fn () => testPsalmParamPriority('')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + }); + + describe('Return Contract Priority (@phpstan-return & @psalm-return)', function () { + test('enforces @phpstan-return list over broad @return mixed', function () { + expect(testPhpstanReturnPriority([10, 20, 30]))->toBe([10, 20, 30]); + + expect(fn () => testPhpstanReturnPriority([10, -5, 30])) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('enforces @psalm-return array shape over broad @return mixed', function () { + expect(testPsalmReturnPriority(['id' => 10, 'name' => 'Alice']))->toBe(['id' => 10, 'name' => 'Alice']); + + expect(fn () => testPsalmReturnPriority(['id' => -10, 'name' => 'Alice'])) + ->toThrow(TypeError::class, 'positive-int') + ; + + expect(fn () => testPsalmReturnPriority(['id' => 10, 'name' => ''])) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + }); + + describe('Inline Variable Priority (@phpstan-var & @psalm-var)', function () { + test('enforces @phpstan-var positive-int over broad @var mixed on local assignment', function () { + /** + * @var mixed $score + * + * @phpstan-var positive-int $score + */ + $score = 100; + expect($score)->toBe(100); + + expect(function () use (&$score) { + $score = -50; + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('enforces @psalm-var non-empty-string over broad @var mixed on local assignment', function () { + /** + * @var mixed $tag + * + * @psalm-var non-empty-string $tag + */ + $tag = 'active'; + expect($tag)->toBe('active'); + + expect(function () use (&$tag) { + $tag = ''; + })->toThrow(TypeError::class, 'non-empty-string'); + }); + }); + + describe('Inherited Generic Collections with @phpstan-param (Doctrine Collection Pattern)', function () { + test('enforces inherited generic template T from @phpstan-param on implementing class', function () { + /** @var DoctrineCollection $collection */ + $collection = new DoctrineCollection(); + + expect($collection->add(new Dog()))->toBeTrue(); + + expect(fn () => $collection->add(new User())) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Animal') + ; + }); + }); + + describe('@phpstan-template Bounds & Covariance Priorities', function () { + test('enforces @phpstan-template upper bound Animal when broad @template has no bound', function () { + $repo = new BoundedRepository(new Dog()); + expect($repo->item)->toBeInstanceOf(Dog::class); + + expect(fn () => new BoundedRepository(new Car())) + ->toThrow(TypeError::class, 'TypePHP\Tests\Fixtures\Domain\Animal') + ; + }); + + test('detects and enforces @phpstan-template-covariant variance on instance', function () { + $producer = new CovariantProducer(new Dog()); + + expect(TypePHP::getGenericVariance($producer))->toBe('covariant') + ->and(handleCovariantProducer($producer))->toBeInstanceOf(Dog::class); + }); + }); +}); diff --git a/tests/TypeChecking/Boundaries/PipeOperatorTest.php b/tests/TypeChecking/Boundaries/PipeOperatorTest.php index 20bc723..0db7638 100644 --- a/tests/TypeChecking/Boundaries/PipeOperatorTest.php +++ b/tests/TypeChecking/Boundaries/PipeOperatorTest.php @@ -24,9 +24,10 @@ test('throws TypeError at the exact pipe step where parameter contract is violated', function () { $service = new PipePipelineService(); $runner = new NativePipeRunner(); - + expect(fn () => $runner->runPipeline(-5, $service)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); test('executes standalone method pipeline with native pipe operator', function () { @@ -41,7 +42,8 @@ $runner = new NativePipeRunner(); expect(fn () => $runner->runStandalonePipeline(-50)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); }); @@ -71,7 +73,7 @@ function formatId(int $id): string $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); - expect(count($transLines))->toBe(count($origLines)); + expect(\count($transLines))->toBe(\count($origLines)); $origTarget = array_search('$targetLine = true;', array_map('trim', $origLines), true); $transTarget = array_search('$targetLine = true;', array_map('trim', $transLines), true); @@ -79,4 +81,4 @@ function formatId(int $id): string expect($transTarget)->toBe($origTarget); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/PropertyHooksTest.php b/tests/TypeChecking/Boundaries/PropertyHooksTest.php index 9f1791c..435c620 100644 --- a/tests/TypeChecking/Boundaries/PropertyHooksTest.php +++ b/tests/TypeChecking/Boundaries/PropertyHooksTest.php @@ -7,8 +7,8 @@ } use TypePHP\Internal\Config; -use TypePHP\Tests\Fixtures\Domain\User; use TypePHP\Tests\Fixtures\Types\HookedInterfaceImplementation; +use TypePHP\Tests\Fixtures\Types\HookedUser; use TypePHP\Tests\Fixtures\Types\PropertyHooks; beforeEach(function () { @@ -76,8 +76,8 @@ expect($fixture->unvalidatedHook)->toBe(-50); }); - test('validates asymmetric visibility properties combined with property hooks', function () { - $profile = new User(); + test('validates asymmetric visibility properties combined with property hooks', function () { + $profile = new HookedUser(); $profile->updateProfile(100, 'Bob'); expect($profile->id)->toBe(100); diff --git a/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php index a703cb5..fb78d4f 100644 --- a/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php +++ b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php @@ -22,17 +22,20 @@ function testObjectShapeOnReadonly(object $obj): bool $user = new ReadonlyUser(42, 'Alice'); expect($user->id)->toBe(42) - ->and($user->username)->toBe('Alice'); + ->and($user->username)->toBe('Alice') + ; }); test('throws TypeError when initializing readonly property with invalid integer', function () { expect(fn () => new ReadonlyUser(-5, 'Alice')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); test('throws TypeError when initializing readonly property with empty string', function () { expect(fn () => new ReadonlyUser(42, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); @@ -42,22 +45,26 @@ function testObjectShapeOnReadonly(object $obj): bool expect($order->orderId)->toBe(100) ->and($order->sku)->toBe('SKU-500') - ->and($order->quantity)->toBe(5); + ->and($order->quantity)->toBe(5) + ; }); test('throws TypeError when promoted readonly orderId violates positive-int', function () { expect(fn () => new ReadonlyOrder(-1, 'SKU-500', 5)) - ->toThrow(TypeError::class, 'Argument $orderId must be of type positive-int'); + ->toThrow(TypeError::class, 'Argument $orderId must be of type positive-int') + ; }); test('throws TypeError when promoted readonly sku violates non-empty-string', function () { expect(fn () => new ReadonlyOrder(100, '', 5)) - ->toThrow(TypeError::class, 'Argument $sku must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $sku must be of type non-empty-string') + ; }); test('throws TypeError when promoted readonly quantity exceeds max bound of int<1, 100>', function () { expect(fn () => new ReadonlyOrder(100, 'SKU-500', 250)) - ->toThrow(TypeError::class, 'Argument $quantity'); + ->toThrow(TypeError::class, 'Argument $quantity') + ; }); }); @@ -66,7 +73,8 @@ function testObjectShapeOnReadonly(object $obj): bool $uninitialized = new UninitializedReadonlyContainer(); expect(fn () => testObjectShapeOnReadonly($uninitialized)) - ->toThrow(TypeError::class, "property 'id' is uninitialized"); + ->toThrow(TypeError::class, "property 'id' is uninitialized") + ; }); test('validates and accepts readonly container once initialized', function () { @@ -80,10 +88,11 @@ function testObjectShapeOnReadonly(object $obj): bool $container = new UninitializedReadonlyContainer(); expect(fn () => $container->initialize(-50, 'Report')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $container->initialize(10, '')) ->toThrow(TypeError::class, 'non-empty-string'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/FirstClassCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/FirstClassCallablesTest.php index f689cbd..51f2ffd 100644 --- a/tests/TypeChecking/CallablesAndIterators/FirstClassCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/FirstClassCallablesTest.php @@ -46,7 +46,8 @@ function applyCodeFormatter(callable $formatter, int $code): string $callable = $service->formatRecord(...); expect(fn () => applyRecordFormatter($callable, -5, 'ITEM')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); test('throws TypeError when first-class callable receives empty prefix string', function () { @@ -54,7 +55,8 @@ function applyCodeFormatter(callable $formatter, int $code): string $callable = $service->formatRecord(...); expect(fn () => applyRecordFormatter($callable, 42, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('throws TypeError when first-class callable method returns an invalid return value', function () { @@ -62,7 +64,8 @@ function applyCodeFormatter(callable $formatter, int $code): string $badCallable = $service->badReturnMethod(...); expect(fn () => applyCodeFormatter($badCallable, 10)) - ->toThrow(TypeError::class, 'must be of type non-empty-string'); + ->toThrow(TypeError::class, 'must be of type non-empty-string') + ; }); }); @@ -78,7 +81,8 @@ function applyCodeFormatter(callable $formatter, int $code): string $staticCallable = FirstClassCallableService::formatStaticCode(...); expect(fn () => applyCodeFormatter($staticCallable, -10)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); }); @@ -92,10 +96,12 @@ function applyCodeFormatter(callable $formatter, int $code): string expect($formatter(100, 'USER'))->toBe('USER_100'); expect(fn () => $formatter(-1, 'USER')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $formatter(100, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php index dd1c188..e0210e1 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php @@ -40,7 +40,8 @@ function testGenericComparator(callable $comparator, mixed $a, mixed $b): bool $badReturnCallback = fn (int $x): string => 'invalid'; expect(fn () => $service->transform($badReturnCallback, 10)) - ->toThrow(TypeError::class, 'must be of type int'); + ->toThrow(TypeError::class, 'must be of type int') + ; }); test('executes generic callback with class bound (@template T of Animal)', function () { @@ -52,10 +53,11 @@ function testGenericComparator(callable $comparator, mixed $a, mixed $b): bool test('throws TypeError when generic animal callback returns empty string', function () { $service = new GenericCallableService(); - $badFormatter = fn (Dog $d): string => ''; + $badFormatter = fn (Dog $d): string => ''; expect(fn () => $service->formatAnimal($badFormatter, new Dog())) - ->toThrow(TypeError::class, 'must be of type non-empty-string'); + ->toThrow(TypeError::class, 'must be of type non-empty-string') + ; }); }); @@ -68,10 +70,11 @@ function testGenericComparator(callable $comparator, mixed $a, mixed $b): bool }); test('throws TypeError when comparator return value is not a boolean', function () { - $badComparator = fn (int $x, int $y): int => 1; + $badComparator = fn (int $x, int $y): int => 1; expect(fn () => testGenericComparator($badComparator, 10, 5)) - ->toThrow(TypeError::class, 'must be of type bool'); + ->toThrow(TypeError::class, 'must be of type bool') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php index 9f52a6a..3fad3ee 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericIteratorsAndGeneratorsTest.php @@ -44,7 +44,8 @@ $result = $service->collectAnimalStream($iterator); expect($result)->toHaveCount(2) - ->and($result[0])->toBeInstanceOf(Dog::class); + ->and($result[0])->toBeInstanceOf(Dog::class) + ; }); test('throws TypeError lazily when traversable yields non-string key', function () { @@ -101,7 +102,8 @@ $gen->current(); expect(fn () => $gen->send('invalid')) - ->toThrow(TypeError::class, 'must be of type int'); + ->toThrow(TypeError::class, 'must be of type int') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php index 1753250..3a95854 100644 --- a/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/HigherOrderCallablesTest.php @@ -32,7 +32,8 @@ function testHigherOrderPipeline(callable $pipeline, callable $transformer, int $factory = $service->createValidatorFactory(); expect(fn () => $factory(-5)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); test('throws TypeError when inner curried callback receives invalid argument', function () { @@ -41,7 +42,8 @@ function testHigherOrderPipeline(callable $pipeline, callable $transformer, int $validator = $factory(3); expect(fn () => $validator('')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('throws TypeError when inner curried callback returns invalid return type', function () { @@ -50,7 +52,8 @@ function testHigherOrderPipeline(callable $pipeline, callable $transformer, int $badValidator = $badFactory(3); expect(fn () => $badValidator('ValidString')) - ->toThrow(TypeError::class, 'must be of type bool'); + ->toThrow(TypeError::class, 'must be of type bool') + ; }); }); @@ -65,10 +68,11 @@ function testHigherOrderPipeline(callable $pipeline, callable $transformer, int test('throws TypeError when transformer inside higher-order pipeline violates return type', function () { $pipeline = fn (callable $trans, int $val): string => $trans($val); - $badTransformer = fn (int $id): string => ''; + $badTransformer = fn (int $id): string => ''; expect(fn () => testHigherOrderPipeline($pipeline, $badTransformer, 42)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Generics/GenericTraitsUseAnnotationTest.php b/tests/TypeChecking/Generics/GenericTraitsUseAnnotationTest.php new file mode 100644 index 0000000..eced140 --- /dev/null +++ b/tests/TypeChecking/Generics/GenericTraitsUseAnnotationTest.php @@ -0,0 +1,50 @@ +', function () { + $service = new ClassLevelTraitService(); + + expect(TypePHP::getGenericType($service))->toBe(Dog::class); + + expect($service->logItem(new Dog()))->toBeTrue(); + expect(fn () => $service->logItem(new Car())) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + }); + + describe('Inline Trait Use Statement Annotations (/** @use */ use Trait;)', function () { + test('pre-binds generic template T upon instantiation when inline use statement declares @use Trait', function () { + $service = new InlineTraitUseService(); + + expect(TypePHP::getGenericType($service))->toBe(Dog::class); + + expect($service->logItem(new Dog()))->toBeTrue(); + expect(fn () => $service->logItem(new Car())) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + + test('pre-binds generic template T with single-line docblock (/** @use Trait */ use Trait;)', function () { + $service = new SingleLineInlineTraitUseService(); + + expect(TypePHP::getGenericType($service))->toBe(Dog::class); + + expect($service->logItem(new Dog()))->toBeTrue(); + expect(fn () => $service->logItem(new Car())) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog') + ; + }); + }); +}); diff --git a/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php b/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php index bd8ff01..a612d82 100644 --- a/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php +++ b/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php @@ -29,7 +29,8 @@ $container = new ChildWithoutTemplateDocblock(); expect(fn () => $container->push(new Car())) - ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')') + ; }); }); @@ -42,7 +43,8 @@ expect($service->setItem(new Cat()))->toBeTrue(); expect(fn () => $service->setItem(new Car())) - ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')') + ; }); }); @@ -54,7 +56,8 @@ expect($service->processElement(100))->toBeTrue(); expect(fn () => $service->processElement(-50)) - ->toThrow(TypeError::class, 'must be of type positive-int'); + ->toThrow(TypeError::class, 'must be of type positive-int') + ; }); }); @@ -65,16 +68,19 @@ expect($service->processData(42))->toBeTrue(); expect(fn () => $service->processData(-5)) - ->toThrow(TypeError::class, 'must be of type positive-int'); + ->toThrow(TypeError::class, 'must be of type positive-int') + ; expect($service->setKey('valid_key'))->toBeTrue(); expect(fn () => $service->setKey('')) - ->toThrow(TypeError::class, 'must be of type non-empty-string'); - + ->toThrow(TypeError::class, 'must be of type non-empty-string') + ; + expect($service->setVal(new Dog()))->toBeTrue(); expect($service->setVal(new Cat()))->toBeTrue(); expect(fn () => $service->setVal(new Car())) - ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Generics/MultiTemplateClassesTest.php b/tests/TypeChecking/Generics/MultiTemplateClassesTest.php index 548c75e..e64dc25 100644 --- a/tests/TypeChecking/Generics/MultiTemplateClassesTest.php +++ b/tests/TypeChecking/Generics/MultiTemplateClassesTest.php @@ -15,10 +15,12 @@ $bag->set('score_alpha', 100); expect($bag->get('score_alpha'))->toBe(100); expect(fn () => $bag->set('', 100)) - ->toThrow(TypeError::class, 'must be of type non-empty-string'); + ->toThrow(TypeError::class, 'must be of type non-empty-string') + ; expect(fn () => $bag->set('score_beta', -50)) - ->toThrow(TypeError::class, 'must be of type positive-int'); + ->toThrow(TypeError::class, 'must be of type positive-int') + ; }); test('inspects multiple pre-bound generic types via TypePHP public API', function () { @@ -30,7 +32,8 @@ ->and(TypePHP::getGenericTypes($catalog))->toBe([ 'K' => 'string', 'V' => Dog::class, - ]); + ]) + ; }); }); @@ -51,10 +54,12 @@ expect($bag->get('timeout'))->toBe(30); expect(fn () => $bag->set(12345, 30)) - ->toThrow(TypeError::class, 'template K = string'); + ->toThrow(TypeError::class, 'template K = string') + ; expect(fn () => $bag->set('timeout', 'thirty')) - ->toThrow(TypeError::class, 'template V = int'); + ->toThrow(TypeError::class, 'template V = int') + ; }); }); @@ -69,7 +74,8 @@ expect($cloned->get('new_key'))->toBe(20); expect(fn () => $cloned->set('bad_val', -99)) - ->toThrow(TypeError::class, 'must be of type positive-int'); + ->toThrow(TypeError::class, 'must be of type positive-int') + ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php b/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php index 99512cc..3d8b8ec 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php @@ -51,4 +51,19 @@ Config::reset(); }); }); + + describe('Edge Case 4: Vendor Isolation on Generic Traits', function () { + test('protects application child class from buggy docblock on excluded vendor parent using a trait', function () { + $ref = new ReflectionClass(SimulatedVendorParent::class); + $filePath = str_replace('\\', '/', (string) $ref->getFileName()); + + Config::set(['exclude' => [$filePath]]); + + $appService = new AppChildService(); + + expect($appService->execute(100))->toBeTrue(); + + Config::reset(); + }); + }); }); diff --git a/tests/TypeChecking/InheritanceAndAttributes/TraitConflictResolutionTest.php b/tests/TypeChecking/InheritanceAndAttributes/TraitConflictResolutionTest.php index 7ef550d..17b60df 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/TraitConflictResolutionTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/TraitConflictResolutionTest.php @@ -11,10 +11,12 @@ expect($service->log(10, 'app_boot'))->toBe('first: 10 - app_boot'); expect(fn () => $service->log(-5, 'app_boot')) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $service->log(10, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('enforces docblock contracts of the aliased trait method (as backupLog)', function () { @@ -23,6 +25,7 @@ expect($service->backupLog(-20, 'backup_msg'))->toBe('second: -20 - backup_msg'); expect(fn () => $service->backupLog(20, 'backup_msg')) - ->toThrow(TypeError::class, 'negative-int'); + ->toThrow(TypeError::class, 'negative-int') + ; }); -}); \ No newline at end of file +}); diff --git a/tests/Visitor/ScopeManagerTest.php b/tests/Visitor/ScopeManagerTest.php index 93a8ced..005a8a4 100644 --- a/tests/Visitor/ScopeManagerTest.php +++ b/tests/Visitor/ScopeManagerTest.php @@ -44,4 +44,34 @@ expect($manager->getVarTypeFromScope('username'))->toBe('non-empty-string'); }); + + test('prioritizes @phpstan-var over @var in scoped variable extractions', function () { + $manager = new ScopeManager(); + $manager->pushScope(); + + $doc = <<<'DOC' +/** + * @var mixed $count + * @phpstan-var positive-int $count + */ +DOC; + $manager->extractVarDocblock($doc); + + expect($manager->getVarTypeFromScope('count'))->toBe('positive-int'); + }); + + test('prioritizes @psalm-var over @var in scoped variable extractions when @phpstan-var is absent', function () { + $manager = new ScopeManager(); + $manager->pushScope(); + + $doc = <<<'DOC' +/** + * @var mixed $tag + * @psalm-var non-empty-string $tag + */ +DOC; + $manager->extractVarDocblock($doc); + + expect($manager->getVarTypeFromScope('tag'))->toBe('non-empty-string'); + }); });