Skip to content

Commit 1632629

Browse files
committed
Enhance SpecialTypeResolver and StreamWrapper to support class trait use docblocks; add tests for generic traits and vendor isolation
1 parent 5390a69 commit 1632629

8 files changed

Lines changed: 196 additions & 12 deletions

File tree

src/Internal/StreamWrapper.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool
599599
}
600600

601601
/**
602-
* Scans top-level AST statements for namespace and use import declarations to seed SpecialTypeResolver.
602+
* Scans top-level AST statements for namespace, use imports, and trait use declarations to seed SpecialTypeResolver.
603603
*
604604
* @param array<\PhpParser\Node\Stmt> $stmts
605605
*/
@@ -611,6 +611,7 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
611611

612612
$namespace = '';
613613
$imports = [];
614+
$classTraitUseDocs = [];
614615

615616
$nodesToScan = $stmts;
616617
foreach ($stmts as $stmt) {
@@ -645,9 +646,19 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
645646
$alias = $use->getAlias()->toString();
646647
$imports[$alias] = $fqcn;
647648
}
649+
} elseif ($stmt instanceof \PhpParser\Node\Stmt\Class_ && $stmt->name !== null) {
650+
$className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString();
651+
foreach ($stmt->stmts as $classStmt) {
652+
if ($classStmt instanceof \PhpParser\Node\Stmt\TraitUse) {
653+
$doc = $classStmt->getDocComment();
654+
if ($doc !== null) {
655+
$classTraitUseDocs[$className][] = $doc->getText();
656+
}
657+
}
658+
}
648659
}
649660
}
650661

651-
SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports);
662+
SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs);
652663
}
653664
}

src/Resolver/SpecialTypeResolver.php

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ final class SpecialTypeResolver
5252
*/
5353
private static array $fileNamespaces = [];
5454

55+
/**
56+
* In-memory cache of class trait use statement docblocks keyed by class FQCN.
57+
*
58+
* @var array<string, array<int, string>>
59+
*/
60+
private static array $classTraitUseDocs = [];
61+
5562
/**
5663
* Validates strict object identity ($value === $thisObj) when the return type node specifies $this.
5764
*/
@@ -692,19 +699,54 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName)
692699
}
693700

694701
/**
695-
* Seeds the in-memory cache directly from StreamWrapper to prevent double file reads and re-parsing.
702+
* Seeds file metadata directly from StreamWrapper.
696703
*
697704
* @param array<string, string> $imports
705+
* @param array<string, array<int, string>> $classTraitUseDocs
698706
*/
699-
public static function seedFileMetadata(string $fileName, string $namespace, array $imports): void
707+
public static function seedFileMetadata(string $fileName, string $namespace, array $imports, array $classTraitUseDocs = []): void
700708
{
701709
if ($fileName !== '') {
702710
$fileName = str_replace('\\', '/', $fileName);
703711
self::$fileNamespaces[$fileName] = $namespace;
704712
self::$fileUseImports[$fileName] = $imports;
713+
foreach ($classTraitUseDocs as $className => $docs) {
714+
self::$classTraitUseDocs[$className] = $docs;
715+
}
705716
}
706717
}
707718

719+
/**
720+
* Returns all inline trait use statement docblock strings declared inside a class.
721+
*
722+
* @return array<int, string>
723+
*/
724+
public static function getClassTraitUseDocs(string $className): array
725+
{
726+
if (isset(self::$classTraitUseDocs[$className])) {
727+
return self::$classTraitUseDocs[$className];
728+
}
729+
730+
if (! class_exists($className) && ! trait_exists($className)) {
731+
return self::$classTraitUseDocs[$className] = [];
732+
}
733+
734+
try {
735+
$ref = new \ReflectionClass($className);
736+
$fileName = $ref->getFileName();
737+
if ($fileName !== false && file_exists($fileName)) {
738+
$source = file_get_contents($fileName);
739+
if ($source !== false) {
740+
self::parseFileMetadata($fileName, $source);
741+
}
742+
}
743+
} catch (\Throwable $e) {
744+
// Silently ignore reflection errors
745+
}
746+
747+
return self::$classTraitUseDocs[$className] ?? [];
748+
}
749+
708750
/**
709751
* Returns use imports for the declaring file of a Reflection object.
710752
*
@@ -939,7 +981,7 @@ private static function isBuiltInTypeKeyword(string $name): bool
939981
}
940982

941983
/**
942-
* Parses the AST of a PHP file once to extract both namespace and use import statements.
984+
* Parses the AST of a PHP file once to extract namespace, use imports, and class trait use docblocks.
943985
*/
944986
private static function parseFileMetadata(string $fileName, string $source): void
945987
{
@@ -995,6 +1037,16 @@ private static function parseFileMetadata(string $fileName, string $source): voi
9951037
$alias = $use->getAlias()->toString();
9961038
$imports[$alias] = $fqcn;
9971039
}
1040+
} elseif ($stmt instanceof Stmt\Class_ && $stmt->name !== null) {
1041+
$className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString();
1042+
foreach ($stmt->stmts as $classStmt) {
1043+
if ($classStmt instanceof Stmt\TraitUse) {
1044+
$doc = $classStmt->getDocComment();
1045+
if ($doc !== null) {
1046+
self::$classTraitUseDocs[$className][] = $doc->getText();
1047+
}
1048+
}
1049+
}
9981050
}
9991051
}
10001052

src/Resolver/TemplateManager.php

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use PHPStan\PhpDocParser\Parser\TypeParser;
2020
use PHPStan\PhpDocParser\ParserConfig;
2121
use TypePHP\Contract\DocblockExtractor;
22+
use TypePHP\Contract\FileFilter;
2223
use TypePHP\Contract\HierarchyResolver;
2324
use TypePHP\Internal\ClassNameValidator;
2425
use TypePHP\Internal\ErrorFactory;
@@ -342,7 +343,8 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t
342343
}
343344

344345
/**
345-
* Resolves and binds parent class (@extends) and interface (@implements) template mappings.
346+
* Resolves and binds parent class (@extends), interface (@implements), and trait (@use) template mappings.
347+
* Respects Vendor Isolation by skipping docblocks from excluded vendor ancestor files.
346348
*/
347349
public static function resolveInheritedTemplates(object $instance, string $targetClassName): void
348350
{
@@ -355,10 +357,26 @@ public static function resolveInheritedTemplates(object $instance, string $targe
355357
[$phpDocParser, $lexer] = self::getPhpDocParserComponents();
356358

357359
foreach ($classHierarchy as $hierClass) {
358-
$classDoc = $hierClass->getDocComment();
360+
$fileName = $hierClass->getFileName();
361+
362+
if ($hierClass->getName() !== $actualClassName && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
363+
continue;
364+
}
359365

366+
$docsToInspect = [];
367+
368+
$classDoc = $hierClass->getDocComment();
360369
if ($classDoc !== false) {
361-
$classTokens = new TokenIterator($lexer->tokenize($classDoc));
370+
$docsToInspect[] = $classDoc;
371+
}
372+
373+
$traitDocs = SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName());
374+
foreach ($traitDocs as $tDoc) {
375+
$docsToInspect[] = $tDoc;
376+
}
377+
378+
foreach ($docsToInspect as $rawDoc) {
379+
$classTokens = new TokenIterator($lexer->tokenize($rawDoc));
362380
$classPhpDocNode = $phpDocParser->parse($classTokens);
363381

364382
$declaredTemplateNames = [];
@@ -372,13 +390,14 @@ public static function resolveInheritedTemplates(object $instance, string $targe
372390
$inheritedTags = DocblockExtractor::getInheritedTags($classPhpDocNode);
373391

374392
foreach ($inheritedTags as $inheritedTag) {
375-
/** @var GenericTypeNode|null $genericTypeNode */
376-
$genericTypeNode = $inheritedTag->type ?? null;
393+
$genericTypeNode = $inheritedTag->type;
377394
if ($genericTypeNode instanceof GenericTypeNode) {
378395
$parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass);
379396

380-
if (ClassNameValidator::isValid($parentName) && is_a($actualClassName, $parentName, true)) {
381-
if (! class_exists($parentName) && ! interface_exists($parentName)) {
397+
$isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName);
398+
399+
if (ClassNameValidator::isValid($parentName) && $isHierarchyMember) {
400+
if (! class_exists($parentName) && ! interface_exists($parentName) && ! trait_exists($parentName)) {
382401
continue;
383402
}
384403

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
9+
/**
10+
* @use GenericItemLoggerTrait<Dog>
11+
*/
12+
class ClassLevelTraitService
13+
{
14+
use GenericItemLoggerTrait;
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
/**
8+
* @template T
9+
*/
10+
trait GenericItemLoggerTrait
11+
{
12+
/**
13+
* @param T $item
14+
*/
15+
public function logItem(mixed $item): bool
16+
{
17+
return true;
18+
}
19+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Generics;
6+
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
9+
class InlineTraitUseService
10+
{
11+
/**
12+
* @use GenericItemLoggerTrait<Dog>
13+
*/
14+
use GenericItemLoggerTrait;
15+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Exception\TypeError;
6+
use TypePHP\Tests\Fixtures\Domain\Car;
7+
use TypePHP\Tests\Fixtures\Domain\Dog;
8+
use TypePHP\Tests\Fixtures\Generics\ClassLevelTraitService;
9+
use TypePHP\Tests\Fixtures\Generics\InlineTraitUseService;
10+
use TypePHP\TypePHP;
11+
12+
describe('Generic Traits with @use, @template-use, and @phpstan-use Annotations', function () {
13+
describe('Class-Level Trait Template Annotations', function () {
14+
test('pre-binds generic template T upon instantiation when class docblock declares @use Trait<T>', function () {
15+
$service = new ClassLevelTraitService();
16+
17+
expect(TypePHP::getGenericType($service))->toBe(Dog::class);
18+
19+
expect($service->logItem(new Dog()))->toBeTrue();
20+
expect(fn () => $service->logItem(new Car()))
21+
->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog')
22+
;
23+
});
24+
});
25+
26+
describe('Inline Trait Use Statement Annotations (/** @use */ use Trait;)', function () {
27+
test('pre-binds generic template T upon instantiation when inline use statement declares @use Trait<T>', function () {
28+
$service = new InlineTraitUseService();
29+
30+
expect(TypePHP::getGenericType($service))->toBe(Dog::class);
31+
32+
expect($service->logItem(new Dog()))->toBeTrue();
33+
expect(fn () => $service->logItem(new Car()))
34+
->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Domain\Dog')
35+
;
36+
});
37+
});
38+
});

tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,19 @@
5151
Config::reset();
5252
});
5353
});
54+
55+
describe('Edge Case 4: Vendor Isolation on Generic Traits', function () {
56+
test('protects application child class from buggy docblock on excluded vendor parent using a trait', function () {
57+
$ref = new ReflectionClass(SimulatedVendorParent::class);
58+
$filePath = str_replace('\\', '/', (string) $ref->getFileName());
59+
60+
Config::set(['exclude' => [$filePath]]);
61+
62+
$appService = new AppChildService();
63+
64+
expect($appService->execute(100))->toBeTrue();
65+
66+
Config::reset();
67+
});
68+
});
5469
});

0 commit comments

Comments
 (0)