From 17482373c1c38be8987a9d4b22151f0221d8f253 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 20 Aug 2026 18:02:53 +0800 Subject: [PATCH 1/5] Add tdd for event classes and tests for method-level and class-level generics --- tests/Fixtures/Events/ConsoleCommandEvent.php | 12 +++++++ tests/Fixtures/Events/ConsoleErrorEvent.php | 12 +++++++ .../Events/TestEventDispatcherInterface.php | 17 +++++++++ tests/Fixtures/Events/TestFlowDispatcher.php | 13 +++++++ .../Events/TestHybridGenericContainer.php | 32 +++++++++++++++++ tests/Fixtures/Events/UserRegisteredEvent.php | 12 +++++++ .../MethodLevelTemplateDispatchTest.php | 36 +++++++++++++++++++ 7 files changed, 134 insertions(+) create mode 100644 tests/Fixtures/Events/ConsoleCommandEvent.php create mode 100644 tests/Fixtures/Events/ConsoleErrorEvent.php create mode 100644 tests/Fixtures/Events/TestEventDispatcherInterface.php create mode 100644 tests/Fixtures/Events/TestFlowDispatcher.php create mode 100644 tests/Fixtures/Events/TestHybridGenericContainer.php create mode 100644 tests/Fixtures/Events/UserRegisteredEvent.php create mode 100644 tests/TypeChecking/Generics/MethodLevelTemplateDispatchTest.php diff --git a/tests/Fixtures/Events/ConsoleCommandEvent.php b/tests/Fixtures/Events/ConsoleCommandEvent.php new file mode 100644 index 0000000..2473eab --- /dev/null +++ b/tests/Fixtures/Events/ConsoleCommandEvent.php @@ -0,0 +1,12 @@ +dispatch($commandEvent); + expect($result1)->toBeInstanceOf(ConsoleCommandEvent::class); + + $errorEvent = new ConsoleErrorEvent('connection timeout'); + $result2 = $dispatcher->dispatch($errorEvent); + expect($result2)->toBeInstanceOf(ConsoleErrorEvent::class); + + $userEvent = new UserRegisteredEvent(100); + $result3 = $dispatcher->dispatch($userEvent); + expect($result3)->toBeInstanceOf(UserRegisteredEvent::class); + }); + + test('maintains class-level template in WeakMap while allowing method-level template to vary per call', function () { + $container = new TestHybridGenericContainer('initial_string_data'); + $res1 = $container->convert(new ConsoleCommandEvent()); + expect($res1)->toBeInstanceOf(ConsoleCommandEvent::class); + + $res2 = $container->convert(new ConsoleErrorEvent()); + expect($res2)->toBeInstanceOf(ConsoleErrorEvent::class); + }); +}); \ No newline at end of file From 9150274f34e20cf6f6dca1de135b7766d5947d9e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 20 Aug 2026 18:45:28 +0800 Subject: [PATCH 2/5] Enhance template handling in ContractParser and related classes; unify method and class templates for improved type resolution and error handling --- src/Contract/ContractParser.php | 46 ++--- src/Internal/Checker/GeneratorChecker.php | 8 +- src/Internal/Checker/ParamChecker.php | 76 ++++---- src/Internal/Checker/ReturnChecker.php | 8 +- src/Internal/ErrorFactory.php | 174 ++++++++++++++---- src/Internal/RuntimeTypeChecker.php | 15 +- src/Resolver/TemplateManager.php | 49 ++--- src/bootstrap.php | 2 +- tests/Fixtures/Events/ConsoleCommandEvent.php | 2 +- tests/Fixtures/Events/ConsoleErrorEvent.php | 2 +- .../Events/TestEventDispatcherInterface.php | 2 +- tests/Fixtures/Events/TestFlowDispatcher.php | 2 +- .../Events/TestHybridGenericContainer.php | 2 +- tests/Fixtures/Events/UserRegisteredEvent.php | 2 +- .../GenericCallablesTest.php | 2 +- .../MethodLevelTemplateDispatchTest.php | 2 +- 16 files changed, 258 insertions(+), 136 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 3b34519..768ca3d 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -30,7 +30,7 @@ final class ContractParser /** * Cache for resolved contract metadata. * - * @var array, templates: array, return: ?TypeNode, aliases: array}> + * @var array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array}> */ private static array $cache = []; @@ -64,7 +64,7 @@ public static function reset(): void /** * Parses PHPDoc contracts for a function or class method. * - * @return array{types: array, templates: array, return: ?TypeNode, aliases: array} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array} */ public static function parse(string $function): array { @@ -83,25 +83,26 @@ public static function parse(string $function): array $ref = $refClass->getMethod($methodName); $contract = self::parseMethod($ref); } else { - $templates = []; + $classTemplates = []; $aliases = []; - self::parseClassLevelDocs($refClass, $templates, $aliases); + self::parseClassLevelDocs($refClass, $classTemplates, $aliases); $contract = [ 'types' => [], - 'templates' => $templates, + 'templates' => [], + 'classTemplates' => $classTemplates, 'return' => null, 'aliases' => $aliases, ]; } } else { - $contract = ['types' => [], 'templates' => [], 'return' => null, 'aliases' => []]; + $contract = ['types' => [], 'templates' => [], 'classTemplates' => [], 'return' => null, 'aliases' => []]; } } else { $ref = new \ReflectionFunction($function); $contract = self::parseFunction($ref); } } catch (\ReflectionException $e) { - $contract = ['types' => [], 'templates' => [], 'return' => null, 'aliases' => []]; + $contract = ['types' => [], 'templates' => [], 'classTemplates' => [], 'return' => null, 'aliases' => []]; } return self::$cache[$function] = $contract; @@ -161,8 +162,8 @@ public static function parseProperty(string $className, string $propertyName): ? } $aliases = []; - $templates = []; - self::parseClassLevelDocs($declaringClass, $templates, $aliases); + $classTemplates = []; + self::parseClassLevelDocs($declaringClass, $classTemplates, $aliases); if (! $isMagicProperty) { $phpDocNode = DocblockExtractor::parseDocString($doc); @@ -276,8 +277,8 @@ public static function parseMagicMethod(string $className, string $methodName): } $aliases = []; - $templates = []; - self::parseClassLevelDocs($declaringClass, $templates, $aliases); + $classTemplates = []; + self::parseClassLevelDocs($declaringClass, $classTemplates, $aliases); $phpDocNode = DocblockExtractor::parseDocString($doc); DocblockExtractor::extractAliases($phpDocNode, $aliases, $declaringClass); @@ -294,7 +295,7 @@ public static function parseMagicMethod(string $className, string $methodName): 'return' => $resolvedReturn, 'parameters' => $resolvedParams, 'aliases' => $aliases, - 'templates' => $templates, + 'templates' => $classTemplates, ]; } catch (\Throwable $e) { return self::$magicMethodCache[$cacheKey] = null; @@ -415,8 +416,8 @@ public static function parseClassAliases(string $className): array /** @var class-string $className */ $refClass = new \ReflectionClass($className); $aliases = []; - $templates = []; - self::parseClassLevelDocs($refClass, $templates, $aliases); + $classTemplates = []; + self::parseClassLevelDocs($refClass, $classTemplates, $aliases); return $aliases; } catch (\Throwable $e) { @@ -427,17 +428,18 @@ public static function parseClassAliases(string $className): array /** * Orchestrates parsing for class methods across the inheritance hierarchy. * - * @return array{types: array, templates: array, return: ?TypeNode, aliases: array} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array} */ private static function parseMethod(\ReflectionMethod $ref): array { $types = []; - $templates = []; + $methodTemplates = []; + $classTemplates = []; $returnType = null; $aliases = []; - self::parseClassLevelDocs($ref->getDeclaringClass(), $templates, $aliases); - self::parseMethodHierarchyDocs($ref, $types, $templates, $returnType, $aliases); + self::parseClassLevelDocs($ref->getDeclaringClass(), $classTemplates, $aliases); + self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases); if ($ref->getName() === '__construct') { self::applyConstructorPromotionFallback($ref, $types); @@ -445,7 +447,8 @@ private static function parseMethod(\ReflectionMethod $ref): array return [ 'types' => $types, - 'templates' => $templates, + 'templates' => $methodTemplates, + 'classTemplates' => $classTemplates, 'return' => $returnType, 'aliases' => $aliases, ]; @@ -454,7 +457,7 @@ private static function parseMethod(\ReflectionMethod $ref): array /** * Orchestrates parsing for standalone global or namespaced functions. * - * @return array{types: array, templates: array, return: ?TypeNode, aliases: array} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array} */ private static function parseFunction(\ReflectionFunction $ref): array { @@ -468,6 +471,7 @@ private static function parseFunction(\ReflectionFunction $ref): array return [ 'types' => [], 'templates' => [], + 'classTemplates' => [], 'return' => null, 'aliases' => [], ]; @@ -505,6 +509,7 @@ private static function parseFunction(\ReflectionFunction $ref): array return [ 'types' => $types, 'templates' => $templates, + 'classTemplates' => [], 'return' => $returnType, 'aliases' => $aliases, ]; @@ -543,7 +548,6 @@ 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 diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index e8ac63c..7516377 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -99,11 +99,11 @@ private static function resolveGeneratorReturnType(string $function, object|stri } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $templates = $contract['templates'] ?? []; - $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $allTemplates); - if (\count($boundTemplates) > 0 || \count($templates) > 0) { - $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $templates); + if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { + $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $allTemplates); $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 858eb01..a3034f6 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -52,14 +52,24 @@ public static function checkParams( return null; } - $templates = $contract['templates']; + $methodTemplates = $contract['templates']; + $classTemplates = $contract['classTemplates'] ?? []; $aliases = $contract['aliases']; - self::initializeCallContext($effectiveFunction, $thisObj, $templates); - self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $templates); + if (\count($methodTemplates) > 0) { + TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates); + } - $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates); - $declaredTemplates = $templates; + if ($thisObj !== null && \count($classTemplates) > 0 && str_contains($effectiveFunction, '::')) { + $declaringClass = explode('::', $effectiveFunction, 2)[0]; + TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); + } + + $allTemplates = [...$classTemplates, ...$methodTemplates]; + self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); + + $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates); + $declaredTemplates = $allTemplates; foreach ($contract['types'] as $paramName => $typeNode) { if (! \array_key_exists($paramName, $vars)) { @@ -72,7 +82,7 @@ public static function checkParams( $vars[$paramName], $effectiveFunction, $thisObj, - $templates, + $allTemplates, $aliases, $boundTemplates, $declaredTemplates, @@ -170,21 +180,6 @@ private static function handleMagicCall( return self::validateMagicArguments($magicContract, $magicArgs, $magicFunction, $thisObj, $registry); } - /** - * Initializes call stack frames or resolves inherited template bounds. - * - * @param array $templates - */ - private static function initializeCallContext(string $effectiveFunction, ?object $thisObj, array $templates): void - { - if ($thisObj === null && \count($templates) > 0) { - TemplateManager::clearCallBindings($effectiveFunction, $templates); - } elseif ($thisObj !== null && str_contains($effectiveFunction, '::')) { - $declaringClass = explode('::', $effectiveFunction, 2)[0]; - TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); - } - } - /** * Pre-infers generic template parameters from array arguments before callback wrapping. * @@ -227,7 +222,7 @@ private static function inferFromTypeNode( ): void { if ($typeNode instanceof GenericTypeNode) { $baseType = strtolower($typeNode->type->name); - if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], strict: true)) { + if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], true)) { return; } @@ -259,8 +254,13 @@ private static function bindTemplateIfUnbound( ?object $thisObj, array $templates ): void { - if (isset($templates[$templateName]) && ! TemplateManager::isBound($effectiveFunction, $thisObj, $templateName)) { - TemplateManager::bindTemplate($effectiveFunction, $thisObj, $templateName, TemplateManager::inferTypeFromValue($sampleValue)); + $contract = ContractParser::parse($effectiveFunction); + $classTemplates = $contract['classTemplates'] ?? []; + $isClassLevelTemplate = isset($classTemplates[$templateName]); + $targetObj = $isClassLevelTemplate ? $thisObj : null; + + if (isset($templates[$templateName]) && ! TemplateManager::isBound($effectiveFunction, $targetObj, $templateName)) { + TemplateManager::bindTemplate($effectiveFunction, $targetObj, $templateName, TemplateManager::inferTypeFromValue($sampleValue)); } } @@ -332,8 +332,6 @@ private static function validateMagicArguments( $aliases = $magicContract['aliases']; $parameters = $magicContract['parameters']; - self::initializeCallContext($function, $thisObj, $templates); - $argValues = array_values($args); $argKeys = array_keys($args); @@ -428,7 +426,12 @@ private static function resolveClassStringTemplate( $templateName = $innerType->name; $templateNode = $templates[$templateName]; - if (! TemplateManager::isBound($function, $thisObj, $templateName)) { + $contract = ContractParser::parse($function); + $classTemplates = $contract['classTemplates'] ?? []; + $isClassLevelTemplate = isset($classTemplates[$templateName]); + $targetObj = $isClassLevelTemplate ? $thisObj : null; + + if (! TemplateManager::isBound($function, $targetObj, $templateName)) { if (! \is_string($val) || ! ClassNameValidator::isValid($val) || (! class_exists($val) && ! interface_exists($val) && ! trait_exists($val) && ! enum_exists($val))) { return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val) . ' given'); } @@ -438,17 +441,17 @@ private static function resolveClassStringTemplate( $boundName = $resolvedBound instanceof IdentifierTypeNode ? $resolvedBound->name : (string) $resolvedBound; $lowerBound = strtolower($boundName); - if ($lowerBound !== 'object' && $lowerBound !== 'mixed' && ! is_a($val, $boundName, allow_string: true)) { + if ($lowerBound !== 'object' && $lowerBound !== 'mixed' && ! is_a($val, $boundName, true)) { return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundName . ", '" . $val . "' given"); } } - TemplateManager::bindTemplate($function, $thisObj, $templateName, new IdentifierTypeNode($val)); + TemplateManager::bindTemplate($function, $targetObj, $templateName, new IdentifierTypeNode($val)); } else { - $expectedTypeNode = TemplateManager::getBoundType($function, $thisObj, $templateName); + $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); $targetClass = $expectedTypeNode instanceof IdentifierTypeNode ? $expectedTypeNode->name : (string) $expectedTypeNode; - if (! \is_string($val) || ! is_a($val, $targetClass, allow_string: true)) { + if (! \is_string($val) || ! is_a($val, $targetClass, true)) { $valStr = TypeFormatter::formatGivenValue($val); return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a class-string of ' . $targetClass . ', ' . $valStr . ' given'); @@ -494,7 +497,12 @@ private static function resolveTemplateParam( $templateNode = $templates[$templateName]; $isVariadic = $typeNode instanceof ArrayTypeNode; - if (! TemplateManager::isBound($function, $thisObj, $templateName)) { + $contract = ContractParser::parse($function); + $classTemplates = $contract['classTemplates'] ?? []; + $isClassLevelTemplate = isset($classTemplates[$templateName]); + $targetObj = $isClassLevelTemplate ? $thisObj : null; + + if (! TemplateManager::isBound($function, $targetObj, $templateName)) { $sampleVal = ($isVariadic && \is_array($val)) ? ($val[0] ?? null) : $val; $inferredType = TemplateManager::inferTypeFromValue($sampleVal); @@ -506,7 +514,7 @@ private static function resolveTemplateParam( } } - TemplateManager::bindTemplate($function, $thisObj, $templateName, $inferredType); + TemplateManager::bindTemplate($function, $targetObj, $templateName, $inferredType); if ($isVariadic && \is_array($val)) { foreach ($val as $idx => $item) { @@ -517,7 +525,7 @@ private static function resolveTemplateParam( } } } else { - $expectedTypeNode = TemplateManager::getBoundType($function, $thisObj, $templateName); + $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); if ($expectedTypeNode === null) { return null; } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 37b8400..33bec40 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -64,6 +64,8 @@ public static function checkReturn( return $value; } + $allTemplates = [...($contract['classTemplates'] ?? []), ...($contract['templates'] ?? [])]; + return self::evaluateReturn( $returnTypeNode, $value, @@ -71,7 +73,7 @@ public static function checkReturn( $thisObj, $vars, $contract['aliases'] ?? [], - $contract['templates'] ?? [], + $allTemplates, $registry, $wrapIterableCallback ); @@ -230,7 +232,7 @@ private static function evaluateReturn( } $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; - if (\in_array($baseName, $genericIterables, strict: true)) { + if (\in_array($baseName, $genericIterables, true)) { return $wrapIterableCallback($function, 'return', $value); } } @@ -314,7 +316,7 @@ private static function resolveTemplateConditional( $isTargetMatch = ClassNameValidator::isValid($subStr) && ClassNameValidator::isValid($targetStr) && (class_exists($subStr) || interface_exists($subStr)) && (class_exists($targetStr) || interface_exists($targetStr)) && - is_a($subStr, $targetStr, allow_string: true); + is_a($subStr, $targetStr, true); } if ($node->negated) { diff --git a/src/Internal/ErrorFactory.php b/src/Internal/ErrorFactory.php index ee68a3a..01de6c7 100644 --- a/src/Internal/ErrorFactory.php +++ b/src/Internal/ErrorFactory.php @@ -13,6 +13,25 @@ */ final class ErrorFactory { + private const INTERNAL_DIR_PATTERNS = [ + 'src/Internal/', + 'src/Wrapper/', + 'src/Validator/', + 'src/Resolver/', + 'src/Contract/', + 'src/Command/', + 'bin/typephp', + ]; + + private const CALL_SITE_KEYWORDS = [ + 'argument $', + 'argument #', + 'callback ', + 'iterator $', + 'return iterator', + 'generator sent value', + ]; + /** * Creates an ErrorMessage value object containing formatted type failure details. */ @@ -26,9 +45,8 @@ public static function createError(string $message): ErrorMessage } /** - * Prepares a TypeError exception before throwing. - * For parameter, callback, iterator, and generator errors, it filters out internal library frames - * and sets the file and line to accurately blame the caller site. + * Prepares a TypeError exception before throwing by filtering internal library frames + * and repointing the exception to the actual application caller location. */ public static function prepareException(TypeError $e, ?int $line = null): TypeError { @@ -36,41 +54,128 @@ public static function prepareException(TypeError $e, ?int $line = null): TypeEr $targetLine = $line; $message = $e->getMessage(); - $isCallSiteError = str_contains($message, 'Argument $') - || str_contains($message, 'argument #') - || str_contains($message, 'Callback ') - || str_contains($message, 'Iterator $') - || str_contains($message, 'Return iterator') - || str_contains($message, 'Generator sent value'); - - if ($isCallSiteError) { - $trace = $e->getTrace(); - - foreach ($trace as $frame) { - if (isset($frame['file'], $frame['line'])) { - $file = str_replace('\\', '/', $frame['file']); - - $isInternal = str_contains($file, 'src/Internal/') - || str_contains($file, 'src/Wrapper/') - || str_contains($file, 'src/Validator/') - || str_contains($file, 'src/Resolver/') - || str_contains($file, 'src/Contract/'); - - if (! $isInternal) { - $targetFile = $frame['file']; - if ($targetLine === null) { - $targetLine = $frame['line']; - } - - break; - } + $isCallSite = self::isCallSiteError(strtolower($message)); + + /** @var array> $rawTrace */ + $rawTrace = $e->getTrace(); + $filteredTrace = self::filterTrace($rawTrace, $isCallSite, $targetFile, $targetLine); + + $sanitizedMessage = self::sanitizeMessage($message, $targetFile, $targetLine); + + self::mutateException($e, $sanitizedMessage, $targetFile, $targetLine, $filteredTrace); + + return $e; + } + + /** + * Checks if the error message indicates a caller argument or callback error. + */ + private static function isCallSiteError(string $lowerMessage): bool + { + foreach (self::CALL_SITE_KEYWORDS as $keyword) { + if (str_contains($lowerMessage, $keyword)) { + return true; + } + } + + return false; + } + + /** + * Filters out internal TypePHP frames from the raw stack trace. + * + * @param array> $trace + * + * @return array> + */ + private static function filterTrace( + array $trace, + bool $isCallSite, + ?string &$targetFile, + ?int &$targetLine + ): array { + $filtered = []; + + foreach ($trace as $frame) { + $file = isset($frame['file']) && \is_string($frame['file']) + ? str_replace('\\', '/', $frame['file']) + : ''; + + if (self::isInternalFile($file)) { + continue; + } + + // Sanitize internal closure class prefixes from remaining frames + if (isset($frame['class']) && \is_string($frame['class']) && str_starts_with($frame['class'], 'TypePHP\\')) { + unset($frame['class'], $frame['type']); + $frame['function'] = '{closure}'; + } + + $filtered[] = $frame; + + if ($isCallSite && $targetFile === null && isset($frame['file'], $frame['line'])) { + $targetFile = (string) $frame['file']; + if ($targetLine === null) { + $targetLine = (int) $frame['line']; } } } + return $filtered; + } + + /** + * Determines whether a file path belongs to TypePHP internals. + */ + private static function isInternalFile(string $normalizedFile): bool + { + if ($normalizedFile === '') { + return false; + } + + foreach (self::INTERNAL_DIR_PATTERNS as $pattern) { + if (str_contains($normalizedFile, $pattern)) { + return true; + } + } + + return false; + } + + /** + * Strips internal wrapper paths and CLI runner prefixes from PHP's error message. + */ + private static function sanitizeMessage(string $message, ?string $targetFile, ?int $targetLine): string + { + if (str_contains($message, 'CallableWrapper.php')) { + $cleaned = (string) preg_replace('/, called in .*?CallableWrapper\.php on line \d+/i', '', $message); + if ($targetFile !== null && $targetLine !== null) { + $cleaned .= ", called in {$targetFile} on line {$targetLine}"; + } + $message = $cleaned; + } + + return str_replace('TypePHP\Command\RunCommand::', '', $message); + } + + /** + * Uses reflection on base \Error to mutate private properties safely. + * + * @param array> $filteredTrace + */ + private static function mutateException( + TypeError $e, + string $message, + ?string $targetFile, + ?int $targetLine, + array $filteredTrace + ): void { try { $ref = new ReflectionClass(\Error::class); + $propMessage = $ref->getProperty('message'); + $propMessage->setValue($e, $message); + if ($targetFile !== null) { $propFile = $ref->getProperty('file'); $propFile->setValue($e, $targetFile); @@ -80,10 +185,13 @@ public static function prepareException(TypeError $e, ?int $line = null): TypeEr $propLine = $ref->getProperty('line'); $propLine->setValue($e, $targetLine); } + + if (\count($filteredTrace) > 0) { + $propTrace = $ref->getProperty('trace'); + $propTrace->setValue($e, $filteredTrace); + } } catch (Throwable $err) { // Silently fallback if reflection mutation fails } - - return $e; } } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 17cb42b..6a3c6c9 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -80,24 +80,19 @@ public static function setupScope(string $function, array $vars, object|string|n $err = self::checkParams($function, $vars, $thisOrClass); - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $contract = ContractParser::parse($function); + $methodTemplates = $contract['templates'] ?? []; + $hasMethodTemplates = \count($methodTemplates) > 0; if ($err !== null) { - if ($thisObj === null) { + if ($hasMethodTemplates) { TemplateManager::popCallFrame($function); } return $err; } - if ($thisObj !== null) { - return null; - } - - $contract = ContractParser::parse($function); - $hasTemplates = \count($contract['templates'] ?? []) > 0; - - return $hasTemplates ? new ScopeCleaner($function) : null; + return $hasMethodTemplates ? new ScopeCleaner($function) : null; } /** diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index c2c6149..1a5bdd7 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -27,7 +27,7 @@ use WeakMap; /** - * @internal Manages generic template bindings for object instances (via WeakMap) and static call stack frames. + * @internal Manages generic template bindings for object instances (via WeakMap) and static/method call stack frames. */ final class TemplateManager { @@ -108,6 +108,8 @@ public static function clearCallBindings(string $function, array $templates): vo */ public static function getBoundTemplates(string $function, ?object $thisObj, array $templates): array { + $bindings = []; + if ($thisObj !== null) { if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); @@ -118,17 +120,18 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr } if (isset(self::$instanceTemplateBindings[$thisObj])) { - return self::$instanceTemplateBindings[$thisObj]; + $bindings = self::$instanceTemplateBindings[$thisObj]; } } if (self::hasCallFrame($function)) { $topFrame = end(self::$callStackBindings[$function]); - - return $topFrame !== false ? $topFrame : []; + if ($topFrame !== false) { + $bindings = [...$bindings, ...$topFrame]; + } } - return []; + return $bindings; } /** @@ -186,6 +189,13 @@ public static function getTemplateVariances(object $instance): array */ public static function isBound(string $function, ?object $thisObj, string $templateName): bool { + if (self::hasCallFrame($function)) { + $topFrame = end(self::$callStackBindings[$function]); + if ($topFrame !== false && isset($topFrame[$templateName])) { + return true; + } + } + if ($thisObj !== null) { if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); @@ -198,12 +208,6 @@ public static function isBound(string $function, ?object $thisObj, string $templ return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); } - if (self::hasCallFrame($function)) { - $topFrame = end(self::$callStackBindings[$function]); - - return isset($topFrame[$templateName]); - } - return false; } @@ -212,6 +216,13 @@ public static function isBound(string $function, ?object $thisObj, string $templ */ public static function getBoundType(string $function, ?object $thisObj, string $templateName): ?TypeNode { + if (self::hasCallFrame($function)) { + $topFrame = end(self::$callStackBindings[$function]); + if ($topFrame !== false && isset($topFrame[$templateName])) { + return $topFrame[$templateName]; + } + } + if ($thisObj !== null) { if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); @@ -224,12 +235,6 @@ public static function getBoundType(string $function, ?object $thisObj, string $ return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; } - if (self::hasCallFrame($function)) { - $topFrame = end(self::$callStackBindings[$function]); - - return $topFrame[$templateName] ?? null; - } - return null; } @@ -258,7 +263,7 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { $className = $typeNode->type->name; - if (\in_array(strtolower($className), ['self', 'static', '$this'], strict: true)) { + if (\in_array(strtolower($className), ['self', 'static', '$this'], true)) { $className = \get_class($instance); } @@ -461,7 +466,7 @@ private static function bindInheritedGenericTag( string $actualClassName ): void { $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); - $isHierarchyMember = is_a($actualClassName, $parentName, allow_string: true) || trait_exists($parentName); + $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); if (! ClassNameValidator::isValid($parentName) || ! $isHierarchyMember) { return; @@ -654,7 +659,7 @@ private static function checkExistingIntersectionVariance(IntersectionTypeNode $ private static function checkNestedGenericVariance(GenericTypeNode $existing, GenericTypeNode $expected): bool { - if (! is_a($existing->type->name, $expected->type->name, allow_string: true)) { + if (! is_a($existing->type->name, $expected->type->name, true)) { return false; } @@ -673,7 +678,7 @@ private static function checkNestedGenericVariance(GenericTypeNode $existing, Ge private static function isSubclass(string $sub, string $super): bool { if (ClassNameValidator::isValid($sub) && ClassNameValidator::isValid($super) && (class_exists($sub) || interface_exists($sub)) && (class_exists($super) || interface_exists($super))) { - return is_a($sub, $super, allow_string: true); + return is_a($sub, $super, true); } return false; @@ -695,7 +700,7 @@ public static function bindInstance(object $instance, string $typeString, string } if ($typeNode instanceof GenericTypeNode) { - self::bindInstanceFromNode($instance, $typeNode, '', forceBind: true); + self::bindInstanceFromNode($instance, $typeNode, '', true); } } catch (\Throwable $e) { // Silently ignore malformed docblock strings diff --git a/src/bootstrap.php b/src/bootstrap.php index 05a3141..83fb62e 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -28,4 +28,4 @@ if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling) { TypePHP::boot(); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/ConsoleCommandEvent.php b/tests/Fixtures/Events/ConsoleCommandEvent.php index 2473eab..6a38e4a 100644 --- a/tests/Fixtures/Events/ConsoleCommandEvent.php +++ b/tests/Fixtures/Events/ConsoleCommandEvent.php @@ -9,4 +9,4 @@ class ConsoleCommandEvent public function __construct(public string $command = 'migrate') { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/ConsoleErrorEvent.php b/tests/Fixtures/Events/ConsoleErrorEvent.php index b3b475f..c035a5c 100644 --- a/tests/Fixtures/Events/ConsoleErrorEvent.php +++ b/tests/Fixtures/Events/ConsoleErrorEvent.php @@ -9,4 +9,4 @@ class ConsoleErrorEvent public function __construct(public string $error = 'database error') { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/TestEventDispatcherInterface.php b/tests/Fixtures/Events/TestEventDispatcherInterface.php index d67dbbb..aa8eb49 100644 --- a/tests/Fixtures/Events/TestEventDispatcherInterface.php +++ b/tests/Fixtures/Events/TestEventDispatcherInterface.php @@ -14,4 +14,4 @@ interface TestEventDispatcherInterface * @return TEvent */ public function dispatch(object $event): object; -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/TestFlowDispatcher.php b/tests/Fixtures/Events/TestFlowDispatcher.php index 62f8a40..5a2822b 100644 --- a/tests/Fixtures/Events/TestFlowDispatcher.php +++ b/tests/Fixtures/Events/TestFlowDispatcher.php @@ -10,4 +10,4 @@ public function dispatch(object $event): object { return $event; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/TestHybridGenericContainer.php b/tests/Fixtures/Events/TestHybridGenericContainer.php index 7f2703d..e78b553 100644 --- a/tests/Fixtures/Events/TestHybridGenericContainer.php +++ b/tests/Fixtures/Events/TestHybridGenericContainer.php @@ -29,4 +29,4 @@ public function convert(object $output): object { return $output; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Events/UserRegisteredEvent.php b/tests/Fixtures/Events/UserRegisteredEvent.php index dfa033e..16614e0 100644 --- a/tests/Fixtures/Events/UserRegisteredEvent.php +++ b/tests/Fixtures/Events/UserRegisteredEvent.php @@ -9,4 +9,4 @@ class UserRegisteredEvent public function __construct(public int $userId = 42) { } -} \ No newline at end of file +} diff --git a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php index e5aae56..f63a72d 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php @@ -111,7 +111,7 @@ function testGenericComparator(callable $comparator, mixed $a, mixed $b): bool expect($result)->toBe(['alpha' => 'alpha:10', 'beta' => 'beta:20']); expect(fn () => $service->mapWithKey($combiner, [0 => 10])) - ->toThrow(TypeError::class, 'key') + ->toThrow(TypeError::class, '$k') ; }); diff --git a/tests/TypeChecking/Generics/MethodLevelTemplateDispatchTest.php b/tests/TypeChecking/Generics/MethodLevelTemplateDispatchTest.php index d88c1eb..16c813b 100644 --- a/tests/TypeChecking/Generics/MethodLevelTemplateDispatchTest.php +++ b/tests/TypeChecking/Generics/MethodLevelTemplateDispatchTest.php @@ -33,4 +33,4 @@ $res2 = $container->convert(new ConsoleErrorEvent()); expect($res2)->toBeInstanceOf(ConsoleErrorEvent::class); }); -}); \ No newline at end of file +}); From 06127aa3610b2c0403924bb40a7237f9e42b2816 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 20 Aug 2026 18:50:49 +0800 Subject: [PATCH 3/5] Refactor ErrorFactory to streamline exception handling and improve type annotations for trace filtering --- src/Internal/ErrorFactory.php | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Internal/ErrorFactory.php b/src/Internal/ErrorFactory.php index 01de6c7..7b6a7f7 100644 --- a/src/Internal/ErrorFactory.php +++ b/src/Internal/ErrorFactory.php @@ -56,9 +56,7 @@ public static function prepareException(TypeError $e, ?int $line = null): TypeEr $message = $e->getMessage(); $isCallSite = self::isCallSiteError(strtolower($message)); - /** @var array> $rawTrace */ - $rawTrace = $e->getTrace(); - $filteredTrace = self::filterTrace($rawTrace, $isCallSite, $targetFile, $targetLine); + $filteredTrace = self::filterTrace($e->getTrace(), $isCallSite, $targetFile, $targetLine); $sanitizedMessage = self::sanitizeMessage($message, $targetFile, $targetLine); @@ -84,9 +82,8 @@ private static function isCallSiteError(string $lowerMessage): bool /** * Filters out internal TypePHP frames from the raw stack trace. * - * @param array> $trace - * - * @return array> + * @param list> $trace + * @return list> */ private static function filterTrace( array $trace, @@ -113,10 +110,10 @@ private static function filterTrace( $filtered[] = $frame; - if ($isCallSite && $targetFile === null && isset($frame['file'], $frame['line'])) { - $targetFile = (string) $frame['file']; + if ($isCallSite && $targetFile === null && isset($frame['file'], $frame['line']) && \is_string($frame['file']) && \is_int($frame['line'])) { + $targetFile = $frame['file']; if ($targetLine === null) { - $targetLine = (int) $frame['line']; + $targetLine = $frame['line']; } } } @@ -161,7 +158,7 @@ private static function sanitizeMessage(string $message, ?string $targetFile, ?i /** * Uses reflection on base \Error to mutate private properties safely. * - * @param array> $filteredTrace + * @param list> $filteredTrace */ private static function mutateException( TypeError $e, From 8fb097cae3fa617e3b5bb0eaa852627c5548ca50 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 20 Aug 2026 18:57:03 +0800 Subject: [PATCH 4/5] Add step to warm up TypePHP cache in CI workflow --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fd3df5..4431b56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: run: ./vendor/bin/phpstan analyse --no-progress if: matrix.os == 'ubuntu-latest' && matrix.php == '8.3' + - name: Warm Up TypePHP Cache + run: php bin/typephp cache:warm + - name: Run Test Suite with Coverage (Pest) run: ./vendor/bin/pest --coverage-clover=clover.xml if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' From a44b2472e541c8cac5d4d1010c46c06340d37d6f Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 20 Aug 2026 19:04:29 +0800 Subject: [PATCH 5/5] Update Pest test suite command to include compact option for improved output --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4431b56..1ce977c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: php bin/typephp cache:warm - name: Run Test Suite with Coverage (Pest) - run: ./vendor/bin/pest --coverage-clover=clover.xml + run: ./vendor/bin/pest --coverage-clover=clover.xml --compact if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' - name: Upload Coverage to Codecov