From e0904e1b647a64c7fa0e28e16e244d052c30fa8d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 21:00:12 +0800 Subject: [PATCH 01/16] Improve global function compatibility with generic type checking --- src/Internal/Checker/InlineChecker.php | 272 ++++++++++++++++-- src/Internal/Checker/ParamChecker.php | 1 + .../Visitor/FunctionContractInjector.php | 13 +- src/Resolver/TemplateManager.php | 2 +- .../HeterogeneousArrayGenericsTest.php | 7 +- .../FunctionTemplateInlineVarTest.php | 126 ++++++++ .../UnspecializedGenericDefaultTest.php | 2 +- 7 files changed, 395 insertions(+), 28 deletions(-) create mode 100644 tests/TypeChecking/Generics/FunctionTemplateInlineVarTest.php diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index a7d3eae..eae4ad1 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -7,11 +7,16 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; +use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -36,18 +41,26 @@ final class InlineChecker { /** - * In-memory cache for tokenized and parsed TypeNode ASTs keyed by normalized type string. + * In-memory cache for tokenized and parsed TypeNode ASTs and their context necessity flag. * - * @var array + * @var array */ private static array $parsedTypeNodeCache = []; /** - * Resets internal type node caches. Useful for test isolation. + * Memoized cache for PHP internal function determinations. + * + * @var array + */ + private static array $internalFunctionsCache = []; + + /** + * Resets internal type node and function caches. Useful for test isolation. */ public static function reset(): void { self::$parsedTypeNodeCache = []; + self::$internalFunctionsCache = []; } /** @@ -112,13 +125,15 @@ public static function checkVariable(mixed $value, string $typeString, string $v try { $normalized = DocblockNormalizer::normalize($typeString); - $typeNode = self::parseTypeString($normalized); + [$typeNode, $needsContext] = self::parseTypeString($normalized); if ($file !== '') { $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); } - $typeNode = self::resolveCallerContext($typeNode); + if ($needsContext) { + $typeNode = self::resolveCallerContext($typeNode); + } if (! self::shouldValidateType($typeNode, $config)) { return $value; @@ -210,27 +225,158 @@ private static function hasActiveInlineChecks(array $config): bool } /** - * Resolves caller class context and applies class-level and method-level templates & type aliases to the AST. + * Resolves caller class or function context and applies templates & type aliases to the AST. */ private static function resolveCallerContext(TypeNode $typeNode): TypeNode + { + $frameInfo = self::findCallerFrame(); + + if ($frameInfo['functionName'] !== null) { + return self::resolveFunctionContext($typeNode, $frameInfo['functionName']); + } + + if ($frameInfo['className'] !== null) { + return self::resolveClassContext( + $typeNode, + $frameInfo['className'], + $frameInfo['methodName'], + $frameInfo['thisObj'] + ); + } + + return $typeNode; + } + + /** + * Inspects the backtrace to find the nearest non-internal caller frame. + * + * @return array{className: ?string, methodName: ?string, functionName: ?string, thisObj: ?object} + */ + private static function findCallerFrame(): array { $className = null; $methodName = null; + $functionName = null; $thisObj = null; - $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 7); + + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 15); foreach ($trace as $frame) { $classCandidate = $frame['class'] ?? null; - if ($classCandidate !== null && ! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { - $className = $classCandidate; - $methodName = $frame['function']; - $thisObj = $frame['object'] ?? null; + $funcCandidate = $frame['function'] ?? null; + + if ($classCandidate === 'Closure' || $classCandidate === 'Generator') { + if ($thisObj === null && isset($frame['object']) && \is_object($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { + $thisObj = $frame['object']; + } + + continue; + } + + if ($funcCandidate === '{closure}' || ($funcCandidate !== null && str_starts_with($funcCandidate, '{closure'))) { + if ($thisObj === null && isset($frame['object']) && \is_object($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { + $thisObj = $frame['object']; + } + + continue; + } + + if ($classCandidate !== null) { + if (! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { + $className = $classCandidate; + $methodName = $funcCandidate; + if ($thisObj === null) { + $thisObj = $frame['object'] ?? null; + } + + break; + } + } else { + if ($funcCandidate !== null && ! str_starts_with($funcCandidate, 'TypePHP\\')) { + if (! \in_array($funcCandidate, ['include', 'include_once', 'require', 'require_once', 'eval'], true)) { + if (self::isInternalFunction($funcCandidate)) { + continue; + } - break; + $functionName = $funcCandidate; + + break; + } + } } } - if ($className === null || (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className))) { + return [ + 'className' => $className, + 'methodName' => $methodName, + 'functionName' => $functionName, + 'thisObj' => $thisObj, + ]; + } + + /** + * Fast check if a function name represents an internal PHP built-in function. + */ + private static function isInternalFunction(string $funcName): bool + { + if (! \function_exists($funcName)) { + return false; + } + + if (isset(self::$internalFunctionsCache[$funcName])) { + return self::$internalFunctionsCache[$funcName]; + } + + try { + $rf = new \ReflectionFunction($funcName); + + return self::$internalFunctionsCache[$funcName] = $rf->isInternal(); + } catch (\ReflectionException $e) { + return self::$internalFunctionsCache[$funcName] = false; + } + } + + /** + * Resolves templates and aliases within standalone functions. + */ + private static function resolveFunctionContext(TypeNode $typeNode, string $functionName): TypeNode + { + if (! \function_exists($functionName)) { + return $typeNode; + } + + try { + $refFunc = new \ReflectionFunction($functionName); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refFunc); + + $contract = ContractParser::parse($functionName); + $declaredTemplates = $contract['templates'] ?? []; + $aliases = $contract['aliases'] ?? []; + $boundTemplates = TemplateManager::getBoundTemplates($functionName, null, $declaredTemplates); + + $activeBindings = [...$aliases, ...$boundTemplates]; + + if (\count($activeBindings) > 0 || \count($declaredTemplates) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $declaredTemplates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refFunc); + } + } catch (\ReflectionException $e) { + // Silently continue if reflection fails + } + + return $typeNode; + } + + /** + * Resolves templates, aliases, and class context within class methods. + */ + private static function resolveClassContext( + TypeNode $typeNode, + string $className, + ?string $methodName, + ?object $thisObj + ): TypeNode { + if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className)) { return $typeNode; } @@ -241,7 +387,7 @@ private static function resolveCallerContext(TypeNode $typeNode): TypeNode $classAliases = ContractParser::parseClassAliases($className); - $targetFunc = ($methodName !== '{closure}') + $targetFunc = ($methodName !== '{closure}' && $methodName !== null) ? $className . '::' . $methodName : $className . '::__construct'; @@ -249,7 +395,7 @@ private static function resolveCallerContext(TypeNode $typeNode): TypeNode $declaredTemplates = $contract['allTemplates'] ?? ($contract['classTemplates'] ?? []); $boundTemplates = TemplateManager::getBoundTemplates($targetFunc, $thisObj, $declaredTemplates); - $activeBindings = array_merge($classAliases, $boundTemplates); + $activeBindings = [...$classAliases, ...$boundTemplates]; if (\count($activeBindings) > 0 || \count($declaredTemplates) > 0) { $typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $declaredTemplates); @@ -292,9 +438,97 @@ private static function substitutePropertyGenerics(TypeNode $typeNode, object $o } /** - * Parses and caches a type string into a TypeNode AST. + * Inspects a TypeNode to check if it requires caller context resolution (templates, aliases, self/static/$this). + */ + private static function needsContextResolution(TypeNode $node): bool + { + if ($node instanceof ThisTypeNode || $node instanceof OffsetAccessTypeNode || $node instanceof ConditionalTypeNode || $node instanceof ConditionalTypeForParameterNode) { + return true; + } + + if ($node instanceof IdentifierTypeNode) { + $lower = strtolower($node->name); + if (\in_array($lower, ['self', 'static', 'parent', '$this'], true)) { + return true; + } + + return ! SpecialTypeResolver::isBuiltInTypeKeyword($lower); + } + + if ($node instanceof GenericTypeNode) { + return true; + } + + if ($node instanceof ConstTypeNode) { + $constExpr = $node->constExpr; + if ($constExpr instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode && $constExpr->className !== '') { + return true; + } + + return false; + } + + if ($node instanceof NullableTypeNode || $node instanceof ArrayTypeNode) { + return self::needsContextResolution($node->type); + } + + if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { + foreach ($node->types as $subType) { + if (self::needsContextResolution($subType)) { + return true; + } + } + + return false; + } + + if ($node instanceof ArrayShapeNode) { + foreach ($node->items as $item) { + if (self::needsContextResolution($item->valueType)) { + return true; + } + } + + if ($node->unsealedType !== null) { + if ($node->unsealedType->keyType !== null && self::needsContextResolution($node->unsealedType->keyType)) { + return true; + } + + return self::needsContextResolution($node->unsealedType->valueType); + } + + return false; + } + + if ($node instanceof ObjectShapeNode) { + foreach ($node->items as $item) { + if (self::needsContextResolution($item->valueType)) { + return true; + } + } + + return false; + } + + if ($node instanceof CallableTypeNode) { + foreach ($node->parameters as $p) { + if (self::needsContextResolution($p->type)) { + return true; + } + } + + return self::needsContextResolution($node->returnType); + } + + return false; + } + + /** + * Parses and caches a type string into a TypeNode AST along with its context requirement flag. + * + * @return array{0: TypeNode, 1: bool} */ - private static function parseTypeString(string $typeString): TypeNode + private static function parseTypeString(string $typeString): array { if (isset(self::$parsedTypeNodeCache[$typeString])) { return self::$parsedTypeNodeCache[$typeString]; @@ -302,8 +536,10 @@ private static function parseTypeString(string $typeString): TypeNode [$typeParser, $lexer] = self::getTypeParserComponents(); $tokens = new TokenIterator($lexer->tokenize($typeString)); + $typeNode = $typeParser->parse($tokens); + $needsContext = self::needsContextResolution($typeNode); - return self::$parsedTypeNodeCache[$typeString] = $typeParser->parse($tokens); + return self::$parsedTypeNodeCache[$typeString] = [$typeNode, $needsContext]; } /** diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index d12cf5b..4b3febb 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -249,6 +249,7 @@ private static function preInferGenericArrayTemplates( foreach ($types as $tNode) { if ($tNode instanceof CallableTypeNode) { $hasCallableParam = true; + break; } } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 3816ed2..77f9c99 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -139,7 +139,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 @@ -537,8 +537,10 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi 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 { @@ -592,12 +594,13 @@ public function enterNode(Node $n): int|Node|null private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid, bool $needsReturnVars = false): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars) extends NodeVisitorAbstract { public function __construct( private Node\Expr $thisArg, private bool $isNativeVoid, private bool $needsReturnVars - ) {} + ) { + } public function enterNode(Node $n): int|array|null { diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 30aef57..b601783 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -622,7 +622,7 @@ private static function bindSingleTemplateArgument( $templateName = $templateTag->name; $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; - if (isset($existingBindings[$templateName])) { + if (isset($existingBindings[$templateName])) { $existingTypeNode = $existingBindings[$templateName]; $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); diff --git a/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php b/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php index c25ff9e..50ef2ab 100644 --- a/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php +++ b/tests/TypeChecking/ArraysAndShapes/HeterogeneousArrayGenericsTest.php @@ -59,11 +59,12 @@ function testHigherOrderMap(array $array, callable $cb): array }); test('higher-order functions with callables still pre-infer template and enforce consistency', function () { - $stringify = fn(int $x): string => "num_{$x}"; + $stringify = fn (int $x): string => "num_{$x}"; expect(testHigherOrderMap([10, 20], $stringify))->toBe(['num_10', 'num_20']); - expect(fn() => testHigherOrderMap([10, 'not_an_int'], $stringify)) - ->toThrow(TypeError::class, "['1']"); + expect(fn () => testHigherOrderMap([10, 'not_an_int'], $stringify)) + ->toThrow(TypeError::class, "['1']") + ; }); }); diff --git a/tests/TypeChecking/Generics/FunctionTemplateInlineVarTest.php b/tests/TypeChecking/Generics/FunctionTemplateInlineVarTest.php new file mode 100644 index 0000000..f167a12 --- /dev/null +++ b/tests/TypeChecking/Generics/FunctionTemplateInlineVarTest.php @@ -0,0 +1,126 @@ + + */ +function testGenericRangeFunction(mixed $start, mixed $end, mixed $step = 1): array +{ + /** @var T $step */ + $step = 1; + + return [$start, $step, $end]; +} + +/** + * Generic function with inline @var list + * + * @template T + * + * @param T $item + * + * @return list + */ +function testGenericListWrapFunction(mixed $item): array +{ + /** @var list $list */ + $list = [$item]; + + return $list; +} + +/** + * Generic function with a closure accessing @var T + * + * @template T + * + * @param T $val + * + * @return T + */ +function testGenericFunctionWithClosure(mixed $val): mixed +{ + $closure = function () use ($val): mixed { + /** @var T $inner */ + $inner = $val; + + return $inner; + }; + + return $closure(); +} + +/** + * Standalone function with local @phpstan-type alias on inline @var + * + * @phpstan-type LocalId positive-int + */ +function testStandaloneFunctionAlias(int $id): int +{ + /** @var LocalId $localId */ + $localId = $id; + + return $localId; +} + +/** + * @template T + * + * @param T $x + */ +function testBadGenericFunction(mixed $x): mixed +{ + /** @var T $val */ + $val = 'not_an_int'; + + return $val; +} + +describe('Standalone Generic Function Template Resolution in Inline @var', function () { + test('resolves inline @var T when called from within a test class (Tempest range() reproduction)', function () { + $result = testGenericRangeFunction(0, 9, 2); + + expect($result)->toBe([0, 1, 9]); + }); + + test('resolves inline @var T when T is inferred as float', function () { + $result = testGenericRangeFunction(0.0, 9.0, 2.0); + + expect($result)->toBe([0.0, 1, 9.0]); + }); + + test('throws TypeError with resolved concrete type when inline variable violates bound template T', function () { + expect(fn () => testBadGenericFunction(42)) + ->toThrow(TypeError::class, 'must be of type int') + ; + }); + + test('resolves inline @var list in standalone generic function', function () { + expect(testGenericListWrapFunction('hello'))->toBe(['hello']); + expect(testGenericListWrapFunction(123))->toBe([123]); + }); + + test('resolves inline @var T inside closures defined within standalone generic functions', function () { + expect(testGenericFunctionWithClosure(500))->toBe(500); + expect(testGenericFunctionWithClosure('valid_string'))->toBe('valid_string'); + }); + + test('resolves @phpstan-type aliases on inline @var inside standalone functions', function () { + expect(testStandaloneFunctionAlias(42))->toBe(42); + + expect(fn () => testStandaloneFunctionAlias(-5)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); +}); diff --git a/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php b/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php index cbe6d8a..e192724 100644 --- a/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php +++ b/tests/TypeChecking/Generics/UnspecializedGenericDefaultTest.php @@ -61,4 +61,4 @@ function consumeMiddlewareContainer(GenericMiddlewareContainer $container): bool ->toThrow(TypeError::class, 'expects GenericMiddlewareContainer, but GenericMiddlewareContainer was given') ; }); -}); \ No newline at end of file +}); From 5ccd9b4d2332e9b748ec8233f59332f753acf351 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 21:55:54 +0800 Subject: [PATCH 02/16] Fix union upper generic bound not properly working and eagerly locking to the first type a generic tempalte encounter --- src/Internal/Checker/InlineChecker.php | 10 +- src/Internal/Checker/ParamChecker.php | 269 +++++++++++++++--- .../TemplateBoundWideningUnificationTest.php | 115 ++++++++ 3 files changed, 351 insertions(+), 43 deletions(-) create mode 100644 tests/TypeChecking/Generics/TemplateBoundWideningUnificationTest.php diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index eae4ad1..17832e2 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -263,18 +263,18 @@ private static function findCallerFrame(): array foreach ($trace as $frame) { $classCandidate = $frame['class'] ?? null; - $funcCandidate = $frame['function'] ?? null; + $funcCandidate = $frame['function']; if ($classCandidate === 'Closure' || $classCandidate === 'Generator') { - if ($thisObj === null && isset($frame['object']) && \is_object($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { + if ($thisObj === null && isset($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { $thisObj = $frame['object']; } continue; } - if ($funcCandidate === '{closure}' || ($funcCandidate !== null && str_starts_with($funcCandidate, '{closure'))) { - if ($thisObj === null && isset($frame['object']) && \is_object($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { + if ($funcCandidate === '{closure}' || str_starts_with($funcCandidate, '{closure')) { + if ($thisObj === null && isset($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { $thisObj = $frame['object']; } @@ -292,7 +292,7 @@ private static function findCallerFrame(): array break; } } else { - if ($funcCandidate !== null && ! str_starts_with($funcCandidate, 'TypePHP\\')) { + if (! str_starts_with($funcCandidate, 'TypePHP\\')) { if (! \in_array($funcCandidate, ['include', 'include_once', 'require', 'require_once', 'eval'], true)) { if (self::isInternalFunction($funcCandidate)) { continue; diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 4b3febb..4558843 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -363,9 +363,7 @@ private static function validateSingleParam( } $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); - $isBareTemplate = self::getTemplateName($typeNode, $templates) !== null; - $shouldSkipTemplateSub = $isBareTemplate || $isClassStringT; if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) { @@ -627,56 +625,251 @@ private static function resolveTemplateParam( $targetObj = $isClassLevelTemplate ? $thisObj : null; if (! TemplateManager::isBound($function, $targetObj, $templateName)) { - $sampleVal = ($isVariadic && \is_array($val)) ? ($val[0] ?? null) : $val; - $inferredType = TemplateManager::inferTypeFromValue($sampleVal); + return self::bindInitialTemplate( + $val, + $paramName, + $function, + $thisObj, + $targetObj, + $templateName, + $templateNode, + $isVariadic, + $isClassLevelTemplate, + $registry + ); + } - if ($templateNode->bound !== null) { - $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); - $err = $registry->validate($sampleVal, $resolvedBound, $function . '(): Argument $' . $paramName . ' (template ' . $templateName . ')'); - if ($err !== null) { - return $err; - } + return self::validateBoundTemplate( + $val, + $paramName, + $function, + $thisObj, + $targetObj, + $templateName, + $templateNode, + $isVariadic, + $isClassLevelTemplate, + $registry + ); + } + + private static function bindInitialTemplate( + mixed $val, + string $paramName, + string $function, + ?object $thisObj, + ?object $targetObj, + string $templateName, + TemplateTagValueNode $templateNode, + bool $isVariadic, + bool $isClassLevelTemplate, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + $sampleVal = ($isVariadic && \is_array($val)) ? ($val[0] ?? null) : $val; + $inferredType = TemplateManager::inferTypeFromValue($sampleVal); + + if ($templateNode->bound !== null) { + $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + $err = $registry->validate($sampleVal, $resolvedBound, $function . '(): Argument $' . $paramName . ' (template ' . $templateName . ')'); + if ($err !== null) { + return $err; } + } + + TemplateManager::bindTemplate($function, $targetObj, $templateName, $inferredType); + + if ($isVariadic && \is_array($val)) { + return self::validateVariadicList( + $val, + $paramName, + $function, + $thisObj, + $targetObj, + $templateName, + $templateNode, + $inferredType, + $isClassLevelTemplate, + $registry + ); + } + + return null; + } + + private static function validateBoundTemplate( + mixed $val, + string $paramName, + string $function, + ?object $thisObj, + ?object $targetObj, + string $templateName, + TemplateTagValueNode $templateNode, + bool $isVariadic, + bool $isClassLevelTemplate, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); + if ($expectedTypeNode === null) { + return null; + } + if ($expectedTypeNode instanceof IdentifierTypeNode && $expectedTypeNode->name === $templateName) { + $inferredType = TemplateManager::inferTypeFromValue($val); TemplateManager::bindTemplate($function, $targetObj, $templateName, $inferredType); - if ($isVariadic && \is_array($val)) { - foreach ($val as $idx => $item) { - $err = $registry->validate($item, $inferredType, $function . '(): Argument $' . $paramName . '[' . $idx . '] (template ' . $templateName . ' = ' . $inferredType . ')'); - if ($err !== null) { - return $err; - } + return null; + } + + if ($isVariadic && \is_array($val)) { + return self::validateVariadicList( + $val, + $paramName, + $function, + $thisObj, + $targetObj, + $templateName, + $templateNode, + $expectedTypeNode, + $isClassLevelTemplate, + $registry + ); + } + + $context = $function . '(): Argument $' . $paramName; + $err = $registry->validate($val, $expectedTypeNode, $context . ' (template ' . $templateName . ' = ' . $expectedTypeNode . ')'); + + if ($err !== null) { + return self::tryWidenTemplate( + $val, + $expectedTypeNode, + $templateNode, + $isClassLevelTemplate, + $function, + $thisObj, + $targetObj, + $templateName, + $context, + $err, + $registry + ); + } + + return null; + } + + /** + * @param array $items + */ + private static function validateVariadicList( + array $items, + string $paramName, + string $function, + ?object $thisObj, + ?object $targetObj, + string $templateName, + TemplateTagValueNode $templateNode, + TypeNode &$currentType, + bool $isClassLevelTemplate, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + foreach ($items as $idx => $item) { + $context = $function . '(): Argument $' . $paramName . '[' . $idx . ']'; + $err = $registry->validate($item, $currentType, $context . ' (template ' . $templateName . ' = ' . $currentType . ')'); + + if ($err !== null) { + $widenErr = self::tryWidenTemplate( + $item, + $currentType, + $templateNode, + $isClassLevelTemplate, + $function, + $thisObj, + $targetObj, + $templateName, + $context, + $err, + $registry + ); + + if ($widenErr !== null) { + return $widenErr; } + + $currentType = TemplateManager::getBoundType($function, $targetObj, $templateName) ?? $currentType; } - } else { - $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); - if ($expectedTypeNode === null) { - return null; - } + } - if ($expectedTypeNode instanceof IdentifierTypeNode && $expectedTypeNode->name === $templateName) { - $inferredType = TemplateManager::inferTypeFromValue($val); - TemplateManager::bindTemplate($function, $targetObj, $templateName, $inferredType); + return null; + } - return null; + private static function tryWidenTemplate( + mixed $val, + TypeNode $currentType, + TemplateTagValueNode $templateNode, + bool $isClassLevelTemplate, + string $function, + ?object $thisObj, + ?object $targetObj, + string $templateName, + string $context, + ErrorMessage $originalError, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + if ($isClassLevelTemplate || $templateNode->bound === null) { + return $originalError; + } + + $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + $boundErr = $registry->validate($val, $resolvedBound, $context . ' (template ' . $templateName . ')'); + + if ($boundErr === null) { + $newInferred = TemplateManager::inferTypeFromValue($val); + $unifiedType = self::unifyTypes($currentType, $newInferred); + TemplateManager::bindTemplate($function, $targetObj, $templateName, $unifiedType); + + return null; + } + + return $boundErr; + } + + /** + * Unifies two types into a combined UnionTypeNode, deduplicating identical member types. + */ + private static function unifyTypes(TypeNode $type1, TypeNode $type2): TypeNode + { + if ((string) $type1 === (string) $type2) { + return $type1; + } + + $types = []; + + if ($type1 instanceof UnionTypeNode) { + $types = $type1->types; + } else { + $types[] = $type1; + } + + if ($type2 instanceof UnionTypeNode) { + foreach ($type2->types as $t) { + $types[] = $t; } + } else { + $types[] = $type2; + } - if ($isVariadic && \is_array($val)) { - foreach ($val as $idx => $item) { - $err = $registry->validate($item, $expectedTypeNode, $function . '(): Argument $' . $paramName . '[' . $idx . '] (template ' . $templateName . ' = ' . $expectedTypeNode . ')'); - if ($err !== null) { - return $err; - } - } - } else { - $err = $registry->validate($val, $expectedTypeNode, $function . '(): Argument $' . $paramName . ' (template ' . $templateName . ' = ' . $expectedTypeNode . ')'); - if ($err !== null) { - return $err; - } + $unique = []; + $deduped = []; + + foreach ($types as $t) { + $str = (string) $t; + if (! isset($unique[$str])) { + $unique[$str] = true; + $deduped[] = $t; } } - return null; + return \count($deduped) === 1 ? $deduped[0] : new UnionTypeNode($deduped); } private static function typeContainsNull(TypeNode $node): bool diff --git a/tests/TypeChecking/Generics/TemplateBoundWideningUnificationTest.php b/tests/TypeChecking/Generics/TemplateBoundWideningUnificationTest.php new file mode 100644 index 0000000..fdf368c --- /dev/null +++ b/tests/TypeChecking/Generics/TemplateBoundWideningUnificationTest.php @@ -0,0 +1,115 @@ + + */ +function testTempestRangePattern(int|float $start, int|float $end, int|float|null $step = 1): array +{ + return [$start, $end, $step]; +} + +/** + * Generic function with object upper bound (@template T of Animal) + * + * @template T of Animal + * + * @param T $first + * @param T $second + * + * @return list + */ +function testAnimalPairUnification(Animal $first, Animal $second): array +{ + return [$first, $second]; +} + +/** + * Variadic generic parameters with upper bound + * + * @template T of int|float + * + * @param T ...$numbers + * + * @return list + */ +function testVariadicBoundWidening(int|float ...$numbers): array +{ + return $numbers; +} + +/** + * Class with method-level template bound widening + */ +class MathServiceWithGenerics +{ + /** + * @template T of int|float + * + * @param T $a + * @param T $b + * + * @return T + */ + public function sum(int|float $a, int|float $b): int|float + { + return $a + $b; + } +} + +describe('Template Bound Widening and Type Unification (@template T of Bound)', function () { + test('reproduces Tempest range(0, 9.8798, 0.48) argument type widening', function () { + $result = testTempestRangePattern(0, 9.8798, 0.48); + + expect($result)->toBe([0, 9.8798, 0.48]); + }); + + test('unifies different subclasses of the declared upper bound (Dog and Cat for Animal)', function () { + $dog = new Dog(); + $cat = new Cat(); + + $result = testAnimalPairUnification($dog, $cat); + + expect($result)->toBe([$dog, $cat]); + }); + + test('unifies mixed int and float across method parameters on classes', function () { + $service = new MathServiceWithGenerics(); + + $result = $service->sum(10, 2.5); + + expect($result)->toBe(12.5); + }); + + test('widens variadic arguments satisfying the template upper bound', function () { + $result = testVariadicBoundWidening(1, 2.5, 3, 4.75); + + expect($result)->toBe([1, 2.5, 3, 4.75]); + }); + + test('throws TypeError when an argument violates the template upper bound entirely', function () { + expect(fn () => testTempestRangePattern(0, 'invalid', 1)) + ->toThrow(TypeError::class, 'must be of type') + ; + }); + + test('throws TypeError when an object argument violates the class upper bound', function () { + expect(fn () => testAnimalPairUnification(new Dog(), new Car())) + ->toThrow(TypeError::class, 'must be of type') + ; + }); +}); From d724eb849c9ebad9f482c03f6465dc744506f783 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 22:26:00 +0800 Subject: [PATCH 03/16] Add support for native return type nullability checks and related tests --- src/Contract/ContractParser.php | 136 ++++++++++++++++-- .../RespectNativeReturnNullabilityTest.php | 102 +++++++++++++ 2 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index c4cd6d1..d944c72 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -60,6 +60,72 @@ final class ContractParser */ private static array $classLevelDocCache = []; + /** + * Fast O(1) lookup matrix for scalar refinements compatible with PHP native built-in types. + * + * @var array> + */ + private const BUILTIN_REFINEMENTS = [ + 'int' => [ + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + ], + 'integer' => [ + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + ], + 'string' => [ + 'non-empty-string' => true, + 'numeric-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'class-string' => true, + 'interface-string' => true, + 'trait-string' => true, + 'enum-string' => true, + 'callable-string' => true, + 'literal-string' => true, + 'truthy-string' => true, + 'non-falsy-string' => true, + ], + 'float' => [ + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'double' => true, + ], + 'double' => [ + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'double' => true, + ], + 'bool' => [ + 'true' => true, + 'false' => true, + 'boolean' => true, + ], + 'boolean' => [ + 'true' => true, + 'false' => true, + 'boolean' => true, + ], + ]; + /** * Resets the contract, property, and class-level docblock caches. */ @@ -586,6 +652,14 @@ private static function parseFunction(\ReflectionFunction $ref): array if ($returnTag !== null) { $substitutedReturn = self::substituteAliases($returnTag->type, $aliases); $returnType = SpecialTypeResolver::resolve($substitutedReturn, $ref); + + if ( + Config::isRespectNativeNullabilityEnabled() + && self::returnTypeExplicitlyAllowsNull($ref) + && ! self::typeContainsNull($returnType) + ) { + $returnType = new NullableTypeNode($returnType); + } } return [ @@ -757,6 +831,14 @@ private static function parseMethodHierarchyDocs( if ($returnTag !== null) { $substitutedReturn = self::substituteAliases($returnTag->type, $aliases); $returnType = SpecialTypeResolver::resolve($substitutedReturn, $hierRef); + + if ( + Config::isRespectNativeNullabilityEnabled() + && self::returnTypeExplicitlyAllowsNull($ref) + && ! self::typeContainsNull($returnType) + ) { + $returnType = new NullableTypeNode($returnType); + } } } } @@ -877,13 +959,19 @@ private static function applyConstructorPromotionFallback( */ private static function isRefinementOfBuiltin(string $refinement, string $builtin): bool { - return match ($builtin) { - 'int', 'integer' => \in_array($refinement, ['positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int', 'unsigned-int'], true) || str_starts_with($refinement, 'int<'), - 'string' => \in_array($refinement, ['non-empty-string', 'numeric-string', 'lowercase-string', 'non-empty-lowercase-string', 'uppercase-string', 'non-empty-uppercase-string', 'class-string', 'interface-string', 'trait-string', 'enum-string', 'callable-string', 'literal-string', 'truthy-string', 'non-falsy-string'], true) || str_starts_with($refinement, 'class-string<'), - 'float', 'double' => \in_array($refinement, ['positive-float', 'negative-float', 'non-positive-float', 'non-negative-float', 'non-zero-float', 'double'], true), - 'bool', 'boolean' => \in_array($refinement, ['true', 'false', 'boolean'], true), - default => false, - }; + if (isset(self::BUILTIN_REFINEMENTS[$builtin][$refinement])) { + return true; + } + + if (($builtin === 'int' || $builtin === 'integer') && str_starts_with($refinement, 'int<')) { + return true; + } + + if ($builtin === 'string' && str_starts_with($refinement, 'class-string<')) { + return true; + } + + return false; } /** @@ -912,6 +1000,32 @@ private static function parameterExplicitlyAllowsNull(\ReflectionParameter $p): return false; } + /** + * Checks if a reflection function or method explicitly declares a nullable native return type (excluding mixed, void, and never). + */ + private static function returnTypeExplicitlyAllowsNull(\ReflectionFunctionAbstract $ref): bool + { + if (! $ref->hasReturnType()) { + return false; + } + + $type = $ref->getReturnType(); + if ($type instanceof \ReflectionNamedType) { + $name = strtolower($type->getName()); + if ($name === 'mixed' || $name === 'void' || $name === 'never') { + return false; + } + + return $type->allowsNull(); + } + + if ($type instanceof \ReflectionUnionType) { + return $type->allowsNull(); + } + + return false; + } + /** * Checks if a TypeNode already represents or contains null. */ @@ -953,7 +1067,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -987,7 +1101,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -1004,7 +1118,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1019,7 +1133,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php b/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php new file mode 100644 index 0000000..cbe11f6 --- /dev/null +++ b/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php @@ -0,0 +1,102 @@ + + */ +function testNativeNullableReturnFunction(bool $returnNull): ?array +{ + if ($returnNull) { + return null; + } + + return [10, 20, 30]; +} + +/** + * Function with PHP 8.0+ native union return (array|null), but non-nullable DocBlock + * + * @return list + */ +function testNativeUnionReturnFunction(bool $returnNull): array|null +{ + if ($returnNull) { + return null; + } + + return [10, 20, 30]; +} + +class ReturnNullabilityService +{ + /** + * Method with native : ?string, but non-nullable DocBlock + * + * @return non-empty-string + */ + public function findUsername(bool $returnNull): ?string + { + if ($returnNull) { + return null; + } + + return 'Alice'; + } +} + +describe('Respect Native Return Type Nullability', function () { + afterEach(function () { + Config::reset(); + }); + + describe('When respect_native_nullability is true (Default Mode)', function () { + test('allows null on function with native : ?array even when DocBlock omitted |null', function () { + Config::set(['respect_native_nullability' => true]); + + expect(testNativeNullableReturnFunction(true))->toBeNull(); + expect(testNativeNullableReturnFunction(false))->toBe([10, 20, 30]); + }); + + test('allows null on method with native : ?string even when DocBlock omitted |null', function () { + Config::set(['respect_native_nullability' => true]); + $service = new ReturnNullabilityService(); + + expect($service->findUsername(true))->toBeNull(); + expect($service->findUsername(false))->toBe('Alice'); + }); + + test('allows null on function with native union return (array|null) even when DocBlock omitted |null', function () { + Config::set(['respect_native_nullability' => true]); + + expect(testNativeUnionReturnFunction(true))->toBeNull(); + expect(testNativeUnionReturnFunction(false))->toBe([10, 20, 30]); + }); + }); + + describe('When respect_native_nullability is false (Strict Pedantic Mode)', function () { + test('rejects null when DocBlock omitted |null even if native return has : ?array', function () { + Config::set(['respect_native_nullability' => false]); + + expect(fn () => testNativeNullableReturnFunction(true)) + ->toThrow(TypeError::class, 'none returned'); + + expect(testNativeNullableReturnFunction(false))->toBe([10, 20, 30]); + }); + + test('rejects null when DocBlock omitted |null even if native return has : ?string', function () { + Config::set(['respect_native_nullability' => false]); + $service = new ReturnNullabilityService(); + + expect(fn () => $service->findUsername(true)) + ->toThrow(TypeError::class, 'none returned'); + + expect($service->findUsername(false))->toBe('Alice'); + }); + }); +}); \ No newline at end of file From 5eda6d3c1bd38ad6d803c77d783ac12acedf39e4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 23:48:59 +0800 Subject: [PATCH 04/16] Improve Cache manager temp file handling --- src/Internal/CacheManager.php | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index 8d48d75..87bc237 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -27,7 +27,7 @@ public static function reset(): void } /** - * Returns the absolute path to the cache directory, isolating by system user and parallel test worker if using temp dir. + * Returns the absolute path to the cache directory, isolating by system user if using temp dir. */ public static function getCacheDir(): string { @@ -55,20 +55,7 @@ public static function getCacheDir(): string $user = (string) getmyuid(); } - $testToken = getenv('TEST_TOKEN'); - $uniqueToken = getenv('UNIQUE_TEST_TOKEN'); - $pestWorkerId = getenv('PEST_PARALLEL_WORKER_ID'); - - $workerToken = '0'; - if (\is_string($testToken) && $testToken !== '') { - $workerToken = $testToken; - } elseif (\is_string($uniqueToken) && $uniqueToken !== '') { - $workerToken = $uniqueToken; - } elseif (\is_string($pestWorkerId) && $pestWorkerId !== '') { - $workerToken = $pestWorkerId; - } - - $userHash = hash('xxh128', 'typephp_' . $user . '_w' . $workerToken); + $userHash = hash('xxh128', 'typephp_' . $user); return self::$resolvedCacheDir = sys_get_temp_dir() . '/typephp-cache-' . $userHash; } @@ -123,7 +110,7 @@ public static function ensureSecureCacheDir(): bool /** * Safely writes cached content atomically to avoid symlink traversal attacks. */ - public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool + public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool { if (! self::ensureSecureCacheDir()) { return false; @@ -132,7 +119,7 @@ public static function writeCachedFileSafely(string $cachedFile, string $transfo $cacheDir = \dirname($cachedFile); $tmpFile = $cacheDir . '/.tmp_' . bin2hex(random_bytes(8)); - if (@file_put_contents($tmpFile, $transformed, LOCK_EX) === false) { + if (@file_put_contents($tmpFile, $transformed) === false) { return false; } @@ -148,8 +135,7 @@ public static function writeCachedFileSafely(string $cachedFile, string $transfo } /** - * Clears all cached transformed files from the cache directory, - * including all parallel worker directories (_w1, _w2, etc.). + * Clears all cached transformed files from the cache directory. */ public static function clear(): int { @@ -293,4 +279,4 @@ private static function findFilesToWarm(string $baseDir): array return $files; } -} +} \ No newline at end of file From 3b68755a6c4d4dc7d562e96aa5b19c7e7746b627 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Mon, 31 Aug 2026 23:49:17 +0800 Subject: [PATCH 05/16] Fix edge cases in Cache Manager --- src/Internal/CacheManager.php | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index 87bc237..0008d99 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -80,7 +80,9 @@ public static function getCachedFilePath(string $resolvedPath): string } /** - * Ensures the cache directory exists securely with strict 0700 ownership. + * Ensures the cache directory exists securely. + * Enforces strict UID/0700 checks on system temp dirs, while allowing + * standard application permissions on custom user-configured directories. */ public static function ensureSecureCacheDir(): bool { @@ -90,32 +92,41 @@ public static function ensureSecureCacheDir(): bool return false; } + $config = Config::get(); + $isCustomDir = \is_string($config['cache_dir'] ?? null) && $config['cache_dir'] !== ''; + if (! is_dir($cacheDir)) { - if (! @mkdir($cacheDir, 0700, recursive: true) && ! is_dir($cacheDir)) { + $mode = $isCustomDir ? 0775 : 0700; + if (! @mkdir($cacheDir, $mode, recursive: true) && ! is_dir($cacheDir)) { return false; } - @chmod($cacheDir, 0700); + if (! $isCustomDir) { + @chmod($cacheDir, 0700); + } } - if (\function_exists('posix_geteuid')) { + if (! $isCustomDir && \function_exists('posix_geteuid')) { $owner = @fileowner($cacheDir); if ($owner !== false && $owner !== posix_geteuid()) { return false; } } - return true; + return is_writable($cacheDir); } /** - * Safely writes cached content atomically to avoid symlink traversal attacks. + * Safely writes cached content atomically to avoid corruption or partial reads. */ - public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool + public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool { if (! self::ensureSecureCacheDir()) { return false; } + $config = Config::get(); + $isCustomDir = \is_string($config['cache_dir'] ?? null) && $config['cache_dir'] !== ''; + $cacheDir = \dirname($cachedFile); $tmpFile = $cacheDir . '/.tmp_' . bin2hex(random_bytes(8)); @@ -123,7 +134,7 @@ public static function writeCachedFileSafely(string $cachedFile, string $transfo return false; } - @chmod($tmpFile, 0600); + @chmod($tmpFile, $isCustomDir ? 0664 : 0600); if (! @rename($tmpFile, $cachedFile)) { @unlink($tmpFile); @@ -279,4 +290,4 @@ private static function findFilesToWarm(string $baseDir): array return $files; } -} \ No newline at end of file +} From 302e04b65de2f3be4f0e06aef180e11808613555 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 01:34:33 +0800 Subject: [PATCH 06/16] Experimental optimization --- src/Internal/Config.php | 6 ++++++ src/Internal/PathMatcher.php | 25 ++++++++++++++++++++++++- src/Internal/StreamWrapper.php | 24 +++++++++++++----------- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 5bfe0e0..e81bc78 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -138,6 +138,12 @@ public static function getProjectRoot(): string return self::$projectRoot; } + $rootCandidate = str_replace('\\', '/', \dirname(__DIR__, 2)); + + if (file_exists($rootCandidate . '/vendor/autoload.php') || file_exists($rootCandidate . '/composer.json') || file_exists($rootCandidate . '/typephp.php')) { + return self::$projectRoot = rtrim($rootCandidate, '/'); + } + $dir = __DIR__; for ($i = 0; $i < 10; $i++) { if (file_exists($dir . '/vendor/autoload.php')) { diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index 8a2c206..6f44301 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -128,6 +128,29 @@ public static function isVendorPath(string $normalizedPath, string $rawPath = '' || ($canonRaw !== '' && (str_starts_with($canonRaw, 'vendor/') || str_contains($canonRaw, '/vendor/'))); } + /** + * Determines whether a given path belongs to an immutable, static source code repository + * (e.g. src/, app/, lib/, packages/, vendor/). + */ + public static function isStaticSourcePath(string $normalizedPath): bool + { + $canon = self::canonicalizePath($normalizedPath); + + if (self::isDynamicWritablePath($canon)) { + return false; + } + + if (str_contains($canon, '/tmp/') || str_contains($canon, '/Fixtures/tmp') || str_contains($canon, '/fixtures/tmp')) { + return false; + } + + return str_starts_with($canon, 'vendor/') || str_contains($canon, '/vendor/') + || str_starts_with($canon, 'src/') || str_contains($canon, '/src/') + || str_starts_with($canon, 'app/') || str_contains($canon, '/app/') + || str_starts_with($canon, 'lib/') || str_contains($canon, '/lib/') + || str_starts_with($canon, 'packages/') || str_contains($canon, '/packages/'); + } + /** * Determines whether a given path is within the TypePHP cache directory. */ @@ -405,4 +428,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin return self::$compiledExcludesCache[$cacheKey] = $compiled; } -} +} \ No newline at end of file diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 879de9b..3355689 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,14 +219,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn () => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; + $exists = (bool) self::silent(fn() => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? fopen($target, $mode, false, $this->context) : fopen($target, $mode) ); @@ -270,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ $handle = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? fopen($targetFile, $mode, $useIncludePath, $this->context) : fopen($targetFile, $mode, $useIncludePath) ); @@ -425,19 +425,21 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn () => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { $isPhp = str_ends_with(strtolower($normalized), '.php'); - if ($isPhp || PathMatcher::isVendorPath($normalized)) { + $isStaticDir = is_dir($path) && PathMatcher::isStaticSourcePath($normalized); + + if ($isPhp || PathMatcher::isVendorPath($normalized) || $isStaticDir) { self::$statCache[$cacheKey] = $result; } return $result; } - if (PathMatcher::isVendorPath($normalized)) { + if (PathMatcher::isStaticSourcePath($normalized)) { self::$staticNegativeStatCache[$normalized] = true; } @@ -460,11 +462,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn () => @chmod($path, $mode)); + $result = (bool) self::silent(fn() => @chmod($path, $mode)); } self::register(); @@ -476,7 +478,7 @@ public function dir_opendir(string $path, int $options): bool self::unregister(); /** @var resource|false $dh */ $dh = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? @opendir($path, $this->context) : @opendir($path) ); @@ -603,7 +605,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn () => true); + set_error_handler(static fn() => true); try { return $callback(); From e596b4d0b2b2b9f4daffba567aa47096fca009d6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 01:52:00 +0800 Subject: [PATCH 07/16] Enhance isStaticSourcePath method to improve path validation for static source code repositories --- src/Internal/PathMatcher.php | 37 +++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index 6f44301..c821cb5 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -129,8 +129,7 @@ public static function isVendorPath(string $normalizedPath, string $rawPath = '' } /** - * Determines whether a given path belongs to an immutable, static source code repository - * (e.g. src/, app/, lib/, packages/, vendor/). + * Determines whether a given path belongs to an immutable, static source code repositor */ public static function isStaticSourcePath(string $normalizedPath): bool { @@ -140,15 +139,35 @@ public static function isStaticSourcePath(string $normalizedPath): bool return false; } - if (str_contains($canon, '/tmp/') || str_contains($canon, '/Fixtures/tmp') || str_contains($canon, '/fixtures/tmp')) { + if ( + str_contains($canon, '/tests/') || str_starts_with($canon, 'tests/') + || str_contains($canon, '/Fixtures/') || str_contains($canon, '/fixtures/') + || str_contains($canon, '/tmp/') || str_contains($canon, '/install/') + ) { return false; } - return str_starts_with($canon, 'vendor/') || str_contains($canon, '/vendor/') - || str_starts_with($canon, 'src/') || str_contains($canon, '/src/') - || str_starts_with($canon, 'app/') || str_contains($canon, '/app/') - || str_starts_with($canon, 'lib/') || str_contains($canon, '/lib/') - || str_starts_with($canon, 'packages/') || str_contains($canon, '/packages/'); + if (str_starts_with($canon, 'vendor/') || str_contains($canon, '/vendor/')) { + return true; + } + + if (str_starts_with($canon, 'src/') || str_contains($canon, '/src/')) { + return true; + } + + if (str_starts_with($canon, 'app/') || str_contains($canon, '/app/')) { + return true; + } + + if (str_starts_with($canon, 'lib/') || str_contains($canon, '/lib/')) { + return true; + } + + if (preg_match('#(^|/)packages/[^/]+/src/#', $canon) === 1) { + return true; + } + + return false; } /** @@ -428,4 +447,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin return self::$compiledExcludesCache[$cacheKey] = $compiled; } -} \ No newline at end of file +} From 4b884369a7423a4846b3caf59d38546cb7c2950b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 02:10:22 +0800 Subject: [PATCH 08/16] Refactor bootstrap logic to enhance parallel processing detection and add new test cases for package path inclusion --- src/bootstrap.php | 12 ++- tests/Contract/PackagePathInclusionTest.php | 102 ++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 tests/Contract/PackagePathInclusionTest.php diff --git a/src/bootstrap.php b/src/bootstrap.php index 93e0060..31a482b 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -37,7 +37,6 @@ 'composer' => true, 'deptrac' => true, 'phan' => true, - 'paratest' => true, ]; $isTooling = isset($toolingBinaries[$binary]); @@ -48,7 +47,7 @@ if (isset($_SERVER['argv']) && \is_array($_SERVER['argv'])) { $hasParallelArg = false; foreach ($_SERVER['argv'] as $arg) { - if (\is_string($arg) && (str_starts_with($arg, '--parallel') || $arg === '-p' || str_starts_with($arg, '--processes'))) { + if (\is_string($arg) && (str_starts_with($arg, '--parallel') || $arg === '-p' || str_starts_with($arg, '--processes') || str_starts_with($arg, '--runner'))) { $hasParallelArg = true; break; @@ -57,7 +56,12 @@ $isParallelParent = ($binary === 'paratest') || ($binary === 'pest' && $hasParallelArg); - if (getenv('TEST_TOKEN') !== false || getenv('PARATEST') !== false || getenv('PEST_PARALLEL_WORKER_ID') !== false) { + if ( + getenv('TEST_TOKEN') !== false + || getenv('PARATEST') !== false + || getenv('UNIQUE_TEST_TOKEN') !== false + || getenv('PEST_PARALLEL_WORKER_ID') !== false + ) { $isParallelParent = false; } } @@ -65,4 +69,4 @@ if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling && ! $isParallelParent) { TypePHP::boot(); } -} +} \ No newline at end of file diff --git a/tests/Contract/PackagePathInclusionTest.php b/tests/Contract/PackagePathInclusionTest.php new file mode 100644 index 0000000..f9e44f6 --- /dev/null +++ b/tests/Contract/PackagePathInclusionTest.php @@ -0,0 +1,102 @@ + [ + 'src/**', + 'packages/**/src/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'packages/**/tests/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + + $coreSource = str_replace('\\', '/', $projectRoot . '/packages/core/src/Application.php'); + $supportSource = str_replace('\\', '/', $projectRoot . '/packages/support/src/Arr/functions.php'); + $consoleSource = str_replace('\\', '/', $projectRoot . '/packages/console/src/Input/ConsoleArgumentBag.php'); + + expect(FileFilter::isFileExcluded($coreSource))->toBeFalse() + ->and(FileFilter::isFileExcluded($supportSource))->toBeFalse() + ->and(FileFilter::isFileExcluded($consoleSource))->toBeFalse(); + }); + + test('correctly excludes package test files with packages/**/tests/** glob', function () { + Config::set([ + 'include' => [ + 'src/**', + 'packages/**/src/**', + ], + 'exclude' => [ + 'vendor/**', + 'packages/**/tests/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + + $packageTest = str_replace('\\', '/', $projectRoot . '/packages/support/tests/Filesystem/UnixFunctionsTest.php'); + $coreTest = str_replace('\\', '/', $projectRoot . '/packages/core/tests/ApplicationTest.php'); + + expect(FileFilter::isFileExcluded($packageTest))->toBeTrue() + ->and(FileFilter::isFileExcluded($coreTest))->toBeTrue(); + }); + + test('verifies PathMatcher includes deep package source files and excludes package test files', function () { + $projectRoot = '/home/runner/work/tempest-framework/tempest-framework'; + $includes = [ + 'src/**', + 'packages/**/src/**', + ]; + $excludes = [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + 'packages/**/tests/**', + ]; + + expect(PathMatcher::isPathIncluded($projectRoot . '/packages/core/src/Application.php', $includes, $excludes, '', $projectRoot))->toBeTrue() + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/support/src/Arr/functions.php', $includes, $excludes, '', $projectRoot))->toBeTrue() + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/console/src/Input/ConsoleArgumentBag.php', $includes, $excludes, '', $projectRoot))->toBeTrue() + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/http/src/IsRequest.php', $includes, $excludes, '', $projectRoot))->toBeTrue(); + + expect(PathMatcher::isPathIncluded($projectRoot . '/packages/support/tests/Filesystem/UnixFunctionsTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse() + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/core/tests/ApplicationTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse() + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/console/tests/ConsoleArgumentBagTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse(); + }); + + test('verifies isStaticSourcePath accurately classifies package source paths vs test fixture paths', function () { + $pkgSourcePath = '/home/runner/work/tempest-framework/tempest-framework/packages/core/src/Application.php'; + $pkgTestFixturePath = '/home/runner/work/tempest-framework/tempest-framework/packages/support/tests/Filesystem/Fixtures/file.txt'; + $viteInstallFixturePath = '/home/runner/work/tempest-framework/tempest-framework/tests/Integration/Vite/install/app/main.entrypoint.ts'; + + expect(PathMatcher::isStaticSourcePath($pkgSourcePath))->toBeTrue() + ->and(PathMatcher::isStaticSourcePath($pkgTestFixturePath))->toBeFalse() + ->and(PathMatcher::isStaticSourcePath($viteInstallFixturePath))->toBeFalse(); + }); +}); \ No newline at end of file From 3e24431bd0c96723066588873ea42ff8387e6fb9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 02:28:23 +0800 Subject: [PATCH 09/16] Fix bug on getting root folder where it did not get the proper value of this library is installed as vendor dependency --- src/Contract/ContractParser.php | 8 ++-- src/Internal/Config.php | 47 ++++++++++--------- src/Internal/StreamWrapper.php | 18 +++---- src/bootstrap.php | 12 ++--- tests/Contract/PackagePathInclusionTest.php | 17 ++++--- tests/Internal/ConfigTest.php | 39 +++++++++++++++ .../RespectNativeReturnNullabilityTest.php | 8 ++-- 7 files changed, 96 insertions(+), 53 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index d944c72..0b96b2d 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -1067,7 +1067,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -1101,7 +1101,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -1118,7 +1118,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); @@ -1133,7 +1133,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof IntersectionTypeNode) { $types = array_map( - fn($t) => self::substituteAliases($t, $aliases), + fn ($t) => self::substituteAliases($t, $aliases), $node->types ); diff --git a/src/Internal/Config.php b/src/Internal/Config.php index e81bc78..b22fc82 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -129,7 +129,7 @@ public static function getArrayValidationStrategy(): string } /** - * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. + * Locates the project root directory by searching upwards for vendor/autoload.php, composer.json, or typephp.php. * Caches the result in memory so the search happens exactly once. */ public static function getProjectRoot(): string @@ -138,15 +138,32 @@ public static function getProjectRoot(): string return self::$projectRoot; } - $rootCandidate = str_replace('\\', '/', \dirname(__DIR__, 2)); - - if (file_exists($rootCandidate . '/vendor/autoload.php') || file_exists($rootCandidate . '/composer.json') || file_exists($rootCandidate . '/typephp.php')) { - return self::$projectRoot = rtrim($rootCandidate, '/'); + $cwd = getcwd(); + if ($cwd !== false) { + $normCwd = rtrim(str_replace('\\', '/', $cwd), '/'); + if ( + file_exists($normCwd . '/vendor/autoload.php') + || file_exists($normCwd . '/composer.json') + || file_exists($normCwd . '/typephp.php') + ) { + return self::$projectRoot = $normCwd; + } + } + + $dir = str_replace('\\', '/', __DIR__); + + if (str_contains($dir, '/vendor/')) { + $vendorPos = strrpos($dir, '/vendor/'); + if ($vendorPos !== false) { + $candidate = substr($dir, 0, $vendorPos); + if (file_exists($candidate . '/vendor/autoload.php') || file_exists($candidate . '/composer.json')) { + return self::$projectRoot = rtrim($candidate, '/'); + } + } } - $dir = __DIR__; for ($i = 0; $i < 10; $i++) { - if (file_exists($dir . '/vendor/autoload.php')) { + if (file_exists($dir . '/vendor/autoload.php') || file_exists($dir . '/typephp.php')) { return self::$projectRoot = rtrim(str_replace('\\', '/', $dir), '/'); } @@ -157,22 +174,6 @@ public static function getProjectRoot(): string $dir = $parent; } - $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 : '.'), '/'); } diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 3355689..4f5d6b1 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -219,14 +219,14 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ self::unregister(); - $exists = (bool) self::silent(fn() => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($target, $mode, false, $this->context) : fopen($target, $mode) ); @@ -270,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($targetFile, $mode, $useIncludePath, $this->context) : fopen($targetFile, $mode, $useIncludePath) ); @@ -425,7 +425,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -462,11 +462,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn() => @chmod($path, $mode)); + $result = (bool) self::silent(fn () => @chmod($path, $mode)); } self::register(); @@ -478,7 +478,7 @@ public function dir_opendir(string $path, int $options): bool self::unregister(); /** @var resource|false $dh */ $dh = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? @opendir($path, $this->context) : @opendir($path) ); @@ -605,7 +605,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn() => true); + set_error_handler(static fn () => true); try { return $callback(); diff --git a/src/bootstrap.php b/src/bootstrap.php index 31a482b..93e0060 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -37,6 +37,7 @@ 'composer' => true, 'deptrac' => true, 'phan' => true, + 'paratest' => true, ]; $isTooling = isset($toolingBinaries[$binary]); @@ -47,7 +48,7 @@ if (isset($_SERVER['argv']) && \is_array($_SERVER['argv'])) { $hasParallelArg = false; foreach ($_SERVER['argv'] as $arg) { - if (\is_string($arg) && (str_starts_with($arg, '--parallel') || $arg === '-p' || str_starts_with($arg, '--processes') || str_starts_with($arg, '--runner'))) { + if (\is_string($arg) && (str_starts_with($arg, '--parallel') || $arg === '-p' || str_starts_with($arg, '--processes'))) { $hasParallelArg = true; break; @@ -56,12 +57,7 @@ $isParallelParent = ($binary === 'paratest') || ($binary === 'pest' && $hasParallelArg); - if ( - getenv('TEST_TOKEN') !== false - || getenv('PARATEST') !== false - || getenv('UNIQUE_TEST_TOKEN') !== false - || getenv('PEST_PARALLEL_WORKER_ID') !== false - ) { + if (getenv('TEST_TOKEN') !== false || getenv('PARATEST') !== false || getenv('PEST_PARALLEL_WORKER_ID') !== false) { $isParallelParent = false; } } @@ -69,4 +65,4 @@ if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling && ! $isParallelParent) { TypePHP::boot(); } -} \ No newline at end of file +} diff --git a/tests/Contract/PackagePathInclusionTest.php b/tests/Contract/PackagePathInclusionTest.php index f9e44f6..efd94fc 100644 --- a/tests/Contract/PackagePathInclusionTest.php +++ b/tests/Contract/PackagePathInclusionTest.php @@ -42,7 +42,8 @@ expect(FileFilter::isFileExcluded($coreSource))->toBeFalse() ->and(FileFilter::isFileExcluded($supportSource))->toBeFalse() - ->and(FileFilter::isFileExcluded($consoleSource))->toBeFalse(); + ->and(FileFilter::isFileExcluded($consoleSource))->toBeFalse() + ; }); test('correctly excludes package test files with packages/**/tests/** glob', function () { @@ -63,7 +64,8 @@ $coreTest = str_replace('\\', '/', $projectRoot . '/packages/core/tests/ApplicationTest.php'); expect(FileFilter::isFileExcluded($packageTest))->toBeTrue() - ->and(FileFilter::isFileExcluded($coreTest))->toBeTrue(); + ->and(FileFilter::isFileExcluded($coreTest))->toBeTrue() + ; }); test('verifies PathMatcher includes deep package source files and excludes package test files', function () { @@ -83,11 +85,13 @@ expect(PathMatcher::isPathIncluded($projectRoot . '/packages/core/src/Application.php', $includes, $excludes, '', $projectRoot))->toBeTrue() ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/support/src/Arr/functions.php', $includes, $excludes, '', $projectRoot))->toBeTrue() ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/console/src/Input/ConsoleArgumentBag.php', $includes, $excludes, '', $projectRoot))->toBeTrue() - ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/http/src/IsRequest.php', $includes, $excludes, '', $projectRoot))->toBeTrue(); + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/http/src/IsRequest.php', $includes, $excludes, '', $projectRoot))->toBeTrue() + ; expect(PathMatcher::isPathIncluded($projectRoot . '/packages/support/tests/Filesystem/UnixFunctionsTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse() ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/core/tests/ApplicationTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse() - ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/console/tests/ConsoleArgumentBagTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse(); + ->and(PathMatcher::isPathIncluded($projectRoot . '/packages/console/tests/ConsoleArgumentBagTest.php', $includes, $excludes, '', $projectRoot))->toBeFalse() + ; }); test('verifies isStaticSourcePath accurately classifies package source paths vs test fixture paths', function () { @@ -97,6 +101,7 @@ expect(PathMatcher::isStaticSourcePath($pkgSourcePath))->toBeTrue() ->and(PathMatcher::isStaticSourcePath($pkgTestFixturePath))->toBeFalse() - ->and(PathMatcher::isStaticSourcePath($viteInstallFixturePath))->toBeFalse(); + ->and(PathMatcher::isStaticSourcePath($viteInstallFixturePath))->toBeFalse() + ; }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/ConfigTest.php b/tests/Internal/ConfigTest.php index eed9914..864226c 100644 --- a/tests/Internal/ConfigTest.php +++ b/tests/Internal/ConfigTest.php @@ -97,4 +97,43 @@ ->and($config['inline_vars']['generics'])->toBeTrue() ; }); + + test('resolves consumer project root when TypePHP is installed inside vendor/typephp/typephp', function () { + $tempBase = sys_get_temp_dir() . '/typephp_root_test_' . uniqid(); + $vendorDir = $tempBase . '/vendor/typephp/typephp/src/Internal'; + mkdir($vendorDir, 0777, true); + + file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/consumer-app'])); + file_put_contents($tempBase . '/vendor/autoload.php', ' 'typephp/typephp'])); + + try { + $prevCwd = getcwd(); + chdir($tempBase); + + Config::reset(); + + $root = Config::getProjectRoot(); + $normTempBase = rtrim(str_replace('\\', '/', $tempBase), '/'); + + expect($root)->toBe($normTempBase) + ->and($root)->not()->toContain('vendor/typephp/typephp') + ; + + if ($prevCwd !== false) { + chdir($prevCwd); + } + } finally { + @unlink($tempBase . '/composer.json'); + @unlink($tempBase . '/vendor/autoload.php'); + @unlink($tempBase . '/vendor/typephp/typephp/composer.json'); + @rmdir($tempBase . '/vendor/typephp/typephp/src/Internal'); + @rmdir($tempBase . '/vendor/typephp/typephp/src'); + @rmdir($tempBase . '/vendor/typephp/typephp'); + @rmdir($tempBase . '/vendor/typephp'); + @rmdir($tempBase . '/vendor'); + @rmdir($tempBase); + Config::reset(); + } + }); }); diff --git a/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php b/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php index cbe11f6..4e4470c 100644 --- a/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php +++ b/tests/TypeChecking/Configuration/RespectNativeReturnNullabilityTest.php @@ -84,7 +84,8 @@ public function findUsername(bool $returnNull): ?string Config::set(['respect_native_nullability' => false]); expect(fn () => testNativeNullableReturnFunction(true)) - ->toThrow(TypeError::class, 'none returned'); + ->toThrow(TypeError::class, 'none returned') + ; expect(testNativeNullableReturnFunction(false))->toBe([10, 20, 30]); }); @@ -94,9 +95,10 @@ public function findUsername(bool $returnNull): ?string $service = new ReturnNullabilityService(); expect(fn () => $service->findUsername(true)) - ->toThrow(TypeError::class, 'none returned'); + ->toThrow(TypeError::class, 'none returned') + ; expect($service->findUsername(false))->toBe('Alice'); }); }); -}); \ No newline at end of file +}); From 1bdd866604acb573578e27595f9d8a5ad60d6e81 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 03:37:42 +0800 Subject: [PATCH 10/16] use experimental manual tokinization instead of nikic parser in special type resolver --- src/Resolver/SpecialTypeResolver.php | 149 +++++++++++++++++---------- 1 file changed, 94 insertions(+), 55 deletions(-) diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 4ca9840..f6a4660 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -4,8 +4,6 @@ namespace TypePHP\Resolver; -use PhpParser\Node\Stmt; -use PhpParser\ParserFactory; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; @@ -139,14 +137,11 @@ final class SpecialTypeResolver private static array $classTraitUseDocs = []; /** - * Resets internal reflection and file caches. Useful for test isolation. + * Resets reflection context and dynamic caches. Preserves static file imports. */ public static function reset(): void { self::$reflectionContextCache = []; - self::$fileUseImports = []; - self::$fileNamespaces = []; - self::$classTraitUseDocs = []; } /** @@ -1039,80 +1034,124 @@ public static function isBuiltInTypeKeyword(string $name): bool } /** - * Parses the AST of a PHP file once to extract namespace, use imports, and class trait use docblocks. + * Fast C-level token scanner using PhpToken (PHP 8.0+) to extract namespace, + * use imports, and trait docblocks in microseconds without PhpParser AST overhead. */ private static function parseFileMetadata(string $fileName, string $source): void { self::$fileNamespaces[$fileName] = ''; self::$fileUseImports[$fileName] = []; - /** @var \PhpParser\Parser|null $parser */ - static $parser = null; - if ($parser === null) { - $parser = (new ParserFactory())->createForNewestSupportedVersion(); - } - try { - $stmts = $parser->parse($source); - if ($stmts === null) { - return; - } - - $imports = []; + $tokens = \PhpToken::tokenize($source); + $count = \count($tokens); $namespace = ''; + $imports = []; + $currentClass = null; - /** @var array $nodesToScan */ - $nodesToScan = $stmts; - foreach ($stmts as $stmt) { - if ($stmt instanceof Stmt\Namespace_) { - $namespace = $stmt->name !== null ? $stmt->name->toString() : ''; - $nodesToScan = $stmt->stmts; + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; - break; - } - } + if ($token->id === T_NAMESPACE) { + $nsParts = []; + for ($j = $i + 1; $j < $count; $j++) { + if ($tokens[$j]->text === ';' || $tokens[$j]->text === '{') { + $i = $j; - foreach ($nodesToScan as $stmt) { - if ($stmt instanceof Stmt\Use_) { - if ($stmt->type !== Stmt\Use_::TYPE_NORMAL) { - continue; + break; + } + if ($tokens[$j]->id === T_NAME_QUALIFIED || $tokens[$j]->id === T_STRING || $tokens[$j]->id === T_NS_SEPARATOR) { + $nsParts[] = $tokens[$j]->text; + } } + $namespace = trim(implode('', $nsParts)); + self::$fileNamespaces[$fileName] = $namespace; - foreach ($stmt->uses as $use) { - $fqcn = $use->name->toString(); - $alias = $use->getAlias()->toString(); - $imports[$alias] = $fqcn; + continue; + } + + if (($token->id === T_CLASS || $token->id === T_INTERFACE || $token->id === T_TRAIT || (defined('T_ENUM') && $token->id === T_ENUM)) && isset($tokens[$i + 2]) && $tokens[$i + 2]->id === T_STRING) { + $className = $tokens[$i + 2]->text; + $currentClass = $namespace !== '' ? $namespace . '\\' . $className : $className; + self::$classTraitUseDocs[$currentClass] ??= []; + + continue; + } + + if ($token->id === T_USE && $currentClass !== null) { + for ($k = $i - 1; $k >= 0; $k--) { + if ($tokens[$k]->id === T_DOC_COMMENT) { + self::$classTraitUseDocs[$currentClass][] = $tokens[$k]->text; + + break; + } + if ($tokens[$k]->id !== T_WHITESPACE) { + break; + } } - } elseif ($stmt instanceof Stmt\GroupUse) { - $prefix = $stmt->prefix->toString(); - foreach ($stmt->uses as $use) { - if ($use->type !== Stmt\Use_::TYPE_NORMAL && $use->type !== Stmt\Use_::TYPE_UNKNOWN && $stmt->type !== Stmt\Use_::TYPE_NORMAL) { - continue; + continue; + } + + if ($token->id === T_USE && $currentClass === null) { + $useStatement = ''; + for ($j = $i + 1; $j < $count; $j++) { + if ($tokens[$j]->text === ';') { + $i = $j; + + break; } + if ($tokens[$j]->text === '{') { + $prefix = trim($useStatement); + $groupContent = ''; + for ($g = $j + 1; $g < $count; $g++) { + if ($tokens[$g]->text === '}') { + $i = $g; + + break; + } + $groupContent .= $tokens[$g]->text; + } + foreach (explode(',', $groupContent) as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + if (preg_match('/^([a-zA-Z0-9_\\\\]+)(?:\s+as\s+([a-zA-Z0-9_]+))?$/i', $part, $m) === 1) { + $fqcn = rtrim($prefix, '\\') . '\\' . trim($m[1]); + $alias = $m[2] ?? basename(str_replace('\\', '/', $fqcn)); + $imports[$alias] = ltrim($fqcn, '\\'); + } + } - $fqcn = $prefix . '\\' . $use->name->toString(); - $alias = $use->getAlias()->toString(); - $imports[$alias] = $fqcn; + break; + } + $useStatement .= $tokens[$j]->text; } - } elseif ($stmt instanceof Stmt\Class_ && $stmt->name !== null) { - $className = $namespace !== '' ? $namespace . '\\' . $stmt->name->toString() : $stmt->name->toString(); - self::$classTraitUseDocs[$className] = []; - foreach ($stmt->stmts as $classStmt) { - if ($classStmt instanceof Stmt\TraitUse) { - $doc = $classStmt->getDocComment(); - if ($doc !== null) { - self::$classTraitUseDocs[$className][] = $doc->getText(); + + if (str_contains($useStatement, ',')) { + foreach (explode(',', $useStatement) as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + if (preg_match('/^(?:function\s+|const\s+)?([a-zA-Z0-9_\\\\]+)(?:\s+as\s+([a-zA-Z0-9_]+))?$/i', $part, $m) === 1) { + $fqcn = trim($m[1]); + $alias = $m[2] ?? basename(str_replace('\\', '/', $fqcn)); + $imports[$alias] = ltrim($fqcn, '\\'); } } + } elseif (preg_match('/^(?:function\s+|const\s+)?([a-zA-Z0-9_\\\\]+)(?:\s+as\s+([a-zA-Z0-9_]+))?$/i', trim($useStatement), $m) === 1) { + $fqcn = trim($m[1]); + $alias = $m[2] ?? basename(str_replace('\\', '/', $fqcn)); + $imports[$alias] = ltrim($fqcn, '\\'); } } } - self::$fileNamespaces[$fileName] = $namespace; self::$fileUseImports[$fileName] = $imports; } catch (\Throwable $e) { - // Silently fall back to empty metadata if parsing fails + self::$fileUseImports[$fileName] = []; } } -} +} \ No newline at end of file From 0012062cba0e188a9fad077b9703b900ec522b7e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 11:34:59 +0800 Subject: [PATCH 11/16] Optimize Stream Wrapper --- src/Internal/StreamWrapper.php | 80 ++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 4f5d6b1..75b119c 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -129,8 +129,7 @@ public static function unregister(): void } /** - * Transforms PHP source code by parsing AST, extracting metadata, applying ContractVisitor, - * and formatting output while preserving exact line numbers to prevent line-drift in debug stack traces. + * Transforms PHP source code and embeds metadata header for instant zero-disk runtime resolution. */ public static function transformSource(string $source, string $filePath = ''): string { @@ -151,7 +150,7 @@ public static function transformSource(string $source, string $filePath = ''): s return $source; } - self::extractAndSeedFileMetadata($oldStmts, $filePath); + $metadata = self::extractAndSeedFileMetadata($oldStmts, $filePath); $oldTokens = $parser->getTokens(); @@ -190,7 +189,14 @@ public static function transformSource(string $source, string $filePath = ''): s $transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed; } - return str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); + $cleanTransformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); + + if ($metadata !== null) { + $metaJson = json_encode($metadata); + $cleanTransformed = preg_replace('/^<\?php/i', " file_exists($path)); - $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; + $exists = (bool) self::silent(fn() => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? fopen($target, $mode, false, $this->context) : fopen($target, $mode) ); @@ -270,7 +276,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ $handle = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? fopen($targetFile, $mode, $useIncludePath, $this->context) : fopen($targetFile, $mode, $useIncludePath) ); @@ -425,7 +431,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(static fn () => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -462,11 +468,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn () => @chmod($path, $mode)); + $result = (bool) self::silent(fn() => @chmod($path, $mode)); } self::register(); @@ -478,7 +484,7 @@ public function dir_opendir(string $path, int $options): bool self::unregister(); /** @var resource|false $dh */ $dh = self::silent( - fn () => ($this->context !== null) + fn() => ($this->context !== null) ? @opendir($path, $this->context) : @opendir($path) ); @@ -605,7 +611,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn () => true); + set_error_handler(static fn() => true); try { return $callback(); @@ -694,13 +700,8 @@ private function openCachedStream(string $resolvedPath, string $mode): bool if (! CacheManager::writeCachedFileSafely($cachedFile, $transformed)) { return $this->openMemoryStream($resolvedPath); } - } - - if (\function_exists('posix_geteuid')) { - $owner = @fileowner($cachedFile); - if ($owner !== false && $owner !== posix_geteuid()) { - return $this->openMemoryStream($resolvedPath); - } + } else { + self::seedMetadataFromCachedFile($cachedFile, $resolvedPath); } $cacheHandle = ($this->context !== null) @@ -711,15 +712,42 @@ private function openCachedStream(string $resolvedPath, string $mode): bool return $this->handle !== null; } + /** + * Reads the lightweight metadata header from the first 512 bytes of the cached file. + */ + private static function seedMetadataFromCachedFile(string $cachedFile, string $resolvedPath): void + { + $fp = @fopen($cachedFile, 'r'); + if ($fp === false) { + return; + } + + $header = (string) fread($fp, 2048); + fclose($fp); + + if (preg_match('/\/\*__TYPEPHP_META__(.+?)__TYPEPHP_META\*\//s', $header, $m)) { + $meta = json_decode($m[1], true); + if (\is_array($meta)) { + SpecialTypeResolver::seedFileMetadata( + $resolvedPath, + $meta['ns'] ?? '', + $meta['imports'] ?? [], + $meta['traits'] ?? [] + ); + } + } + } + /** * Scans top-level AST statements for namespace, use imports, and trait use declarations to seed SpecialTypeResolver. - * + * * @param array<\PhpParser\Node\Stmt> $stmts + * @return array{ns: string, imports: array, traits: array>}|null */ - private static function extractAndSeedFileMetadata(array $stmts, string $filePath): void + private static function extractAndSeedFileMetadata(array $stmts, string $filePath): ?array { if ($filePath === '') { - return; + return null; } $namespace = ''; @@ -773,5 +801,11 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat } SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); + + return [ + 'ns' => $namespace, + 'imports' => $imports, + 'traits' => $classTraitUseDocs, + ]; } } From b556897a9cae846a3273c7df1f26d6a042eeaed0 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 14:26:01 +0800 Subject: [PATCH 12/16] Enhance CacheManager and SpecialTypeResolver with memoization and improved documentation --- src/Internal/CacheManager.php | 19 +++++-- src/Internal/RuntimeTypeChecker.php | 2 +- src/Internal/StreamWrapper.php | 79 +++++++--------------------- src/Resolver/SpecialTypeResolver.php | 34 ++++++++++-- 4 files changed, 65 insertions(+), 69 deletions(-) diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index 0008d99..8846b8f 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -21,9 +21,15 @@ final class CacheManager */ private static ?string $resolvedCacheDir = null; + /** + * Memoized verification state of the cache directory. + */ + private static ?bool $secureDirVerified = null; + public static function reset(): void { self::$resolvedCacheDir = null; + self::$secureDirVerified = null; } /** @@ -86,10 +92,14 @@ public static function getCachedFilePath(string $resolvedPath): string */ public static function ensureSecureCacheDir(): bool { + if (self::$secureDirVerified !== null) { + return self::$secureDirVerified; + } + $cacheDir = self::getCacheDir(); if (is_link($cacheDir)) { - return false; + return self::$secureDirVerified = false; } $config = Config::get(); @@ -98,7 +108,7 @@ public static function ensureSecureCacheDir(): bool if (! is_dir($cacheDir)) { $mode = $isCustomDir ? 0775 : 0700; if (! @mkdir($cacheDir, $mode, recursive: true) && ! is_dir($cacheDir)) { - return false; + return self::$secureDirVerified = false; } if (! $isCustomDir) { @chmod($cacheDir, 0700); @@ -108,11 +118,11 @@ public static function ensureSecureCacheDir(): bool if (! $isCustomDir && \function_exists('posix_geteuid')) { $owner = @fileowner($cacheDir); if ($owner !== false && $owner !== posix_geteuid()) { - return false; + return self::$secureDirVerified = false; } } - return is_writable($cacheDir); + return self::$secureDirVerified = is_writable($cacheDir); } /** @@ -190,7 +200,6 @@ public static function clear(): int } } - // Also clean up any lingering temporary swap files $tmpFiles = glob($dir . '/.tmp_*'); if ($tmpFiles !== false) { foreach ($tmpFiles as $tFile) { diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index e533e63..bcf3c77 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -81,7 +81,7 @@ public static function setupScope(string $function, array $vars, object|string|n $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - $err = self::checkParams($function, $vars, $thisOrClass); + $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry(), $effectiveFunction); $contract = ContractParser::parse($effectiveFunction); $methodTemplates = $contract['templates'] ?? []; diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 75b119c..5bb7b8d 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -53,7 +53,7 @@ final class StreamWrapper implements StreamWrapperInterface private static array $statCache = []; /** - * In-memory cache for static-path negative misses only (vendor directories). + * In-memory cache for static-path negative misses only (vendor & package source directories). * * @var array */ @@ -129,7 +129,8 @@ public static function unregister(): void } /** - * Transforms PHP source code and embeds metadata header for instant zero-disk runtime resolution. + * Transforms PHP source code by parsing AST, extracting metadata, applying ContractVisitor, + * and formatting output while preserving exact line numbers to prevent line-drift in debug stack traces. */ public static function transformSource(string $source, string $filePath = ''): string { @@ -150,7 +151,7 @@ public static function transformSource(string $source, string $filePath = ''): s return $source; } - $metadata = self::extractAndSeedFileMetadata($oldStmts, $filePath); + self::extractAndSeedFileMetadata($oldStmts, $filePath); $oldTokens = $parser->getTokens(); @@ -189,14 +190,7 @@ public static function transformSource(string $source, string $filePath = ''): s $transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed; } - $cleanTransformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); - - if ($metadata !== null) { - $metaJson = json_encode($metadata); - $cleanTransformed = preg_replace('/^<\?php/i', " file_exists($path)); - $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; + $exists = (bool) self::silent(static fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(static fn () => realpath($path)) : false; if (! $exists || $resolvedPath === false || ! self::isApplicationFile($path, $resolvedPath)) { $target = ($resolvedPath !== false) ? $resolvedPath : $path; /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($target, $mode, false, $this->context) : fopen($target, $mode) ); @@ -276,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode, int $options self::unregister(); /** @var resource|false $handle */ $handle = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? fopen($targetFile, $mode, $useIncludePath, $this->context) : fopen($targetFile, $mode, $useIncludePath) ); @@ -411,7 +405,7 @@ public function stream_close(): void /** * Resolves file status with dual-tier memoization caching: * 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK). - * 2. Only memoizes .php source files and immutable vendor paths. + * 2. Memoizes PHP files, vendor files, and static package source directories. * * @return array|false */ @@ -431,7 +425,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(static fn() => $isLink ? @lstat($path) : @stat($path)); + $result = self::silent(static fn () => $isLink ? @lstat($path) : @stat($path)); self::register(); if ($result !== false) { @@ -468,11 +462,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn() => @touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn () => @touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn() => @chmod($path, $mode)); + $result = (bool) self::silent(fn () => @chmod($path, $mode)); } self::register(); @@ -484,7 +478,7 @@ public function dir_opendir(string $path, int $options): bool self::unregister(); /** @var resource|false $dh */ $dh = self::silent( - fn() => ($this->context !== null) + fn () => ($this->context !== null) ? @opendir($path, $this->context) : @opendir($path) ); @@ -611,7 +605,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(static fn() => true); + set_error_handler(static fn () => true); try { return $callback(); @@ -681,7 +675,7 @@ private function openMemoryStream(string $resolvedPath): bool } /** - * Transforms, persists, and loads cached source code from disk. + * Transforms, persists, and loads cached source code from disk in a single handle open. */ private function openCachedStream(string $resolvedPath, string $mode): bool { @@ -700,8 +694,6 @@ private function openCachedStream(string $resolvedPath, string $mode): bool if (! CacheManager::writeCachedFileSafely($cachedFile, $transformed)) { return $this->openMemoryStream($resolvedPath); } - } else { - self::seedMetadataFromCachedFile($cachedFile, $resolvedPath); } $cacheHandle = ($this->context !== null) @@ -712,42 +704,15 @@ private function openCachedStream(string $resolvedPath, string $mode): bool return $this->handle !== null; } - /** - * Reads the lightweight metadata header from the first 512 bytes of the cached file. - */ - private static function seedMetadataFromCachedFile(string $cachedFile, string $resolvedPath): void - { - $fp = @fopen($cachedFile, 'r'); - if ($fp === false) { - return; - } - - $header = (string) fread($fp, 2048); - fclose($fp); - - if (preg_match('/\/\*__TYPEPHP_META__(.+?)__TYPEPHP_META\*\//s', $header, $m)) { - $meta = json_decode($m[1], true); - if (\is_array($meta)) { - SpecialTypeResolver::seedFileMetadata( - $resolvedPath, - $meta['ns'] ?? '', - $meta['imports'] ?? [], - $meta['traits'] ?? [] - ); - } - } - } - /** * Scans top-level AST statements for namespace, use imports, and trait use declarations to seed SpecialTypeResolver. - * + * * @param array<\PhpParser\Node\Stmt> $stmts - * @return array{ns: string, imports: array, traits: array>}|null */ - private static function extractAndSeedFileMetadata(array $stmts, string $filePath): ?array + private static function extractAndSeedFileMetadata(array $stmts, string $filePath): void { if ($filePath === '') { - return null; + return; } $namespace = ''; @@ -801,11 +766,5 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat } SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); - - return [ - 'ns' => $namespace, - 'imports' => $imports, - 'traits' => $classTraitUseDocs, - ]; } } diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index f6a4660..56a1c5a 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -115,6 +115,13 @@ final class SpecialTypeResolver */ private static array $reflectionContextCache = []; + /** + * In-memory cache for resolved FQCNs per context and type name. + * + * @var array + */ + private static array $fqcnCache = []; + /** * In-memory cache of file import maps keyed by filename. * @@ -142,6 +149,7 @@ final class SpecialTypeResolver public static function reset(): void { self::$reflectionContextCache = []; + self::$fqcnCache = []; } /** @@ -925,6 +933,7 @@ public static function getNamespaceFromFile(string $fileName): string /** * Resolves a short class name to its fully qualified class name (FQCN) using Reflection context. + * Memoizes resolved FQCNs in memory to avoid autoloader search storms. * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ @@ -942,6 +951,17 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun return $name; } + $contextKey = match (true) { + $ref instanceof \ReflectionClass => 'C:' . $ref->getName(), + $ref instanceof \ReflectionMethod => 'M:' . $ref->getDeclaringClass()->getName() . '::' . $ref->getName(), + $ref instanceof \ReflectionFunction => 'F:' . $ref->getName(), + }; + + $cacheKey = $contextKey . '|' . $name; + if (isset(self::$fqcnCache[$cacheKey])) { + return self::$fqcnCache[$cacheKey]; + } + $imports = self::getUseImports($ref); $namespace = match (true) { $ref instanceof \ReflectionClass => $ref->getNamespaceName(), @@ -949,7 +969,9 @@ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFun $ref instanceof \ReflectionFunction => $ref->getNamespaceName(), }; - return self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + $resolved = self::resolveNameFromImportsAndNamespace($name, $imports, $namespace); + + return self::$fqcnCache[$cacheKey] = $resolved; } /** @@ -1036,6 +1058,12 @@ public static function isBuiltInTypeKeyword(string $name): bool /** * Fast C-level token scanner using PhpToken (PHP 8.0+) to extract namespace, * use imports, and trait docblocks in microseconds without PhpParser AST overhead. + * + * Execution Flow: + * 1. Namespace: Identifies T_NAMESPACE to track file-level namespace prefix. + * 2. Class Scope: Tracks class, interface, trait, and enum boundaries. + * 3. Trait DocBlocks: Scans doc comments preceding T_USE statements inside class declarations. + * 4. Top-Level Imports: Parses single, multi, and group use statements outside classes into alias maps. */ private static function parseFileMetadata(string $fileName, string $source): void { @@ -1070,7 +1098,7 @@ private static function parseFileMetadata(string $fileName, string $source): voi continue; } - if (($token->id === T_CLASS || $token->id === T_INTERFACE || $token->id === T_TRAIT || (defined('T_ENUM') && $token->id === T_ENUM)) && isset($tokens[$i + 2]) && $tokens[$i + 2]->id === T_STRING) { + if (($token->id === T_CLASS || $token->id === T_INTERFACE || $token->id === T_TRAIT || (\defined('T_ENUM') && $token->id === T_ENUM)) && isset($tokens[$i + 2]) && $tokens[$i + 2]->id === T_STRING) { $className = $tokens[$i + 2]->text; $currentClass = $namespace !== '' ? $namespace . '\\' . $className : $className; self::$classTraitUseDocs[$currentClass] ??= []; @@ -1154,4 +1182,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi self::$fileUseImports[$fileName] = []; } } -} \ No newline at end of file +} From 71f22f141f82a2caca63b47cc08444902b58c7a4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 17:01:30 +0800 Subject: [PATCH 13/16] Normalize path checks to be case-insensitive for improved compatibility --- src/Internal/PathMatcher.php | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index c821cb5..98958db 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -198,27 +198,30 @@ public static function isLibraryInternal(string $normalizedPath): bool } $canon = self::canonicalizePath($normalizedPath); + $lowerCanon = strtolower($canon); + $lowerLibSrcDir = strtolower($libSrcDir); - if (str_contains($libSrcDir, '/vendor/')) { - return str_starts_with($canon, $libSrcDir); + if (str_contains($lowerLibSrcDir, '/vendor/')) { + return str_starts_with($lowerCanon, $lowerLibSrcDir); } - if (str_starts_with($canon, $libSrcDir)) { + if (str_starts_with($lowerCanon, $lowerLibSrcDir)) { $internalDirs = [ - $libSrcDir . 'Internal/', - $libSrcDir . 'Contract/', - $libSrcDir . 'Command/', - $libSrcDir . 'Validator/', - $libSrcDir . 'Wrapper/', - $libSrcDir . 'Resolver/', - $libSrcDir . 'Extension/', - $libSrcDir . 'Exception/', - $libSrcDir . 'TypePHP.php', - $libSrcDir . 'bootstrap.php', + $lowerLibSrcDir . 'internal/', + $lowerLibSrcDir . 'contract/', + $lowerLibSrcDir . 'command/', + $lowerLibSrcDir . 'validator/', + $lowerLibSrcDir . 'wrapper/', + $lowerLibSrcDir . 'resolver/', + $lowerLibSrcDir . 'extension/', + $lowerLibSrcDir . 'exception/', + $lowerLibSrcDir . 'compiler/', + $lowerLibSrcDir . 'typephp.php', + $lowerLibSrcDir . 'bootstrap.php', ]; foreach ($internalDirs as $dir) { - if (str_starts_with($canon, $dir)) { + if (str_starts_with($lowerCanon, $dir)) { return true; } } From ff79805c2ecc0e959248454a0328cf9b695c936b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 17:24:18 +0800 Subject: [PATCH 14/16] Refactor getProjectRoot method to improve clarity and remove unnecessary checks --- src/Internal/Config.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Internal/Config.php b/src/Internal/Config.php index b22fc82..292f011 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -132,6 +132,10 @@ public static function getArrayValidationStrategy(): string * Locates the project root directory by searching upwards for vendor/autoload.php, composer.json, or typephp.php. * Caches the result in memory so the search happens exactly once. */ + /** + * 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) { @@ -144,7 +148,6 @@ public static function getProjectRoot(): string if ( file_exists($normCwd . '/vendor/autoload.php') || file_exists($normCwd . '/composer.json') - || file_exists($normCwd . '/typephp.php') ) { return self::$projectRoot = $normCwd; } @@ -163,7 +166,7 @@ public static function getProjectRoot(): string } for ($i = 0; $i < 10; $i++) { - if (file_exists($dir . '/vendor/autoload.php') || file_exists($dir . '/typephp.php')) { + if (file_exists($dir . '/composer.json') || file_exists($dir . '/vendor/autoload.php')) { return self::$projectRoot = rtrim(str_replace('\\', '/', $dir), '/'); } From 2562e95cabd9da38210b0b19d3d1c11892044e80 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 17:28:16 +0800 Subject: [PATCH 15/16] Normalize project root path resolution to handle real paths correctly --- tests/Internal/ConfigTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Internal/ConfigTest.php b/tests/Internal/ConfigTest.php index 864226c..1c0439c 100644 --- a/tests/Internal/ConfigTest.php +++ b/tests/Internal/ConfigTest.php @@ -114,7 +114,8 @@ Config::reset(); $root = Config::getProjectRoot(); - $normTempBase = rtrim(str_replace('\\', '/', $tempBase), '/'); + $realTempBase = realpath($tempBase) !== false ? realpath($tempBase) : $tempBase; + $normTempBase = rtrim(str_replace('\\', '/', (string) $realTempBase), '/'); expect($root)->toBe($normTempBase) ->and($root)->not()->toContain('vendor/typephp/typephp') From 5d80c981b5254b2dda855d8dbc986990cf1b1368 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 1 Sep 2026 17:34:27 +0800 Subject: [PATCH 16/16] Refactor getProjectRoot method to improve path normalization and add documentationn and fix errors on windows ci --- src/Internal/Config.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 292f011..69e5968 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -136,6 +136,10 @@ public static function getArrayValidationStrategy(): string * 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. */ + /** + * 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) { @@ -144,7 +148,8 @@ public static function getProjectRoot(): string $cwd = getcwd(); if ($cwd !== false) { - $normCwd = rtrim(str_replace('\\', '/', $cwd), '/'); + $realCwd = realpath($cwd) !== false ? realpath($cwd) : $cwd; + $normCwd = rtrim(str_replace('\\', '/', (string) $realCwd), '/'); if ( file_exists($normCwd . '/vendor/autoload.php') || file_exists($normCwd . '/composer.json') @@ -159,15 +164,19 @@ public static function getProjectRoot(): string $vendorPos = strrpos($dir, '/vendor/'); if ($vendorPos !== false) { $candidate = substr($dir, 0, $vendorPos); - if (file_exists($candidate . '/vendor/autoload.php') || file_exists($candidate . '/composer.json')) { - return self::$projectRoot = rtrim($candidate, '/'); + $realCandidate = realpath($candidate) !== false ? realpath($candidate) : $candidate; + $normCandidate = rtrim(str_replace('\\', '/', (string) $realCandidate), '/'); + if (file_exists($normCandidate . '/vendor/autoload.php') || file_exists($normCandidate . '/composer.json')) { + return self::$projectRoot = $normCandidate; } } } for ($i = 0; $i < 10; $i++) { if (file_exists($dir . '/composer.json') || file_exists($dir . '/vendor/autoload.php')) { - return self::$projectRoot = rtrim(str_replace('\\', '/', $dir), '/'); + $realDir = realpath($dir) !== false ? realpath($dir) : $dir; + + return self::$projectRoot = rtrim(str_replace('\\', '/', (string) $realDir), '/'); } $parent = \dirname($dir); @@ -177,7 +186,9 @@ public static function getProjectRoot(): string $dir = $parent; } - return self::$projectRoot = rtrim(str_replace('\\', '/', $cwd !== false ? $cwd : '.'), '/'); + $fallback = $cwd !== false ? (realpath($cwd) !== false ? realpath($cwd) : $cwd) : '.'; + + return self::$projectRoot = rtrim(str_replace('\\', '/', (string) $fallback), '/'); } /**