Skip to content

Commit f773880

Browse files
committed
Added stub system to easily tame badly written thrid party libraries with inaccurate docblocks
1 parent 792e155 commit f773880

8 files changed

Lines changed: 857 additions & 24 deletions

File tree

src/Contract/ContractParser.php

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
2020
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
2121
use TypePHP\Internal\Config;
22+
use TypePHP\Internal\StubManager;
2223
use TypePHP\Resolver\SpecialTypeResolver;
2324
use TypePHP\Validator\TypeValidatorRegistry;
2425

2526
/**
26-
* @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @return, @template, @phpstan-type, @var).
27+
* @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @return, @template, @phpstan-type, @var, stubs).
2728
*/
2829
final class ContractParser
2930
{
@@ -67,6 +68,7 @@ public static function reset(): void
6768
DocblockExtractor::reset();
6869
FileFilter::reset();
6970
TypeValidatorRegistry::reset();
71+
StubManager::reset();
7072
}
7173

7274
/**
@@ -214,6 +216,12 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri
214216
{
215217
$current = $refClass;
216218
while ($current !== false) {
219+
$className = $current->getName();
220+
$stubDoc = StubManager::getPropertyDoc($className, $propertyName);
221+
if ($stubDoc !== null) {
222+
return ['doc' => $stubDoc, 'declaringClass' => $current];
223+
}
224+
217225
if ($current->hasProperty($propertyName)) {
218226
$refProp = $current->getProperty($propertyName);
219227
$doc = $refProp->getDocComment();
@@ -226,6 +234,12 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri
226234
}
227235

228236
foreach ($refClass->getInterfaces() as $interface) {
237+
$interfaceName = $interface->getName();
238+
$stubDoc = StubManager::getPropertyDoc($interfaceName, $propertyName);
239+
if ($stubDoc !== null) {
240+
return ['doc' => $stubDoc, 'declaringClass' => $interface];
241+
}
242+
229243
if ($interface->hasProperty($propertyName)) {
230244
$interfaceProp = $interface->getProperty($propertyName);
231245
$doc = $interfaceProp->getDocComment();
@@ -248,13 +262,16 @@ private static function findMagicPropertyDoc(\ReflectionClass $refClass, string
248262
$classHierarchy = HierarchyResolver::getClassHierarchy($refClass);
249263

250264
foreach ($classHierarchy as $hierClass) {
265+
$className = $hierClass->getName();
251266
$fileName = $hierClass->getFileName();
252-
if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
267+
$stubDoc = StubManager::getClassDoc($className);
268+
269+
if ($stubDoc === null && $hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
253270
continue;
254271
}
255272

256-
$classDoc = $hierClass->getDocComment();
257-
if ($classDoc !== false) {
273+
$classDoc = $stubDoc ?? $hierClass->getDocComment();
274+
if ($classDoc !== false && $classDoc !== null) {
258275
$extractedType = DocblockExtractor::extractTypeFromClassPropertyDoc($classDoc, $propertyName);
259276
if ($extractedType !== null) {
260277
return [
@@ -338,13 +355,16 @@ private static function findMagicMethodDoc(\ReflectionClass $refClass, string $m
338355
$classHierarchy = HierarchyResolver::getClassHierarchy($refClass);
339356

340357
foreach ($classHierarchy as $hierClass) {
358+
$className = $hierClass->getName();
341359
$fileName = $hierClass->getFileName();
342-
if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
360+
$stubDoc = StubManager::getClassDoc($className);
361+
362+
if ($stubDoc === null && $hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
343363
continue;
344364
}
345365

346-
$classDoc = $hierClass->getDocComment();
347-
if ($classDoc !== false) {
366+
$classDoc = $stubDoc ?? $hierClass->getDocComment();
367+
if ($classDoc !== false && $classDoc !== null) {
348368
$tag = DocblockExtractor::extractMagicMethodContract($classDoc, $methodName);
349369
if ($tag !== null) {
350370
return [
@@ -494,8 +514,11 @@ private static function parseFunction(\ReflectionFunction $ref): array
494514
$returnType = null;
495515
$aliases = [];
496516

497-
$doc = $ref->getDocComment();
498-
if ($doc === false) {
517+
$funcName = $ref->getName();
518+
$stubDoc = StubManager::getFunctionDoc($funcName);
519+
$doc = $stubDoc ?? $ref->getDocComment();
520+
521+
if ($doc === false || $doc === null) {
499522
return [
500523
'types' => [],
501524
'templates' => [],
@@ -549,7 +572,6 @@ private static function parseFunction(\ReflectionFunction $ref): array
549572

550573
/**
551574
* Resolves class-level docblocks (templates and aliases) up the class inheritance chain.
552-
* Memoizes results in $classLevelDocCache per class name for O(1) performance across method calls.
553575
*
554576
* @param \ReflectionClass<object> $declaringClass
555577
* @param array<string, TemplateTagValueNode> $templates
@@ -569,13 +591,16 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar
569591
$classHierarchy = HierarchyResolver::getClassHierarchy($declaringClass);
570592

571593
foreach ($classHierarchy as $hierClass) {
594+
$hierClassName = $hierClass->getName();
572595
$fileName = $hierClass->getFileName();
573-
if (FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
596+
$stubDoc = StubManager::getClassDoc($hierClassName);
597+
598+
if ($stubDoc === null && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
574599
continue;
575600
}
576601

577-
$classDoc = $hierClass->getDocComment();
578-
if ($classDoc !== false) {
602+
$classDoc = $stubDoc ?? $hierClass->getDocComment();
603+
if ($classDoc !== false && $classDoc !== null) {
579604
$classPhpDocNode = DocblockExtractor::parseDocString($classDoc);
580605

581606
foreach (DocblockExtractor::extractTemplates($classPhpDocNode) as $name => $tag) {
@@ -624,14 +649,17 @@ private static function parseMethodHierarchyDocs(
624649

625650
foreach ($hierarchy as $hierRef) {
626651
$isOriginal = ($hierRef === $ref);
652+
$declaringClass = $hierRef->getDeclaringClass()->getName();
653+
$methodName = $hierRef->getName();
654+
$stubDoc = StubManager::getMethodDoc($declaringClass, $methodName);
627655

628656
$fileName = $hierRef->getFileName();
629-
if (! $isOriginal && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
657+
if ($stubDoc === null && ! $isOriginal && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) {
630658
continue;
631659
}
632660

633-
$doc = $hierRef->getDocComment();
634-
if ($doc === false) {
661+
$doc = $stubDoc ?? $hierRef->getDocComment();
662+
if ($doc === false || $doc === null) {
635663
continue;
636664
}
637665

@@ -729,10 +757,13 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref
729757
$paramName = $p->getName();
730758

731759
if (! isset($types[$paramName]) && $declaringClass->hasProperty($paramName)) {
760+
$className = $declaringClass->getName();
761+
$stubDoc = StubManager::getPropertyDoc($className, $paramName);
762+
732763
$propertyRef = $declaringClass->getProperty($paramName);
733-
$propDoc = $propertyRef->getDocComment();
764+
$propDoc = $stubDoc ?? $propertyRef->getDocComment();
734765

735-
if ($propDoc !== false) {
766+
if ($propDoc !== false && $propDoc !== null) {
736767
$propType = DocblockExtractor::extractTypeFromPropertyDoc($propDoc, $paramName);
737768
if ($propType !== null) {
738769
if ($p->hasType()) {

src/Extension/ExtensionInterface.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
namespace TypePHP\Extension;
66

77
/**
8-
* Interface for third-party extensions providing automatic configuration overrides and custom rules.
8+
* Interface for third-party extensions providing automatic configuration overrides (include paths and stub files).
99
*/
1010
interface ExtensionInterface
1111
{
1212
/**
13-
* Returns configuration array (include) to merge into TypePHP.
13+
* Returns configuration array (include and stubs) to merge into TypePHP.
1414
*
1515
* @return array<string, mixed>
1616
*/

src/Extension/ExtensionManager.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ final class ExtensionManager
1111
{
1212
/**
1313
* Safely loads and merges whitelist include paths from explicitly configured extensions.
14-
* Extensions are structurally restricted to 'include' paths only.
14+
* Extensions are structurally restricted to 'include' and 'stubs' only.
1515
* Exclude authority belongs strictly to the end-user's local typephp.php file.
1616
*
1717
* @param array<int, class-string<ExtensionInterface>> $configuredExtensions
@@ -42,4 +42,35 @@ public static function loadExtensionIncludes(array $configuredExtensions = []):
4242

4343
return array_unique($extensionIncludes);
4444
}
45+
46+
/**
47+
* Safely loads and merges stub file paths from explicitly configured extensions.
48+
*
49+
* @param array<int, class-string<ExtensionInterface>> $configuredExtensions
50+
*
51+
* @return array<int, string>
52+
*/
53+
public static function loadExtensionStubs(array $configuredExtensions = []): array
54+
{
55+
$extensionStubs = [];
56+
$uniqueExtensions = array_unique($configuredExtensions);
57+
58+
foreach ($uniqueExtensions as $extensionClass) {
59+
if (\is_string($extensionClass) && class_exists($extensionClass) && is_a($extensionClass, ExtensionInterface::class, allow_string: true)) {
60+
/** @var ExtensionInterface $instance */
61+
$instance = new $extensionClass();
62+
$config = $instance->getConfig();
63+
64+
if (isset($config['stubs']) && \is_array($config['stubs'])) {
65+
foreach ($config['stubs'] as $stub) {
66+
if (\is_string($stub) && $stub !== '') {
67+
$extensionStubs[] = $stub;
68+
}
69+
}
70+
}
71+
}
72+
}
73+
74+
return array_unique($extensionStubs);
75+
}
4576
}

src/Internal/Config.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ public static function getProjectRoot(): string
126126
return self::$projectRoot;
127127
}
128128

129-
// Search upwards from this file (vendor/typephp/typephp/src/Internal -> project root)
130129
$dir = __DIR__;
131130
for ($i = 0; $i < 10; $i++) {
132131
if (file_exists($dir . '/vendor/autoload.php')) {
@@ -140,7 +139,6 @@ public static function getProjectRoot(): string
140139
$dir = $parent;
141140
}
142141

143-
// Fallback: Search upwards from getcwd() (for monorepos or test runners)
144142
$cwd = getcwd();
145143
if ($cwd !== false) {
146144
$dir = $cwd;
@@ -192,6 +190,7 @@ public static function get(): array
192190
'include' => ['src/**', 'app/**', 'internals/**', 'tests/**'],
193191
'exclude' => ['vendor/**', 'storage/**', 'var/**', 'cache/**'],
194192
'extensions' => [],
193+
'stubs' => [],
195194
];
196195

197196
$projectRoot = self::getProjectRoot();
@@ -210,7 +209,11 @@ public static function get(): array
210209
$configuredExtensions = \is_array($userConfig['extensions'] ?? null) ? $userConfig['extensions'] : [];
211210

212211
$extensionIncludes = ExtensionManager::loadExtensionIncludes($configuredExtensions);
212+
$extensionStubs = ExtensionManager::loadExtensionStubs($configuredExtensions);
213+
213214
$defaultConfig['include'] = array_unique(array_merge($defaultConfig['include'], $extensionIncludes));
215+
$defaultConfig['stubs'] = array_unique(array_merge($defaultConfig['stubs'], $extensionStubs));
216+
214217
/** @var array<string, mixed> $mergedConfig */
215218
$mergedConfig = array_replace_recursive($defaultConfig, $userConfig);
216219

@@ -229,6 +232,21 @@ public static function set(array $config): void
229232
/** @var array<string, mixed> $mergedConfig */
230233
$mergedConfig = array_replace_recursive(self::get(), $config);
231234

235+
if (isset($config['extensions']) && \is_array($config['extensions'])) {
236+
/** @var array<int, class-string<ExtensionInterface>> $configuredExtensions */
237+
$configuredExtensions = $config['extensions'];
238+
$extensionIncludes = ExtensionManager::loadExtensionIncludes($configuredExtensions);
239+
$extensionStubs = ExtensionManager::loadExtensionStubs($configuredExtensions);
240+
241+
/** @var array<int, string> $currentIncludes */
242+
$currentIncludes = \is_array($mergedConfig['include'] ?? null) ? $mergedConfig['include'] : [];
243+
/** @var array<int, string> $currentStubs */
244+
$currentStubs = \is_array($mergedConfig['stubs'] ?? null) ? $mergedConfig['stubs'] : [];
245+
246+
$mergedConfig['include'] = array_unique(array_merge($currentIncludes, $extensionIncludes));
247+
$mergedConfig['stubs'] = array_unique(array_merge($currentStubs, $extensionStubs));
248+
}
249+
232250
self::$cachedConfig = $mergedConfig;
233251
self::syncFlags($mergedConfig);
234252

@@ -238,6 +256,7 @@ public static function set(array $config): void
238256
FileFilter::reset();
239257
PathMatcher::reset();
240258
StreamWrapper::reset();
259+
StubManager::reset();
241260
}
242261

243262
/**
@@ -263,6 +282,7 @@ public static function reset(): void
263282
FileFilter::reset();
264283
PathMatcher::reset();
265284
StreamWrapper::reset();
285+
StubManager::reset();
266286
}
267287

268288
/**

0 commit comments

Comments
 (0)