From 91a03752196ac7bdc8e7f5c0ae0c3d325ea4d178 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 14 Oct 2026 14:57:36 +0800 Subject: [PATCH 01/11] Enhance ContractVisitor and FunctionContractInjector to support by-reference tracking and improve doc comment resolution for performance; add comprehensive tests for unannotated method return checks --- src/Internal/Ast/ContractVisitor.php | 40 ++- src/Internal/Ast/FunctionContractInjector.php | 95 +++++- .../UnannotatedMethodReturnInjectionTest.php | 284 ++++++++++++++++++ 3 files changed, 401 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/UnannotatedMethodReturnInjectionTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index d6e600b..6bb82d2 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -15,6 +15,7 @@ use TypePHP\Internal\Docblock\DocblockNormalizer; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Util\StubManager; /** * @internal AST Node Visitor that injects contract checks, scope tracking, property hook validation, and parameter/return wrappers into functions and methods. @@ -66,6 +67,13 @@ final class ContractVisitor extends NodeVisitorAbstract */ private array $thisAvailableStack = []; + /** + * Tracks whether the enclosing function or method returns by reference. + * + * @var list + */ + private array $byRefStack = []; + public function __construct() { $this->scopeManager = new ScopeManager(); @@ -80,6 +88,10 @@ public function enterNode(Node $node): ?array { $this->trackDeclarationEntry($node); + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Expr\Closure) { + $this->byRefStack[] = $node->byRef; + } + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { $this->scopeManager->pushScope(isIsolated: true); } elseif ($node instanceof Node\Expr\Closure) { @@ -135,6 +147,10 @@ public function enterNode(Node $node): ?array } if ($node instanceof Node\Stmt\Return_) { + if ($node->expr !== null && $this->byRefStack !== [] && end($this->byRefStack)) { + $this->markWriteContext($node->expr); + } + $this->handleReturn($node); return null; @@ -173,6 +189,10 @@ public function leaveNode(Node $node): ?Node { $this->trackDeclarationExit($node); + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Expr\Closure) { + array_pop($this->byRefStack); + } + if ($node instanceof Node\Expr\Clone_) { return $this->wrapClone($node); } @@ -195,13 +215,6 @@ public function leaveNode(Node $node): ?Node } } - if ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) { - $replacement = $this->handleIncDec($node); - if ($replacement !== null) { - return $replacement; - } - } - if ($node instanceof Node\Expr\StaticPropertyFetch && $node->name instanceof Node\VarLikeIdentifier) { $propName = $node->name->toString(); @@ -429,13 +442,23 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ $hasRealInheritance = false; $hasPropertyWithDoc = false; $isReadonly = false; + $isFinal = false; $hasTemplates = false; + $doc = $node->getDocComment(); + if ($doc === null && $typeName !== null && StubManager::hasClassStub($typeName)) { + $stubDocText = StubManager::getClassDoc($typeName); + if ($stubDocText !== null) { + $doc = new \PhpParser\Comment\Doc($stubDocText); + } + } + if ($node instanceof Node\Stmt\Class_) { $hasExtends = $node->extends !== null; $hasImplements = $node->implements !== []; $hasTraits = false; $isReadonly = ($node->flags & Node\Stmt\Class_::MODIFIER_READONLY) !== 0; + $isFinal = ($node->flags & Node\Stmt\Class_::MODIFIER_FINAL) !== 0; foreach ($node->stmts as $stmt) { if ($stmt instanceof Node\Stmt\TraitUse) { @@ -453,7 +476,6 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ } } - $doc = $node->getDocComment(); $hasTemplates = $doc !== null && ( str_contains($doc->getText(), '@template') || str_contains($doc->getText(), '@phpstan-template') @@ -473,7 +495,6 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ $hasRealInheritance = $node->implements !== []; $hasInheritance = $hasRealInheritance; } else { - $doc = $node->getDocComment(); $hasTemplates = $doc !== null && ( str_contains($doc->getText(), '@template') || str_contains($doc->getText(), '@phpstan-template') @@ -488,6 +509,7 @@ private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\ 'hasRealInheritance' => $hasRealInheritance, 'hasPropertyWithDoc' => $hasPropertyWithDoc, 'isReadonly' => $isReadonly, + 'isFinal' => $isFinal, 'hasTemplates' => $hasTemplates, ]; } diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 4638953..d8fafd1 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -8,6 +8,7 @@ use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; +use TypePHP\Internal\Util\StubManager; /** * @internal Injects parameter checks, return checks, param-out checks, and generator interceptors into functions and methods. @@ -28,7 +29,7 @@ final class FunctionContractInjector ]; /** - * @param array{hasInheritance?: bool, hasRealInheritance?: bool, hasPropertyWithDoc?: bool, isReadonly?: bool, hasTemplates?: bool}|null $classContext + * @param array{name?: ?string, hasInheritance?: bool, hasRealInheritance?: bool, hasPropertyWithDoc?: bool, isReadonly?: bool, isFinal?: bool, hasTemplates?: bool}|null $classContext */ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ?array $classContext = null): void { @@ -37,13 +38,14 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? } $isClassMethod = $node instanceof Node\Stmt\ClassMethod; - $doc = self::resolveDocComment($node); + $doc = self::resolveDocComment($node, $classContext); $docText = $doc !== null ? $doc->getText() : ''; $hasInheritance = $classContext['hasInheritance'] ?? true; $hasRealInheritance = $classContext['hasRealInheritance'] ?? $hasInheritance; $hasPropertyWithDoc = $classContext['hasPropertyWithDoc'] ?? true; $isReadonlyClass = $classContext['isReadonly'] ?? false; + $isFinalClass = $classContext['isFinal'] ?? false; $hasClassTemplates = $classContext['hasTemplates'] ?? false; $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; @@ -72,10 +74,21 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'); + $hasMethodReturnStatements = self::hasReturnStatements($node); + $hasReturn = ! $isMagicLifecycle && ! $isNativeNever && ! ($isNativeVoid && ! $hasReturnDoc) - && ($isMagicGet || $isMagicCall || self::hasReturnContracts($docText, $isClassMethod, $isPrivate)); + && ($isMagicGet || $isMagicCall || self::hasReturnContracts( + $docText, + $isClassMethod, + $hasInheritance, + $isPrivate, + $isFinalClass, + $node->returnType !== null, + $hasMethodReturnStatements, + $classContext === null, + )); $byRefParams = []; foreach ($node->params as $p) { @@ -104,7 +117,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $isConstructor, $hasPropertyWithDoc, $classContext === null, - $node->attrGroups !== [] + $node->attrGroups !== [], ); if (! $hasParam && ! $hasReturn && ! $hasParamOut && ! $hasSelfOut) { @@ -132,7 +145,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $hasReturn, $hasParamOut ? $byRefParams : [], $hasSelfOut, - $node->byRef + $node->byRef, ); } @@ -160,8 +173,13 @@ public static function buildSelfOutCheckStmt(Node\Expr $thisArg, bool $needsRetu return $stmt; } - private static function resolveDocComment(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): ?Doc - { + /** + * @param array{name?: ?string, hasInheritance?: bool, hasRealInheritance?: bool, hasPropertyWithDoc?: bool, isReadonly?: bool, isFinal?: bool, hasTemplates?: bool}|null $classContext + */ + private static function resolveDocComment( + Node\Stmt\Function_|Node\Stmt\ClassMethod $node, + ?array $classContext = null, + ): ?Doc { $doc = $node->getDocComment(); if ($doc !== null) { return $doc; @@ -176,6 +194,25 @@ private static function resolveDocComment(Node\Stmt\Function_|Node\Stmt\ClassMet } } + $className = $classContext['name'] ?? null; + if ($node instanceof Node\Stmt\ClassMethod && $className !== null) { + $methodName = $node->name->toString(); + if (StubManager::hasMethodStub($className, $methodName)) { + $stubText = StubManager::getMethodDoc($className, $methodName); + if ($stubText !== null) { + return new Doc($stubText); + } + } + } elseif ($node instanceof Node\Stmt\Function_) { + $funcName = $node->name->toString(); + if (StubManager::hasFunctionStub($funcName)) { + $stubText = StubManager::getFunctionDoc($funcName); + if ($stubText !== null) { + return new Doc($stubText); + } + } + } + return null; } @@ -248,8 +285,17 @@ private static function hasNonMixedParam(string $docText): bool private static function hasReturnContracts( string $docText, bool $isClassMethod, - bool $isPrivate + bool $hasInheritance, + bool $isPrivate, + bool $isFinalClass = false, + bool $hasNativeReturnType = false, + bool $hasReturnStatements = false, + bool $isDirectUnitTest = false, ): bool { + if ($isDirectUnitTest && $isClassMethod) { + return true; + } + if ($isPrivate && $docText === '') { return false; } @@ -275,7 +321,38 @@ private static function hasReturnContracts( return true; } - return $isClassMethod && ! $isPrivate; + if (! $isClassMethod) { + return false; + } + + if ($hasInheritance && ! $isPrivate) { + return true; + } + + if ($isFinalClass) { + return false; + } + + if (! $hasNativeReturnType && ! $hasReturnStatements) { + return false; + } + + return ! $isPrivate; + } + + private static function hasReturnStatements(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): bool + { + if ($node->stmts === null || $node->stmts === []) { + return false; + } + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) { + return true; + } + } + + return false; } private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_|Node\Stmt\ClassMethod $node): Node\Expr diff --git a/tests/Unit/UnannotatedMethodReturnInjectionTest.php b/tests/Unit/UnannotatedMethodReturnInjectionTest.php new file mode 100644 index 0000000..86f0bff --- /dev/null +++ b/tests/Unit/UnannotatedMethodReturnInjectionTest.php @@ -0,0 +1,284 @@ +not()->toContain('RuntimeTypeChecker::checkReturn') + ->and($transformed)->not()->toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->not()->toContain('$__typephpRet') + ->and($transformed)->not()->toContain('return') + ; + }); + + test('does NOT inject checkReturn into final standalone class method with no docblock and existing return', function () { + $source = <<<'PHP' +not()->toContain('RuntimeTypeChecker::checkReturn') + ->and($transformed)->toContain("return 'meow';") + ; + }); + + test('does NOT inject checkReturn into private method with no docblock', function () { + $source = <<<'PHP' +not()->toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('injects param checks but does NOT inject return checks when standalone method only has @param docblock', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->not()->toContain('RuntimeTypeChecker::checkReturn') + ->and($transformed)->not()->toContain('$__typephpRet') + ; + }); + + test('does NOT inject checkReturn when method has @return mixed', function () { + $source = <<<'PHP' +not()->toContain('RuntimeTypeChecker::checkReturn'); + }); + }); + + describe('2. Standalone Classes With Explicit Return Contracts (Must Inject)', function () { + test('injects checkReturn when standalone method explicitly declares @return docblock', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn') + ->and($transformed)->toContain('$__typephpRet') + ; + }); + + test('injects checkReturn when standalone method declares @phpstan-return or @psalm-return', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('injects checkReturn when method declares $this return contract', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + }); + + describe('3. Classes With Inheritance, Interfaces, Traits, or Templates (LSP Must Inject)', function () { + test('injects checkReturn into unannotated method when class extends another class (LSP preservation)', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('injects checkReturn into unannotated method when class implements an interface (LSP preservation)', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('injects checkReturn into unannotated method when class uses a trait', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + + test('injects checkReturn into unannotated method when class has a class-level docblock (templates/magic methods)', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkReturn'); + }); + }); +}); From 64e16473289069abe98a7305f380c0038bac1945 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 14 Oct 2026 21:28:11 +0800 Subject: [PATCH 02/11] Optimize validation logic in ArrayValidator and GenericValidator; introduce CollectionElementValidator for improved element validation and add support for new types in IdentifierValidator --- src/Internal/Io/StreamWrapper.php | 35 +--- src/Internal/Util/PathMatcher.php | 26 ++- src/Internal/Validator/ArrayValidator.php | 135 ++---------- .../Validator/CollectionElementValidator.php | 176 ++++++++++++++++ src/Internal/Validator/GenericValidator.php | 193 ++---------------- .../Validator/IdentifierValidator.php | 9 + 6 files changed, 237 insertions(+), 337 deletions(-) create mode 100644 src/Internal/Validator/CollectionElementValidator.php diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 757648c..d1603f5 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -122,20 +122,6 @@ final class StreamWrapper implements StreamWrapperInterface */ private static bool $forceTelemetry = false; - /** - * Native PHP functions that read raw source code for viewing, highlighting, or tokenizing. - * - * @var array - */ - private const READ_ONLY_FUNCTIONS = [ - 'file_get_contents' => true, - 'file' => true, - 'readfile' => true, - 'highlight_file' => true, - 'show_source' => true, - 'token_get_all' => true, - ]; - /** * Returns a shared singleton instance of the PHP-Parser parser. */ @@ -401,8 +387,7 @@ private static function neutralizeTrailingLineComments(string $code): string * Opens a file stream using a multi-stage validation pipeline: * 1. Rejects non-include operations, non-read modes, and non-PHP files directly. * 2. Runs high-speed string prefix pre-filtering to bypass excluded vendor, cache, and storage paths. - * 3. Detects native source-viewing functions (error screen highlighters and tokenizers) to serve raw source code. - * 4. Executes a single unregister/register block for application files to load cached or memory-transformed bytecode. + * 3. Executes a single unregister/register block for application files to load cached or memory-transformed bytecode. */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { @@ -440,10 +425,6 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $this->openDirectHandle($path, $mode, $options); } - if (self::isReadOnlyCall()) { - return $this->openDirectHandle($path, $mode, $options); - } - self::unregister(); if (isset(self::$realpathCache[$path])) { @@ -508,20 +489,6 @@ private static function isPharCall(): bool return false; } - /** - * Determines whether the stream is being opened by a native PHP source-viewing function - * (e.g. highlight_file, show_source, file_get_contents, token_get_all) by inspecting shallow backtrace frames. - */ - private static function isReadOnlyCall(): bool - { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); - - $caller1 = strtolower($trace[1]['function'] ?? ''); - $caller2 = strtolower($trace[2]['function'] ?? ''); - - return isset(self::READ_ONLY_FUNCTIONS[$caller1]) || isset(self::READ_ONLY_FUNCTIONS[$caller2]); - } - /** * Opens an underlying filesystem handle directly with error reporting options and context support. */ diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index a9b0d6a..101e4a1 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -47,13 +47,21 @@ final class PathMatcher private static ?string $cachedLibSrcDir = null; /** - * Resets compiled pattern, prefix, and directory caches. + * Cache for canonicalized paths. + * + * @var array + */ + private static array $canonicalPathCache = []; + + /** + * Resets compiled pattern, prefix, directory, and canonical path caches. */ public static function reset(): void { self::$compiledIncludesCache = []; self::$compiledExcludesCache = []; self::$includePrefixCache = []; + self::$canonicalPathCache = []; self::$cachedCacheDir = null; self::$cachedLibSrcDir = null; CacheManager::reset(); @@ -77,12 +85,16 @@ public static function normalizePath(string|false|null $path): string */ public static function canonicalizePath(string $path): string { - $path = str_replace('\\', '/', $path); - if (! str_contains($path, '..') && ! str_contains($path, '/.')) { - return $path; + if (isset(self::$canonicalPathCache[$path])) { + return self::$canonicalPathCache[$path]; + } + + $normalized = str_replace('\\', '/', $path); + if (! str_contains($normalized, '..') && ! str_contains($normalized, '/.')) { + return self::$canonicalPathCache[$path] = $normalized; } - $parts = explode('/', $path); + $parts = explode('/', $normalized); $absolutes = []; foreach ($parts as $part) { @@ -111,10 +123,10 @@ public static function canonicalizePath(string $path): string } if ($absolutes === ['']) { - return '/'; + return self::$canonicalPathCache[$path] = '/'; } - return implode('/', $absolutes); + return self::$canonicalPathCache[$path] = implode('/', $absolutes); } /** diff --git a/src/Internal/Validator/ArrayValidator.php b/src/Internal/Validator/ArrayValidator.php index 3352a41..45eba2f 100644 --- a/src/Internal/Validator/ArrayValidator.php +++ b/src/Internal/Validator/ArrayValidator.php @@ -6,13 +6,11 @@ use Generator; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; -use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use Traversable; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; -use TypePHP\Internal\Util\Config; /** * Validates array and Traversable collection instances against ArrayTypeNode ASTs (Type[]). @@ -28,85 +26,24 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } - /** @var ArrayTypeNode $arrayNode */ - $arrayNode = $node; - if ($value instanceof Generator) { return null; } - if (\is_array($value)) { - $count = \count($value); - if ($count === 0) { - return null; - } - - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - return $this->validateArrayHybrid($value, $arrayNode, $context, $registry, $count); - } - - if ($arrayNode->type instanceof IdentifierTypeNode) { - $elemName = $arrayNode->type->name; - - if ($elemName === 'int') { - foreach ($value as $k => $v) { - if (! \is_int($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'string') { - foreach ($value as $k => $v) { - if (! \is_string($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'bool') { - foreach ($value as $k => $v) { - if (! \is_bool($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'float') { - foreach ($value as $k => $v) { - if (! \is_float($v) && ! \is_int($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - } - - foreach ($value as $k => $v) { - $err = $registry->validate($v, $arrayNode->type, '', $isSensitive); - if ($err !== null) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); - } - } + /** + * @var ArrayTypeNode + */ + $arrayNode = $node; - return null; + if (\is_array($value)) { + return CollectionElementValidator::validateElements( + $value, + $arrayNode->type, + $context, + $registry, + isList: false, + isSensitive: $isSensitive, + ); } foreach ($value as $k => $v) { @@ -122,50 +59,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } - - /** - * @param array $value - */ - private function validateArrayHybrid( - array $value, - ArrayTypeNode $arrayNode, - string $context, - TypeValidatorRegistry $registry, - int $count - ): ?ErrorMessage { - if (array_is_list($value)) { - $sampleIndices = [0, $count - 1]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleIndices[] = mt_rand(1, $count - 2); - } - - foreach ($sampleIndices as $idx) { - $err = $registry->validate($value[$idx], $arrayNode->type, ''); - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $idx . ']' . $err->getMessage()); - } - } - - return null; - } - - $keys = array_keys($value); - $sampleKeys = [$keys[0], $keys[$count - 1]]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; - } - - foreach ($sampleKeys as $k) { - $err = $registry->validate($value[$k], $arrayNode->type, ''); - if ($err !== null) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); - } - } - - return null; - } } diff --git a/src/Internal/Validator/CollectionElementValidator.php b/src/Internal/Validator/CollectionElementValidator.php new file mode 100644 index 0000000..ca0595a --- /dev/null +++ b/src/Internal/Validator/CollectionElementValidator.php @@ -0,0 +1,176 @@ + $items + */ + public static function validateElements( + array $items, + TypeNode $elementTypeNode, + string $context, + TypeValidatorRegistry $registry, + bool $isList = false, + bool $isSensitive = false, + ?int $count = null, + ): ?ErrorMessage { + $count ??= \count($items); + if ($count === 0) { + return null; + } + + if ($elementTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($elementTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { + return null; + } + + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + return self::validateHybrid($items, $elementTypeNode, $context, $registry, $count, $isList, $isSensitive); + } + + if ($elementTypeNode instanceof IdentifierTypeNode) { + $elemName = $elementTypeNode->name; + + if ($elemName === 'int') { + foreach ($items as $k => $v) { + if (! \is_int($v)) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'positive-int') { + foreach ($items as $k => $v) { + if (! \is_int($v) || $v <= 0) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type positive-int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'string') { + foreach ($items as $k => $v) { + if (! \is_string($v)) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'non-empty-string') { + foreach ($items as $k => $v) { + if (! \is_string($v) || $v === '') { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type non-empty-string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'bool') { + foreach ($items as $k => $v) { + if (! \is_bool($v)) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + + if ($elemName === 'float') { + foreach ($items as $k => $v) { + if (! \is_float($v) && ! \is_int($v)) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . ' must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); + } + } + + return null; + } + } + + foreach ($items as $k => $v) { + $err = $registry->validate($v, $elementTypeNode, '', $isSensitive); + if ($err !== null) { + return ErrorFactory::createError($context . self::formatKey($k, $isList) . $err->getMessage()); + } + } + + return null; + } + + /** + * @param array $items + */ + private static function validateHybrid( + array $items, + TypeNode $elementTypeNode, + string $context, + TypeValidatorRegistry $registry, + int $count, + bool $isList, + bool $isSensitive, + ): ?ErrorMessage { + if ($isList || array_is_list($items)) { + $sampleIndices = [0, $count - 1]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleIndices[] = mt_rand(1, $count - 2); + } + + foreach ($sampleIndices as $idx) { + $err = $registry->validate($items[$idx], $elementTypeNode, '', $isSensitive); + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $idx . ']' . $err->getMessage()); + } + } + + return null; + } + + $keys = array_keys($items); + $sampleKeys = [$keys[0], $keys[$count - 1]]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; + } + + foreach ($sampleKeys as $k) { + $err = $registry->validate($items[$k], $elementTypeNode, '', $isSensitive); + if ($err !== null) { + $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; + + return ErrorFactory::createError($context . '[' . $keyStr . ']' . $err->getMessage()); + } + } + + return null; + } + + private static function formatKey(int|string $k, bool $isList): string + { + if ($isList) { + return '[' . $k . ']'; + } + + return '[' . (\is_string($k) ? "'" . $k . "'" : (string) $k) . ']'; + } +} diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 7810e1f..ee0b281 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -474,88 +474,15 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return null; } - if ($valueTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valueTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { - return null; - } - - $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valueTypeNode->type->name)])); - - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - $sampleIndices = [0, $count - 1]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleIndices[] = mt_rand(1, $count - 2); - } - - foreach ($sampleIndices as $k) { - $v = $value[$k]; - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valueTypeNode, '', $isSensitive) - : $registry->validate($v, $valueTypeNode, '', $isSensitive); - - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } - - return null; - } - - if ($valueTypeNode instanceof IdentifierTypeNode) { - $elemName = $valueTypeNode->name; - - if ($elemName === 'int') { - foreach ($value as $k => $v) { - if (! \is_int($v)) { - return ErrorFactory::createError($context . '[' . $k . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'string') { - foreach ($value as $k => $v) { - if (! \is_string($v)) { - return ErrorFactory::createError($context . '[' . $k . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'bool') { - foreach ($value as $k => $v) { - if (! \is_bool($v)) { - return ErrorFactory::createError($context . '[' . $k . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'float') { - foreach ($value as $k => $v) { - if (! \is_float($v) && ! \is_int($v)) { - return ErrorFactory::createError($context . '[' . $k . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - } - - foreach ($value as $k => $v) { - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valueTypeNode, '', $isSensitive) - : $registry->validate($v, $valueTypeNode, '', $isSensitive); - - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } - - return null; + return CollectionElementValidator::validateElements( + $value, + $valueTypeNode, + $context, + $registry, + isList: true, + isSensitive: $isSensitive, + count: $count, + ); } private function validateArray(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage @@ -602,99 +529,17 @@ private function validateSingleTypeArray( string $context, TypeValidatorRegistry $registry, bool $isSensitive, - int $count + int $count, ): ?ErrorMessage { - if ($valTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { - return null; - } - - $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! isset(self::BUILTIN_GENERICS[strtolower($valTypeNode->type->name)])); - - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - $keys = array_keys($value); - $sampleKeys = [$keys[0], $keys[$count - 1]]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; - } - - foreach ($sampleKeys as $k) { - $v = $value[$k]; - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) - : $registry->validate($v, $valTypeNode, '', $isSensitive); - - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } - - return null; - } - - if ($valTypeNode instanceof IdentifierTypeNode) { - $elemName = $valTypeNode->name; - - if ($elemName === 'int') { - foreach ($value as $k => $v) { - if (! \is_int($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type int, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'string') { - foreach ($value as $k => $v) { - if (! \is_string($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type string, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'bool') { - foreach ($value as $k => $v) { - if (! \is_bool($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type bool, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - - if ($elemName === 'float') { - foreach ($value as $k => $v) { - if (! \is_float($v) && ! \is_int($v)) { - $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; - - return ErrorFactory::createError($context . '[' . $keyStr . '] must be of type float, ' . TypeFormatter::formatGivenValue($v, $isSensitive) . ' given'); - } - } - - return null; - } - } - - foreach ($value as $k => $v) { - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) - : $registry->validate($v, $valTypeNode, '', $isSensitive); - - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } - - return null; + return CollectionElementValidator::validateElements( + $value, + $valTypeNode, + $context, + $registry, + isList: false, + isSensitive: $isSensitive, + count: $count, + ); } /** diff --git a/src/Internal/Validator/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index b0b32de..9d7bc0b 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -29,6 +29,15 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali if ($name === 'string') { return \is_string($value) ? null : ErrorFactory::createError($context . ' must be of type string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } + if ($name === 'positive-int') { + return (\is_int($value) && $value > 0) ? null : ErrorFactory::createError($context . ' must be of type positive-int, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'non-empty-string') { + return (\is_string($value) && $value !== '') ? null : ErrorFactory::createError($context . ' must be of type non-empty-string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } + if ($name === 'array-key') { + return (\is_int($value) || \is_string($value)) ? null : ErrorFactory::createError($context . ' must be of type array-key, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); + } if ($name === 'bool') { return \is_bool($value) ? null : ErrorFactory::createError($context . ' must be of type bool, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } From 30e534021525764dd28c0d01c81a555e6a440a4e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 15 Oct 2026 00:05:02 +0800 Subject: [PATCH 03/11] Implement PHP 8.5 "clone with" functionality; add RuntimeTypeChecker::cloneWith method and related tests for property validation and generic type preservation --- src/Internal/Ast/ContractVisitor.php | 47 ++++ src/Internal/RuntimeTypeChecker.php | 35 +++ .../Php85/CloneWithFunctionalFixtures.php | 123 ++++++++++ .../Boundaries/FunctionalCloneWithTest.php | 220 ++++++++++++++++++ .../Boundaries/Php85CloneWithTest.php | 197 ++++++++++++++++ 5 files changed, 622 insertions(+) create mode 100644 tests/Fixtures/Php85/CloneWithFunctionalFixtures.php create mode 100644 tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php create mode 100644 tests/TypeChecking/Boundaries/Php85CloneWithTest.php diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 6bb82d2..1e8cfbb 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -197,6 +197,13 @@ public function leaveNode(Node $node): ?Node return $this->wrapClone($node); } + if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name && strtolower($node->name->getLast()) === 'clone') { + $replacement = $this->wrapCloneFunction($node); + if ($replacement !== null) { + return $replacement; + } + } + if ($node instanceof Node\Expr\AssignOp) { $replacement = $this->handleAssignOp($node); if ($replacement !== null) { @@ -245,6 +252,46 @@ public function leaveNode(Node $node): ?Node return null; } + private function wrapCloneFunction(Node\Expr\FuncCall $node): ?Node\Expr + { + if ($node->getAttribute('typephp_wrapped') === true) { + return null; + } + + $firstArg = $node->args[0] ?? null; + if (! $firstArg instanceof Node\Arg) { + return null; + } + + $node->setAttribute('typephp_wrapped', true); + + $originalExpr = $firstArg->value; + + $secondArg = $node->args[1] ?? null; + $propertiesExpr = $secondArg instanceof Node\Arg + ? $secondArg->value + : new Node\Expr\ConstFetch(new Node\Name('null')); + + $node->args[0] = new Node\Arg( + new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::prepareClone'), + [new Node\Arg($originalExpr)], + ), + ); + + return new Node\Expr\StaticCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'), + 'cloneWith', + [ + new Node\Arg($node), + new Node\Arg($originalExpr), + new Node\Arg($propertiesExpr), + new Node\Arg(new Node\Scalar\MagicConst\File()), + new Node\Arg(new Node\Scalar\LNumber($node->getStartLine())), + ], + ); + } + private function markWriteContext(Node $node): void { while ($node instanceof Node\Expr\ArrayDimFetch || $node instanceof Node\Expr\PropertyFetch) { diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index eb5d3e7..4ac48c4 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -643,6 +643,41 @@ public static function cloneInstance(mixed $cloned, mixed $original): mixed return $cloned; } + /** + * PHP 8.5: Copies generic bindings and validates overridden properties on clone($obj, [...]). + * + * @param array|null $overriddenProperties + */ + public static function cloneWith( + mixed $cloned, + mixed $original = null, + ?array $overriddenProperties = null, + string $file = '', + ?int $line = null, + ): mixed { + $source = \is_object($original) ? $original : TemplateManager::$pendingCloneSource; + TemplateManager::$pendingCloneSource = null; + + if (! \is_object($cloned) || ! \is_object($source)) { + return $cloned; + } + + TemplateManager::copyInstanceBindings($source, $cloned); + + if ($overriddenProperties !== null && $overriddenProperties !== [] && Config::isInlinePropertiesEnabled()) { + foreach ($overriddenProperties as $propName => $propValue) { + $res = self::checkProperty($propValue, $cloned, (string) $propName, $file); + if ($res instanceof ErrorMessage) { + $typeError = new \TypePHP\Exception\TypeError($res->getMessage()); + + throw ErrorFactory::prepareException($typeError, $line, $file !== '' ? $file : null); + } + } + } + + return $cloned; + } + /** * Infers a TypeNode AST representation from a raw PHP value. */ diff --git a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php new file mode 100644 index 0000000..685f18f --- /dev/null +++ b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php @@ -0,0 +1,123 @@ +item = $item; + } + + /** + * @param T $newItem + */ + public function withItem(mixed $newItem): static + { + return clone($this, ['item' => $newItem]); + } +} + +/** + * @template K of array-key + * @template V + */ +class MultiTemplateMapFixture +{ + /** + * @var array + */ + public array $data; + + /** + * @param array $data + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * @param array $newData + */ + public function withData(array $newData): static + { + return clone($this, ['data' => $newData]); + } +} + +final readonly class Psr7ResponseWitherFixture +{ + /** + * @var int<100, 599> + */ + public int $statusCode; + + /** + * @var non-empty-string + */ + public string $reasonPhrase; + + public function __construct(int $statusCode = 200, string $reasonPhrase = 'OK') + { + $this->statusCode = $statusCode; + $this->reasonPhrase = $reasonPhrase; + } + + public function withStatus(int $code, string $reasonPhrase = 'OK'): self + { + return clone($this, [ + 'statusCode' => $code, + 'reasonPhrase' => $reasonPhrase, + ]); + } +} + +class MagicCloneWithFixture +{ + /** + * @var positive-int + */ + public int $counter = 10; + + public bool $magicCloneExecuted = false; + + public function __clone(): void + { + $this->magicCloneExecuted = true; + $this->counter = 999; + } +} + +class HookedCloneWithFixture +{ + /** + * @var positive-int + */ + public int $total = 100 { + set(int $value) => $value; + } +} + +class ShapeCloneWithFixture +{ + /** + * @var array{id: positive-int, role: 'admin'|'user'} + */ + public array $session = ['id' => 1, 'role' => 'user']; +} diff --git a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php new file mode 100644 index 0000000..e8743c1 --- /dev/null +++ b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php @@ -0,0 +1,220 @@ +toBe(Dog::class); + + $newDog = new Dog(); + $clonedBox = $box->withItem($newDog); + + expect($clonedBox)->not()->toBe($box) + ->and($clonedBox->item)->toBe($newDog) + ->and(TypePHP::getGenericType($clonedBox))->toBe(Dog::class) + ; + }); + + test('throws TypeError when wither method attempts to clone with incompatible type', function () { + $box = new BoundedGenericBoxFixture(new Dog()); + + expect(fn () => $box->withItem(new Car())) + ->toThrow(TypeError::class, 'must be of type ' . Dog::class) + ; + }); + + test('validates external clone with on public generic property', function () { + $box = new BoundedGenericBoxFixture(new Dog()); + + $validClone = clone($box, ['item' => new Dog()]); + expect(TypePHP::getGenericType($validClone))->toBe(Dog::class); + + expect(fn () => clone($box, ['item' => new Car()])) + ->toThrow(TypeError::class, 'must be of type ' . Dog::class) + ; + }); + }); + + describe('2. Multi-Template Generic Maps (Map)', function () { + test('preserves both K and V templates and validates new array data on clone', function () { + $map = new MultiTemplateMapFixture(['user_1' => 100, 'user_2' => 200]); + + expect(TypePHP::getGenericType($map, 'K'))->toBe('string') + ->and(TypePHP::getGenericType($map, 'V'))->toBe('int') + ; + + $updatedMap = $map->withData(['user_3' => 300]); + + expect(TypePHP::getGenericType($updatedMap, 'K'))->toBe('string') + ->and(TypePHP::getGenericType($updatedMap, 'V'))->toBe('int') + ->and($updatedMap->data)->toBe(['user_3' => 300]) + ; + }); + + test('throws TypeError when clone override violates generic value type V', function () { + $map = new MultiTemplateMapFixture(['user_1' => 100]); + + expect(fn () => clone($map, ['data' => ['user_1' => 'not_an_int']])) + ->toThrow(TypeError::class, "['user_1'] must be of type int") + ; + }); + }); + + describe('3. PSR-7 Readonly Class Wither Pattern', function () { + test('allows modifying readonly properties via withStatus() inside class scope', function () { + $res = new Psr7ResponseWitherFixture(200, 'OK'); + + $notFound = $res->withStatus(404, 'Not Found'); + + expect($notFound->statusCode)->toBe(404) + ->and($notFound->reasonPhrase)->toBe('Not Found') + ->and($res->statusCode)->toBe(200) + ; + }); + + test('throws TypeError when withStatus() receives status code violating int<100, 599>', function () { + $res = new Psr7ResponseWitherFixture(); + + expect(fn () => $res->withStatus(999, 'Custom Status')) + ->toThrow(TypeError::class, '<= 599') + ; + + expect(fn () => $res->withStatus(50, 'Too Low')) + ->toThrow(TypeError::class, '>= 100') + ; + }); + + test('throws TypeError when withStatus() receives empty string for non-empty-string reasonPhrase', function () { + $res = new Psr7ResponseWitherFixture(); + + expect(fn () => $res->withStatus(200, '')) + ->toThrow(TypeError::class, 'non-empty-string') + ; + }); + }); + + describe('4. Magic __clone() Execution Precedence', function () { + test('executes __clone() first, but applies clone-with property overrides as final state', function () { + $model = new MagicCloneWithFixture(); + expect($model->counter)->toBe(10); + + // In PHP 8.5 RFC: __clone() runs (setting counter to 999), then ['counter' => 50] overwrites it + $cloned = clone($model, ['counter' => 50]); + + expect($cloned->magicCloneExecuted)->toBeTrue() + ->and($cloned->counter)->toBe(50) + ; + }); + + test('validates final overridden state even when __clone() mutated the object first', function () { + $model = new MagicCloneWithFixture(); + + expect(fn () => clone($model, ['counter' => -100])) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + }); + + describe('5. PHP 8.4 Property Hooks with PHP 8.5 Clone With', function () { + test('triggers set hook during property write and enforces contract', function () { + $order = new HookedCloneWithFixture(); + + $cloned = clone($order, ['total' => 250]); + expect($cloned->total)->toBe(250); + + expect(fn () => clone($order, ['total' => -50])) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + }); + + describe('6. Array Shape Properties Overrides', function () { + test('validates array shape properties on cloned instance', function () { + $fixture = new ShapeCloneWithFixture(); + + $cloned = clone($fixture, [ + 'session' => ['id' => 42, 'role' => 'admin'], + ]); + + expect($cloned->session)->toBe(['id' => 42, 'role' => 'admin']); + }); + + test('throws TypeError when array shape id violates positive-int', function () { + $fixture = new ShapeCloneWithFixture(); + + expect(fn () => clone($fixture, ['session' => ['id' => -1, 'role' => 'user']])) + ->toThrow(TypeError::class, "['id'] must be of type positive-int") + ; + }); + + test('throws TypeError when array shape role violates literal union', function () { + $fixture = new ShapeCloneWithFixture(); + + expect(fn () => clone($fixture, ['session' => ['id' => 10, 'role' => 'superuser']])) + ->toThrow(TypeError::class, "['role'] must be of type ('admin' | 'user')") + ; + }); + }); + + describe('7. Chained Withers & Nested Clones', function () { + test('executes multi-step wither chain without state corruption', function () { + $res = (new Psr7ResponseWitherFixture()) + ->withStatus(301, 'Moved Permanently') + ->withStatus(200, 'OK') + ; + + expect($res->statusCode)->toBe(200) + ->and($res->reasonPhrase)->toBe('OK') + ; + }); + + test('throws TypeError on second step of wither chain when invalid parameter is passed', function () { + expect(fn () => (new Psr7ResponseWitherFixture()) + ->withStatus(200, 'OK') + ->withStatus(750, 'Invalid Status')) + ->toThrow(TypeError::class, '<= 599') + ; + }); + }); + + describe('8. Audit Report Mode with Clone With (on_violation => report)', function () { + test('records violation in report mode without throwing when clone-with property fails contract', function () { + Config::set(['on_violation' => 'report']); + + $user = new ShapeCloneWithFixture(); + $cloned = clone($user, ['session' => ['id' => -999, 'role' => 'user']]); + + expect($cloned)->toBeInstanceOf(ShapeCloneWithFixture::class); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('property') + ->and($violations[0]->target)->toBe('$session') + ; + }); + }); +}); diff --git a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php new file mode 100644 index 0000000..1670eac --- /dev/null +++ b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php @@ -0,0 +1,197 @@ +id = $id; + $this->sku = $sku; + } + + public function withId(int $id): self + { + return clone($this, ['id' => $id]); + } +} + +describe('PHP 8.5 "Clone With" Syntax Support (TDD)', function () { + describe('1. AST Transformation (nikic/php-parser FuncCall interception)', function () { + test('transforms PHP 8.5 clone($obj, [...]) function call into RuntimeTypeChecker::cloneWith wrapper', function () { + $source = <<<'PHP' + 20]); +PHP; + + $transformed = StreamWrapper::transformSource($source, 'test_clone_with.php'); + + expect($transformed)->toContain('RuntimeTypeChecker::cloneWith') + ->and($transformed)->toContain('[\'score\' => 20]') + ; + }); + + test('transforms single-argument clone($obj) into RuntimeTypeChecker::cloneInstance wrapper', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::cloneInstance'); + }); + }); + + describe('2. Runtime Generic Type Preservation via WeakMap', function () { + test('preserves generic template bindings when cloning an object with cloneWith helper', function () { + /** @var GenericBox $original */ + $original = new GenericBox(); + $original->set(new Dog()); + + $cloned = clone $original; + + RuntimeTypeChecker::cloneWith($cloned, $original); + + expect(TypePHP::getGenericType($cloned))->toBe(Dog::class); + + $cloned->set(new Dog()); + expect($cloned->item)->toBeInstanceOf(Dog::class); + + expect(fn () => $cloned->set(new Car())) + ->toThrow(TypeError::class, Dog::class) + ; + }); + }); + + describe('3. Overridden Property Validation on Clone', function () { + test('allows valid property overrides on cloned instances without throwing', function () { + $user = new CloneWithModelFixture(); + $cloned = clone $user; + + $result = RuntimeTypeChecker::cloneWith( + $cloned, + $user, + ['score' => 50, 'name' => 'Bob'], + __FILE__, + __LINE__ + ); + + expect($result)->toBe($cloned); + }); + + test('throws TypeError when overridden property violates positive-int contract', function () { + $user = new CloneWithModelFixture(); + $cloned = clone $user; + + expect(function () use ($cloned, $user) { + RuntimeTypeChecker::cloneWith( + $cloned, + $user, + ['score' => -5], + __FILE__, + __LINE__ + ); + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError when overridden property violates non-empty-string contract', function () { + $user = new CloneWithModelFixture(); + $cloned = clone $user; + + expect(function () use ($cloned, $user) { + RuntimeTypeChecker::cloneWith( + $cloned, + $user, + ['name' => ''], + __FILE__, + __LINE__ + ); + })->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('validates overridden properties on readonly classes during cloneWith', function () { + $order = new CloneWithReadonlyOrder(100, 'SKU-ORIGINAL'); + $cloned = clone $order; + + expect(function () use ($cloned, $order) { + RuntimeTypeChecker::cloneWith( + $cloned, + $order, + ['id' => -999], + __FILE__, + __LINE__ + ); + })->toThrow(TypeError::class, 'positive-int'); + }); + }); + + describe('4. Native PHP 8.5 Execution End-to-End', function () { + test('executes native clone($obj, [...]) through stream wrapper and enforces type contracts', function () { + $source = <<<'PHP' +withId(100); + +$user = new \TypePHP\Tests\TypeChecking\Boundaries\CloneWithModelFixture(); +$validUser = clone($user, ['score' => 50]); + +$badUser = clone($user, ['score' => -10]); +PHP; + + $tempDir = sys_get_temp_dir() . '/typephp_php85_clone_' . uniqid(); + mkdir($tempDir, 0777, true); + $script = $tempDir . '/run_clone.php'; + file_put_contents($script, $source); + + try { + StreamWrapper::register(); + + expect(fn () => require $script) + ->toThrow(TypeError::class, 'positive-int') + ; + } finally { + @unlink($script); + @rmdir($tempDir); + } + }); + }); +}); From df3fd82b5fa7bb375308a4a1119d2ce8b0be7dd1 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 15 Oct 2026 00:13:54 +0800 Subject: [PATCH 04/11] fix failing test on 8.5 --- .../Php85/CloneWithFunctionalFixtures.php | 52 +++++++++++++++++++ .../Boundaries/FunctionalCloneWithTest.php | 20 +++---- .../Boundaries/Php85CloneWithTest.php | 45 +++------------- 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php index 685f18f..2800741 100644 --- a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php +++ b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php @@ -6,6 +6,58 @@ use TypePHP\Tests\Fixtures\Domain\Animal; +/** + * Executes native PHP 8.5 clone-with operations so test files don't fail parsing on PHP <= 8.4. + */ +class NativeCloneWithRunner +{ + public static function clone(object $object, ?array $properties = null): object + { + if ($properties !== null) { + return clone($object, $properties); + } + + return clone $object; + } +} + +class CloneWithModelFixture +{ + /** + * @var positive-int + */ + public int $score = 10; + + /** + * @var non-empty-string + */ + public string $name = 'Alice'; +} + +final readonly class CloneWithReadonlyOrder +{ + /** + * @var positive-int + */ + public int $id; + + /** + * @var non-empty-string + */ + public string $sku; + + public function __construct(int $id, string $sku) + { + $this->id = $id; + $this->sku = $sku; + } + + public function withId(int $id): self + { + return clone($this, ['id' => $id]); + } +} + /** * @template T of Animal */ diff --git a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php index e8743c1..b23d7c6 100644 --- a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php +++ b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php @@ -18,6 +18,7 @@ use TypePHP\Tests\Fixtures\Php85\HookedCloneWithFixture; use TypePHP\Tests\Fixtures\Php85\MagicCloneWithFixture; use TypePHP\Tests\Fixtures\Php85\MultiTemplateMapFixture; +use TypePHP\Tests\Fixtures\Php85\NativeCloneWithRunner; use TypePHP\Tests\Fixtures\Php85\Psr7ResponseWitherFixture; use TypePHP\Tests\Fixtures\Php85\ShapeCloneWithFixture; use TypePHP\TypePHP; @@ -78,7 +79,7 @@ test('throws TypeError when clone override violates generic value type V', function () { $map = new MultiTemplateMapFixture(['user_1' => 100]); - expect(fn () => clone($map, ['data' => ['user_1' => 'not_an_int']])) + expect(fn () => NativeCloneWithRunner::clone($map, ['data' => ['user_1' => 'not_an_int']])) ->toThrow(TypeError::class, "['user_1'] must be of type int") ; }); @@ -122,8 +123,7 @@ $model = new MagicCloneWithFixture(); expect($model->counter)->toBe(10); - // In PHP 8.5 RFC: __clone() runs (setting counter to 999), then ['counter' => 50] overwrites it - $cloned = clone($model, ['counter' => 50]); + $cloned = NativeCloneWithRunner::clone($model, ['counter' => 50]); expect($cloned->magicCloneExecuted)->toBeTrue() ->and($cloned->counter)->toBe(50) @@ -133,7 +133,7 @@ test('validates final overridden state even when __clone() mutated the object first', function () { $model = new MagicCloneWithFixture(); - expect(fn () => clone($model, ['counter' => -100])) + expect(fn () => NativeCloneWithRunner::clone($model, ['counter' => -100])) ->toThrow(TypeError::class, 'positive-int') ; }); @@ -143,10 +143,10 @@ test('triggers set hook during property write and enforces contract', function () { $order = new HookedCloneWithFixture(); - $cloned = clone($order, ['total' => 250]); + $cloned = NativeCloneWithRunner::clone($order, ['total' => 250]); expect($cloned->total)->toBe(250); - expect(fn () => clone($order, ['total' => -50])) + expect(fn () => NativeCloneWithRunner::clone($order, ['total' => -50])) ->toThrow(TypeError::class, 'positive-int') ; }); @@ -156,7 +156,7 @@ test('validates array shape properties on cloned instance', function () { $fixture = new ShapeCloneWithFixture(); - $cloned = clone($fixture, [ + $cloned = NativeCloneWithRunner::clone($fixture, [ 'session' => ['id' => 42, 'role' => 'admin'], ]); @@ -166,7 +166,7 @@ test('throws TypeError when array shape id violates positive-int', function () { $fixture = new ShapeCloneWithFixture(); - expect(fn () => clone($fixture, ['session' => ['id' => -1, 'role' => 'user']])) + expect(fn () => NativeCloneWithRunner::clone($fixture, ['session' => ['id' => -1, 'role' => 'user']])) ->toThrow(TypeError::class, "['id'] must be of type positive-int") ; }); @@ -174,7 +174,7 @@ test('throws TypeError when array shape role violates literal union', function () { $fixture = new ShapeCloneWithFixture(); - expect(fn () => clone($fixture, ['session' => ['id' => 10, 'role' => 'superuser']])) + expect(fn () => NativeCloneWithRunner::clone($fixture, ['session' => ['id' => 10, 'role' => 'superuser']])) ->toThrow(TypeError::class, "['role'] must be of type ('admin' | 'user')") ; }); @@ -206,7 +206,7 @@ Config::set(['on_violation' => 'report']); $user = new ShapeCloneWithFixture(); - $cloned = clone($user, ['session' => ['id' => -999, 'role' => 'user']]); + $cloned = NativeCloneWithRunner::clone($user, ['session' => ['id' => -999, 'role' => 'user']]); expect($cloned)->toBeInstanceOf(ShapeCloneWithFixture::class); diff --git a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php index 1670eac..62b4aab 100644 --- a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php +++ b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php @@ -8,51 +8,18 @@ return; } +require_once __DIR__ . '/../../Fixtures/Php85/CloneWithFunctionalFixtures.php'; + use TypePHP\Exception\TypeError; use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\RuntimeTypeChecker; use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; use TypePHP\Tests\Fixtures\Generics\GenericBox; +use TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture; +use TypePHP\Tests\Fixtures\Php85\CloneWithReadonlyOrder; use TypePHP\TypePHP; -class CloneWithModelFixture -{ - /** - * @var positive-int - */ - public int $score = 10; - - /** - * @var non-empty-string - */ - public string $name = 'Alice'; -} - -final readonly class CloneWithReadonlyOrder -{ - /** - * @var positive-int - */ - public int $id; - - /** - * @var non-empty-string - */ - public string $sku; - - public function __construct(int $id, string $sku) - { - $this->id = $id; - $this->sku = $sku; - } - - public function withId(int $id): self - { - return clone($this, ['id' => $id]); - } -} - describe('PHP 8.5 "Clone With" Syntax Support (TDD)', function () { describe('1. AST Transformation (nikic/php-parser FuncCall interception)', function () { test('transforms PHP 8.5 clone($obj, [...]) function call into RuntimeTypeChecker::cloneWith wrapper', function () { @@ -168,10 +135,10 @@ public function withId(int $id): self $source = <<<'PHP' withId(100); -$user = new \TypePHP\Tests\TypeChecking\Boundaries\CloneWithModelFixture(); +$user = new \TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture(); $validUser = clone($user, ['score' => 50]); $badUser = clone($user, ['score' => -10]); From 88bd13069ae11eb9db12f33bb92b5b7f46eb79fb Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 15 Oct 2026 00:25:04 +0800 Subject: [PATCH 05/11] Refactor clone method in NativeCloneWithRunner to cloneWith; update tests for consistency and type validation --- .../Php85/CloneWithFunctionalFixtures.php | 16 ++++------ .../Boundaries/FunctionalCloneWithTest.php | 32 +++++++++---------- .../Boundaries/Php85CloneWithTest.php | 3 +- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php index 2800741..fe66fa3 100644 --- a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php +++ b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php @@ -6,18 +6,14 @@ use TypePHP\Tests\Fixtures\Domain\Animal; -/** - * Executes native PHP 8.5 clone-with operations so test files don't fail parsing on PHP <= 8.4. - */ class NativeCloneWithRunner { - public static function clone(object $object, ?array $properties = null): object + /** + * @param array $properties + */ + public static function cloneWith(object $object, array $properties): object { - if ($properties !== null) { - return clone($object, $properties); - } - - return clone $object; + return clone($object, $properties); } } @@ -172,4 +168,4 @@ class ShapeCloneWithFixture * @var array{id: positive-int, role: 'admin'|'user'} */ public array $session = ['id' => 1, 'role' => 'user']; -} +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php index b23d7c6..08a6588 100644 --- a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php +++ b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php @@ -26,12 +26,12 @@ describe('Functional Type-Checking for PHP 8.5 "Clone With"', function () { describe('1. Generic Bound Preservation & Validation (@template T of Animal)', function () { test('preserves generic template T in WeakMap after wither method clone', function () { - $dog = new Dog(); + $dog = new Dog('Rover'); $box = new BoundedGenericBoxFixture($dog); expect(TypePHP::getGenericType($box))->toBe(Dog::class); - $newDog = new Dog(); + $newDog = new Dog('Max'); $clonedBox = $box->withItem($newDog); expect($clonedBox)->not()->toBe($box) @@ -41,7 +41,7 @@ }); test('throws TypeError when wither method attempts to clone with incompatible type', function () { - $box = new BoundedGenericBoxFixture(new Dog()); + $box = new BoundedGenericBoxFixture(new Dog('Rover')); expect(fn () => $box->withItem(new Car())) ->toThrow(TypeError::class, 'must be of type ' . Dog::class) @@ -49,12 +49,12 @@ }); test('validates external clone with on public generic property', function () { - $box = new BoundedGenericBoxFixture(new Dog()); + $box = new BoundedGenericBoxFixture(new Dog('Rover')); - $validClone = clone($box, ['item' => new Dog()]); + $validClone = NativeCloneWithRunner::cloneWith($box, ['item' => new Dog('Buddy')]); expect(TypePHP::getGenericType($validClone))->toBe(Dog::class); - expect(fn () => clone($box, ['item' => new Car()])) + expect(fn () => NativeCloneWithRunner::cloneWith($box, ['item' => new Car()])) ->toThrow(TypeError::class, 'must be of type ' . Dog::class) ; }); @@ -79,7 +79,7 @@ test('throws TypeError when clone override violates generic value type V', function () { $map = new MultiTemplateMapFixture(['user_1' => 100]); - expect(fn () => NativeCloneWithRunner::clone($map, ['data' => ['user_1' => 'not_an_int']])) + expect(fn () => NativeCloneWithRunner::cloneWith($map, ['data' => ['user_1' => 'not_an_int']])) ->toThrow(TypeError::class, "['user_1'] must be of type int") ; }); @@ -123,7 +123,7 @@ $model = new MagicCloneWithFixture(); expect($model->counter)->toBe(10); - $cloned = NativeCloneWithRunner::clone($model, ['counter' => 50]); + $cloned = NativeCloneWithRunner::cloneWith($model, ['counter' => 50]); expect($cloned->magicCloneExecuted)->toBeTrue() ->and($cloned->counter)->toBe(50) @@ -133,7 +133,7 @@ test('validates final overridden state even when __clone() mutated the object first', function () { $model = new MagicCloneWithFixture(); - expect(fn () => NativeCloneWithRunner::clone($model, ['counter' => -100])) + expect(fn () => NativeCloneWithRunner::cloneWith($model, ['counter' => -100])) ->toThrow(TypeError::class, 'positive-int') ; }); @@ -143,10 +143,10 @@ test('triggers set hook during property write and enforces contract', function () { $order = new HookedCloneWithFixture(); - $cloned = NativeCloneWithRunner::clone($order, ['total' => 250]); + $cloned = NativeCloneWithRunner::cloneWith($order, ['total' => 250]); expect($cloned->total)->toBe(250); - expect(fn () => NativeCloneWithRunner::clone($order, ['total' => -50])) + expect(fn () => NativeCloneWithRunner::cloneWith($order, ['total' => -50])) ->toThrow(TypeError::class, 'positive-int') ; }); @@ -156,7 +156,7 @@ test('validates array shape properties on cloned instance', function () { $fixture = new ShapeCloneWithFixture(); - $cloned = NativeCloneWithRunner::clone($fixture, [ + $cloned = NativeCloneWithRunner::cloneWith($fixture, [ 'session' => ['id' => 42, 'role' => 'admin'], ]); @@ -166,7 +166,7 @@ test('throws TypeError when array shape id violates positive-int', function () { $fixture = new ShapeCloneWithFixture(); - expect(fn () => NativeCloneWithRunner::clone($fixture, ['session' => ['id' => -1, 'role' => 'user']])) + expect(fn () => NativeCloneWithRunner::cloneWith($fixture, ['session' => ['id' => -1, 'role' => 'user']])) ->toThrow(TypeError::class, "['id'] must be of type positive-int") ; }); @@ -174,7 +174,7 @@ test('throws TypeError when array shape role violates literal union', function () { $fixture = new ShapeCloneWithFixture(); - expect(fn () => NativeCloneWithRunner::clone($fixture, ['session' => ['id' => 10, 'role' => 'superuser']])) + expect(fn () => NativeCloneWithRunner::cloneWith($fixture, ['session' => ['id' => 10, 'role' => 'superuser']])) ->toThrow(TypeError::class, "['role'] must be of type ('admin' | 'user')") ; }); @@ -206,7 +206,7 @@ Config::set(['on_violation' => 'report']); $user = new ShapeCloneWithFixture(); - $cloned = NativeCloneWithRunner::clone($user, ['session' => ['id' => -999, 'role' => 'user']]); + $cloned = NativeCloneWithRunner::cloneWith($user, ['session' => ['id' => -999, 'role' => 'user']]); expect($cloned)->toBeInstanceOf(ShapeCloneWithFixture::class); @@ -217,4 +217,4 @@ ; }); }); -}); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php index 62b4aab..ae242d5 100644 --- a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php +++ b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php @@ -62,6 +62,7 @@ $cloned->set(new Dog()); expect($cloned->item)->toBeInstanceOf(Dog::class); + expect(fn () => $cloned->set(new Car())) ->toThrow(TypeError::class, Dog::class) ; @@ -161,4 +162,4 @@ } }); }); -}); +}); \ No newline at end of file From 7609c00f4fc9def02b4af00979934f8f82d548ed Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 15 Oct 2026 00:26:54 +0800 Subject: [PATCH 06/11] fix code styling --- tests/Fixtures/Php85/CloneWithFunctionalFixtures.php | 2 +- tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php | 2 +- tests/TypeChecking/Boundaries/Php85CloneWithTest.php | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php index fe66fa3..2230185 100644 --- a/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php +++ b/tests/Fixtures/Php85/CloneWithFunctionalFixtures.php @@ -168,4 +168,4 @@ class ShapeCloneWithFixture * @var array{id: positive-int, role: 'admin'|'user'} */ public array $session = ['id' => 1, 'role' => 'user']; -} \ No newline at end of file +} diff --git a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php index 08a6588..5f1efdf 100644 --- a/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php +++ b/tests/TypeChecking/Boundaries/FunctionalCloneWithTest.php @@ -217,4 +217,4 @@ ; }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php index ae242d5..62b4aab 100644 --- a/tests/TypeChecking/Boundaries/Php85CloneWithTest.php +++ b/tests/TypeChecking/Boundaries/Php85CloneWithTest.php @@ -62,7 +62,6 @@ $cloned->set(new Dog()); expect($cloned->item)->toBeInstanceOf(Dog::class); - expect(fn () => $cloned->set(new Car())) ->toThrow(TypeError::class, Dog::class) ; @@ -162,4 +161,4 @@ } }); }); -}); \ No newline at end of file +}); From 4f5ee3d8898d42929bd5758057640f49a2c94b28 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 17 Oct 2026 21:07:56 +0800 Subject: [PATCH 07/11] Enhance type handling and variance checks in ParamChecker, TypeFormatter, and TemplateManager; improve GenericValidator's enum caching; add tests for F-bounded polymorphism and diagnostic formatting of non-generic classes. --- src/Internal/Checker/ParamChecker.php | 4 + src/Internal/Diagnostic/TypeFormatter.php | 17 ++- src/Internal/Generics/TemplateManager.php | 132 +++++++++++++++++- src/Internal/Validator/GenericValidator.php | 18 +-- .../Generics/FBoundedPolymorphismTest.php | 39 ++++++ ...ReifiedGenericDiagnosticFormattingTest.php | 101 ++++++++++++++ 6 files changed, 293 insertions(+), 18 deletions(-) diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 7622615..a805b11 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -1337,6 +1337,10 @@ private static function checkClassStringSatisfiesBound(string $val, TypeNode $bo return is_a($val, $boundName, allow_string: true); } + if ($boundNode instanceof GenericTypeNode) { + return TemplateManager::checkVariance(new IdentifierTypeNode($val), $boundNode, GenericTypeNode::VARIANCE_COVARIANT); + } + if ($boundNode instanceof UnionTypeNode) { foreach ($boundNode->types as $unionType) { if (self::checkClassStringSatisfiesBound($val, $unionType)) { diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index 0dcbce3..c111f8b 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -80,12 +80,21 @@ public static function formatGivenValue(mixed $value, bool $isSensitive = false) if (\is_object($value)) { $className = $value::class; - $bindings = TemplateManager::getBoundTemplatesForInstance($value); + $declaredTemplateNames = TemplateManager::getDeclaredClassTemplateNames($className); - if ($bindings !== []) { - $typeStrings = array_map(static fn ($node): string => (string) $node, array_values($bindings)); + if ($declaredTemplateNames !== []) { + $bindings = TemplateManager::getBoundTemplatesForInstance($value); - return $className . '<' . implode(', ', $typeStrings) . '>'; + $typeStrings = []; + foreach ($declaredTemplateNames as $tName) { + if (isset($bindings[$tName])) { + $typeStrings[] = (string) $bindings[$tName]; + } + } + + if ($typeStrings !== []) { + return $className . '<' . implode(', ', $typeStrings) . '>'; + } } return get_debug_type($value); diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index c23fa3d..dc36336 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -394,6 +394,20 @@ final class TemplateManager */ private static array $pendingInstantiations = []; + /** + * Stack tracking in-progress variance checks to prevent infinite recursion on self-referential/F-bound cycles. + * + * @var array + */ + private static array $varianceCycleStack = []; + + /** + * Cache for declared class template names directly on a class (excluding parent interfaces). + * + * @var array> + */ + private static array $declaredClassTemplateNamesCache = []; + /** * Resets all static generic template bindings, call stack frames, and method template caches. */ @@ -410,6 +424,8 @@ public static function reset(): void self::$isMethodTemplateCache = []; self::$isFBoundCache = []; self::$pendingInstantiations = []; + self::$varianceCycleStack = []; + self::$declaredClassTemplateNamesCache = []; } /** @@ -467,6 +483,37 @@ public static function applyPendingInstantiation(object $instance): void } } + /** + * Returns template names explicitly declared on the class itself. + * + * @return list + */ + public static function getDeclaredClassTemplateNames(string $className): array + { + if (isset(self::$declaredClassTemplateNamesCache[$className])) { + return self::$declaredClassTemplateNamesCache[$className]; + } + + if (! class_exists($className, false) && ! class_exists($className) && ! interface_exists($className, false) && ! interface_exists($className) && ! trait_exists($className, false) && ! trait_exists($className) && ! enum_exists($className, false) && ! enum_exists($className)) { + return self::$declaredClassTemplateNamesCache[$className] = []; + } + + try { + /** @var class-string $className */ + $ref = new \ReflectionClass($className); + $doc = StubManager::getClassDoc($className) ?? $ref->getDocComment(); + if ($doc !== false && $doc !== null) { + $phpDocNode = DocblockExtractor::parseDocString($doc); + $templates = DocblockExtractor::extractTemplates($phpDocNode); + + return self::$declaredClassTemplateNamesCache[$className] = array_keys($templates); + } + } catch (\Throwable $e) { + } + + return self::$declaredClassTemplateNamesCache[$className] = []; + } + /** * Retrieves inherited class-level template bindings for a class name. * @@ -1317,9 +1364,7 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str } if ($existing instanceof IdentifierTypeNode && $expected instanceof GenericTypeNode) { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT && self::isSubclass($existing->name, $expected->type->name)) { - return true; - } + return self::checkIdentifierAgainstGenericVariance($existing, $expected, $variance); } if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { @@ -1333,6 +1378,71 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str return false; } + /** + * Verifies whether a concrete class identifier satisfies an expected generic type (e.g. F-bound Rogue <: Comparable). + */ + private static function checkIdentifierAgainstGenericVariance( + IdentifierTypeNode $existing, + GenericTypeNode $expected, + string $variance, + ): bool { + if (! self::isSubclass($existing->name, $expected->type->name)) { + return false; + } + + if ($variance !== GenericTypeNode::VARIANCE_COVARIANT) { + return false; + } + + $cycleKey = $existing->name . '@' . (string) $expected; + if (isset(self::$varianceCycleStack[$cycleKey])) { + return true; + } + self::$varianceCycleStack[$cycleKey] = true; + + try { + $classBindings = self::getClassInheritedBindings($existing->name); + if ($classBindings !== []) { + $parentTemplates = DocblockParser::parseClassTemplates($expected->type->name); + $parentTemplateNames = array_keys($parentTemplates); + + $declaredVariances = []; + if (class_exists($expected->type->name, false) || class_exists($expected->type->name) || interface_exists($expected->type->name)) { + try { + $ref = new \ReflectionClass($expected->type->name); + [, $classVariances] = self::collectHierarchyTemplatesAndVariances($ref); + $declaredVariances = array_values($classVariances); + } catch (\Throwable $e) { + } + } + + foreach ($parentTemplateNames as $idx => $tName) { + if (isset($classBindings[$tName], $expected->genericTypes[$idx])) { + $actualArg = $classBindings[$tName]; + $expectedArg = $expected->genericTypes[$idx]; + + $useSiteVariance = $expected->variances[$idx] ?? GenericTypeNode::VARIANCE_INVARIANT; + $declaredVariance = $declaredVariances[$idx] ?? GenericTypeNode::VARIANCE_INVARIANT; + + $innerVariance = match (true) { + $useSiteVariance !== GenericTypeNode::VARIANCE_INVARIANT => $useSiteVariance, + $declaredVariance === GenericTypeNode::VARIANCE_CONTRAVARIANT => GenericTypeNode::VARIANCE_CONTRAVARIANT, + default => GenericTypeNode::VARIANCE_COVARIANT, + }; + + if (! self::checkVariance($actualArg, $expectedArg, $innerVariance)) { + return false; + } + } + } + } + } finally { + unset(self::$varianceCycleStack[$cycleKey]); + } + + return true; + } + /** * O(1) scalar subtype verification. */ @@ -1568,11 +1678,21 @@ public static function inferTypeFromValue(mixed $value): TypeNode if (\is_object($value)) { $className = $value::class; + $declaredTemplateNames = self::getDeclaredClassTemplateNames($className); - if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && \count(self::$instanceTemplateBindings[$value]) > 0) { - $genericTypes = array_values(self::$instanceTemplateBindings[$value]); + if ($declaredTemplateNames !== [] && self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value])) { + $instanceBindings = self::$instanceTemplateBindings[$value]; + $genericTypes = []; - return new GenericTypeNode(self::$classNodeCache[$className] ??= new IdentifierTypeNode($className), $genericTypes); + foreach ($declaredTemplateNames as $tName) { + if (isset($instanceBindings[$tName])) { + $genericTypes[] = $instanceBindings[$tName]; + } + } + + if ($genericTypes !== []) { + return new GenericTypeNode(self::$classNodeCache[$className] ??= new IdentifierTypeNode($className), $genericTypes); + } } return self::$classNodeCache[$className] ??= new IdentifierTypeNode($className); diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index ee0b281..62015d8 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -152,10 +152,11 @@ private function validateKeyOfEnum(mixed $value, string $enumClass, string $cont } if (! isset(self::$enumKeyCache[$enumClass])) { - self::$enumKeyCache[$enumClass] = array_map( - static fn (\UnitEnum $case): string => $case->name, - $enumClass::cases() - ); + $keys = []; + foreach ($enumClass::cases() as $case) { + $keys[] = $case->name; + } + self::$enumKeyCache[$enumClass] = $keys; } if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { @@ -240,10 +241,11 @@ private function validateValueOfEnum(mixed $value, string $enumClass, string $co } if (! isset(self::$enumValueCache[$enumClass])) { - self::$enumValueCache[$enumClass] = array_map( - static fn (\BackedEnum $case): string|int => $case->value, - $enumClass::cases() - ); + $values = []; + foreach ($enumClass::cases() as $case) { + $values[] = $case->value; + } + self::$enumValueCache[$enumClass] = $values; } if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { diff --git a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php index 08f14d2..11b3c13 100644 --- a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php +++ b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php @@ -138,6 +138,24 @@ final class FBoundImpostor extends FBoundBase { } +/** + * Rogue class: Claims to implement FBoundComparable, but binds T to FBoundNum instead of itself! + * This violates the self-referential F-bound `@template T of FBoundComparable`. + * + * @implements FBoundComparable + */ +final class FBoundRogue implements FBoundComparable +{ + public function __construct(public int $id = 1) + { + } + + public function compareTo(mixed $other): int + { + return 0; + } +} + describe('F-Bounded Polymorphism (@template T of Interface)', function () { describe('1. Function-Level F-Bounds (fMin)', function () { test('fMin accepts homogeneous matching types (Text, Text)', function () { @@ -193,6 +211,27 @@ final class FBoundImpostor extends FBoundBase $list = new FBoundSortedList(); })->toThrow(TypeError::class, 'does not satisfy upper bound'); }); + + test('SortedList rejects class with mismatched F-bound type argument on pre-binding (Rogue implements Comparable not Comparable)', function () { + expect(function () { + /** @var FBoundSortedList $list */ + $list = new FBoundSortedList(); + })->toThrow( + TypeError::class, + 'does not satisfy upper bound' + ); + }); + + test('SortedList rejects rogue class when added to pre-bound collection', function () { + expect(function () { + /** @var FBoundSortedList $list */ + $list = new FBoundSortedList(); + $list->add(new FBoundRogue()); + })->toThrow( + TypeError::class, + 'does not satisfy upper bound' + ); + }); }); describe('3. Self-Typed Hierarchy (Base>)', function () { diff --git a/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php b/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php index 714056b..3a3703f 100644 --- a/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php +++ b/tests/TypeChecking/Generics/ReifiedGenericDiagnosticFormattingTest.php @@ -53,6 +53,107 @@ function acceptScalarUnionParam(mixed $val): void { } +/** + * Generic interface with F-bound + * + * @template T of FBoundFormattingComparable + */ +interface FBoundFormattingComparable +{ + public function compareTo(mixed $other): int; +} + +/** + * Concrete class implementing generic interface with concrete type argument + * + * @implements FBoundFormattingComparable + */ +class FBoundFormattingMoney implements FBoundFormattingComparable +{ + public function __construct(public int $amount = 100) + { + } + + public function compareTo(mixed $other): int + { + return 0; + } +} + +/** + * Non-generic class implementing generic interface with different type argument + * + * @implements FBoundFormattingComparable + */ +class FBoundFormattingFakeMoney implements FBoundFormattingComparable +{ + public function __construct(public string $label = 'fake') + { + } + + public function compareTo(mixed $other): int + { + return 0; + } +} + +/** + * Function expecting F-bounded instances + * + * @template T of FBoundFormattingComparable + * + * @param T $a + * @param T $b + * + * @return T + */ +function fBoundMaxOf(mixed $a, mixed $b): mixed +{ + return $a; +} + +describe('Non-Generic Classes Implementing Generic Interfaces Diagnostic Formatting (TDD)', function () { + test('TypeFormatter::formatGivenValue does not format non-generic class as generic (FakeMoney bug)', function () { + $fakeMoney = new FBoundFormattingFakeMoney(); + + expect(TypeFormatter::formatGivenValue($fakeMoney)) + ->toBe(FBoundFormattingFakeMoney::class) + ->and(TypeFormatter::formatGivenValue($fakeMoney))->not()->toContain('<') + ; + }); + + test('TemplateManager::inferTypeFromValue infers IdentifierTypeNode instead of GenericTypeNode for closed non-generic class', function () { + $fakeMoney = new FBoundFormattingFakeMoney(); + + $inferred = \TypePHP\Internal\Generics\TemplateManager::inferTypeFromValue($fakeMoney); + + expect($inferred)->toBeInstanceOf(\PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode::class) + ->and((string) $inferred)->toBe(FBoundFormattingFakeMoney::class) + ; + }); + + test('end-to-end function error message reports non-generic class name without phantom generic arguments', function () { + $money = new FBoundFormattingMoney(100); + $fakeMoney = new FBoundFormattingFakeMoney('wrong'); + + expect(fn () => fBoundMaxOf($money, $fakeMoney)) + ->toThrow(function (TypeError $e) { + expect($e->getMessage())->toContain(FBoundFormattingFakeMoney::class . ' given') + ->and($e->getMessage())->not()->toContain(FBoundFormattingFakeMoney::class . '<') + ; + }) + ; + }); + + test('still preserves and formats genuine generic classes with their type arguments (No regression)', function () { + $box = new DiagnosticInnerBox(new Dog()); + + expect(TypeFormatter::formatGivenValue($box)) + ->toBe(DiagnosticInnerBox::class . '<' . Dog::class . '>') + ; + }); +}); + describe('Preserve Reified Nested Generics in TypeFormatter::formatGivenValue()', function () { beforeEach(function () { Config::reset(); From 509bc0c3715679dd4c49785496c0341dcd69a9fd Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 17 Oct 2026 21:13:50 +0800 Subject: [PATCH 08/11] Fix error message in FBoundedPolymorphismTest to correctly reflect return type of FBoundImpostor --- tests/TypeChecking/Generics/FBoundedPolymorphismTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php index 11b3c13..822d691 100644 --- a/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php +++ b/tests/TypeChecking/Generics/FBoundedPolymorphismTest.php @@ -246,7 +246,7 @@ public function compareTo(mixed $other): int expect(fn () => $impostor->copy()) ->toThrow( TypeError::class, - 'Return value must be of type ' . FBoundDerived::class . ', ' . FBoundImpostor::class . '<' . FBoundDerived::class . '> returned' + 'Return value must be of type ' . FBoundDerived::class . ', ' . FBoundImpostor::class . ' returned' ) ; }); From b61c3adaf1b382f40ae4d65eee8e2173c0cb40ac Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 17 Oct 2026 21:58:51 +0800 Subject: [PATCH 09/11] Enhance ParamChecker, ParamOutChecker, and SelfOutChecker to support dynamic parameters; update DocblockParser for dynamic param detection; add EloquentLateStaticBoundTest for Eloquent query scope validation. --- src/Internal/Checker/ParamChecker.php | 31 +++++-- src/Internal/Checker/ParamOutChecker.php | 2 +- src/Internal/Checker/SelfOutChecker.php | 2 +- src/Internal/Docblock/DocblockParser.php | 31 ++++++- .../Generics/EloquentLateStaticBoundTest.php | 93 +++++++++++++++++++ 5 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 tests/TypeChecking/Generics/EloquentLateStaticBoundTest.php diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index a805b11..754d01e 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -177,13 +177,14 @@ public static function checkParams( } $paramsUseGenerics = (bool) ($contract['paramsUseGenerics'] ?? true); + $paramsAreDynamic = (bool) ($contract['paramsAreDynamic'] ?? false); $methodTemplates = $contract['templates']; $classTemplates = $contract['classTemplates'] ?? []; $aliases = $contract['aliases']; $hasMethodTemplates = (\count($methodTemplates) > 0); $sensitiveParams = $contract['sensitiveParams'] ?? []; - if (! $paramsUseGenerics && ! $hasMethodTemplates && \count($aliases) === 0) { + if (! $paramsUseGenerics && ! $hasMethodTemplates && \count($aliases) === 0 && ! $paramsAreDynamic) { return self::validateSimpleParams($contract['types'], $vars, $effectiveFunction, $registry, $sensitiveParams); } @@ -201,7 +202,7 @@ public static function checkParams( : []; $declaredTemplates = $allTemplates; - $baseTypes = self::resolveBaseTypes($contract['types'], $effectiveFunction, $thisObj, $aliases); + $baseTypes = self::resolveBaseTypes($contract['types'], $effectiveFunction, $thisObj, $aliases, $paramsAreDynamic); return self::validateAllParameters( $contract['types'], @@ -326,18 +327,30 @@ private static function resolveBaseTypes( array $types, string $effectiveFunction, ?object $thisObj, - array $aliases + array $aliases, + bool $paramsAreDynamic = false ): array { $baseTypes = []; foreach ($types as $paramName => $typeNode) { - if (! isset(self::$baseTypeCache[$effectiveFunction][$paramName])) { - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; - } - $resolved = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); + $isParamDynamic = $paramsAreDynamic && (str_contains((string) $typeNode, 'static') || str_contains((string) $typeNode, '$this')); + + if (! $isParamDynamic && isset(self::$baseTypeCache[$effectiveFunction][$paramName])) { + $baseTypes[$paramName] = self::$baseTypeCache[$effectiveFunction][$paramName]; + + continue; + } + + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } + + $resolved = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); + + if (! $isParamDynamic) { self::$baseTypeCache[$effectiveFunction][$paramName] = $resolved; } - $baseTypes[$paramName] = self::$baseTypeCache[$effectiveFunction][$paramName]; + + $baseTypes[$paramName] = $resolved; } return $baseTypes; diff --git a/src/Internal/Checker/ParamOutChecker.php b/src/Internal/Checker/ParamOutChecker.php index 897206b..4dd10ff 100644 --- a/src/Internal/Checker/ParamOutChecker.php +++ b/src/Internal/Checker/ParamOutChecker.php @@ -80,8 +80,8 @@ public static function checkParamOut( if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $allTemplates); - $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); } + $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); $context = $effectiveFunction . '(): Argument &$' . $paramName . ' (param-out)'; diff --git a/src/Internal/Checker/SelfOutChecker.php b/src/Internal/Checker/SelfOutChecker.php index f4b2339..c695e71 100644 --- a/src/Internal/Checker/SelfOutChecker.php +++ b/src/Internal/Checker/SelfOutChecker.php @@ -74,8 +74,8 @@ public static function checkSelfOut( if (\count($boundTemplates) > 0 || \count($allTemplates) > 0) { $selfOutNode = TemplateSubstitutor::substitute($selfOutNode, $boundTemplates, $allTemplates); - $selfOutNode = SpecialTypeResolver::resolve($selfOutNode, $effectiveFunction, $thisObj); } + $selfOutNode = SpecialTypeResolver::resolve($selfOutNode, $effectiveFunction, $thisObj); if (ConditionalChecker::containsConditional($selfOutNode)) { $selfOutNode = ConditionalChecker::resolve($selfOutNode, $vars, $boundTemplates, $registry, $effectiveFunction); diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index f3cbc65..5e7f093 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -47,6 +47,7 @@ * hasSelfOutContract: bool, * hasReturnContract: bool, * paramsUseGenerics: bool, + * paramsAreDynamic: bool, * returnUsesGenerics: bool, * returnUsesMethodTemplates: bool, * returnIsThis: bool, @@ -308,7 +309,8 @@ private static function computeContractFlags( bool $paramsUseGenerics, bool $returnUsesGenerics, array $aliases, - array $methodTemplates + array $methodTemplates, + bool $paramsAreDynamic = false ): array { $allParamsUnconstrained = true; if (\count($types) > 0) { @@ -328,6 +330,7 @@ private static function computeContractFlags( } $isSimple = ! $paramsUseGenerics + && ! $paramsAreDynamic && ! $returnUsesGenerics && \count($aliases) === 0 && \count($methodTemplates) === 0; @@ -378,6 +381,7 @@ public static function parse(string $function): array 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, + 'paramsAreDynamic' => false, 'returnUsesGenerics' => false, 'returnUsesMethodTemplates' => false, 'returnIsThis' => false, @@ -994,6 +998,26 @@ private static function parseMethod(\ReflectionMethod $ref, ?\ReflectionClass $t } } + $paramsAreDynamic = false; + foreach ($types as $tNode) { + $tStr = (string) $tNode; + if (str_contains($tStr, 'static') || str_contains($tStr, '$this')) { + $paramsAreDynamic = true; + + break; + } + } + if (! $paramsAreDynamic) { + foreach ($paramOuts as $tNode) { + $tStr = (string) $tNode; + if (str_contains($tStr, 'static') || str_contains($tStr, '$this')) { + $paramsAreDynamic = true; + + break; + } + } + } + $returnUsesMethodTemplates = false; if ($returnType !== null && \count($methodTemplates) > 0) { $returnUsesMethodTemplates = self::typeReferencesTemplate($returnType, $methodTemplates); @@ -1019,7 +1043,8 @@ private static function parseMethod(\ReflectionMethod $ref, ?\ReflectionClass $t $paramsUseGenerics, $returnUsesGenerics, $aliases, - $methodTemplates + $methodTemplates, + $paramsAreDynamic ); return [ @@ -1037,6 +1062,7 @@ private static function parseMethod(\ReflectionMethod $ref, ?\ReflectionClass $t 'hasSelfOutContract' => $selfOut !== null, 'hasReturnContract' => $returnType !== null, 'paramsUseGenerics' => $paramsUseGenerics, + 'paramsAreDynamic' => $paramsAreDynamic, 'returnUsesGenerics' => $returnUsesGenerics, 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, 'returnIsThis' => $returnIsThis, @@ -1094,6 +1120,7 @@ private static function parseFunction(\ReflectionFunction $ref): array 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, + 'paramsAreDynamic' => false, 'returnUsesGenerics' => false, 'returnUsesMethodTemplates' => false, 'returnIsThis' => false, diff --git a/tests/TypeChecking/Generics/EloquentLateStaticBoundTest.php b/tests/TypeChecking/Generics/EloquentLateStaticBoundTest.php new file mode 100644 index 0000000..2da19f1 --- /dev/null +++ b/tests/TypeChecking/Generics/EloquentLateStaticBoundTest.php @@ -0,0 +1,93 @@ +model; + } +} + +class ReproPatientModel extends ReproEloquentModelBase +{ + /** + * Exact Laravel Query Scope pattern from the user's codebase: + * + * @param ReproEloquentBuilder $query + * @param int $therapistId + * + * @return ReproEloquentBuilder + */ + public function scopeAssignedToTherapist(ReproEloquentBuilder $query, int $therapistId): ReproEloquentBuilder + { + return $query; + } + + /** + * Variant using $this instead of static: + * + * @param ReproEloquentBuilder<$this> $query + */ + public function scopeWithThisType(ReproEloquentBuilder $query): ReproEloquentBuilder + { + return $query; + } +} + +class ReproSpecialPatientModel extends ReproPatientModel +{ +} + +class ReproUnrelatedNonModel +{ +} + +describe('Eloquent Query Scope Builder Parameter Contracts (Reproduction)', function () { + test('accepts EloquentBuilder matching Model instance when scope is typed as Builder', function () { + $patient = new ReproPatientModel(); + $builder = new ReproEloquentBuilder($patient); + + // This reproduces the exact call made by Laravel's Model::callNamedScope(): + $result = $patient->scopeAssignedToTherapist($builder, 1); + + expect($result)->toBe($builder); + }); + + test('accepts EloquentBuilder matching Model instance when scope is typed as Builder<$this>', function () { + $patient = new ReproPatientModel(); + $builder = new ReproEloquentBuilder($patient); + + $result = $patient->scopeWithThisType($builder); + + expect($result)->toBe($builder); + }); + + test('accepts subclass of Model calling inherited scope with Builder', function () { + $specialPatient = new ReproSpecialPatientModel(); + $builder = new ReproEloquentBuilder($specialPatient); + + $result = $specialPatient->scopeAssignedToTherapist($builder, 1); + + expect($result)->toBe($builder); + }); +}); From d23df3c80c0cb4d10b13a5697e0f158219a53be6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 18 Oct 2026 13:48:27 +0800 Subject: [PATCH 10/11] Fix phpstan errors --- src/Internal/Docblock/DocblockParser.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 5e7f093..92a30c9 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -406,6 +406,7 @@ public static function parse(string $function): array 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, + 'paramsAreDynamic' => false, 'returnUsesGenerics' => false, 'returnUsesMethodTemplates' => false, 'returnIsThis' => false, @@ -434,6 +435,7 @@ public static function parse(string $function): array 'hasSelfOutContract' => false, 'hasReturnContract' => false, 'paramsUseGenerics' => false, + 'paramsAreDynamic' => false, 'returnUsesGenerics' => false, 'returnUsesMethodTemplates' => false, 'returnIsThis' => false, @@ -1230,7 +1232,8 @@ private static function parseFunction(\ReflectionFunction $ref): array $paramsUseGenerics, $returnUsesMethodTemplates, $aliases, - $templates + $templates, + false ); return [ @@ -1248,6 +1251,7 @@ private static function parseFunction(\ReflectionFunction $ref): array 'hasSelfOutContract' => false, 'hasReturnContract' => $returnType !== null, 'paramsUseGenerics' => $paramsUseGenerics, + 'paramsAreDynamic' => false, 'returnUsesGenerics' => $returnUsesMethodTemplates, 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, 'returnIsThis' => $returnIsThis, From 6359c0bbcc3d5f881ebdb26da3ef759ca84a2da8 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 18 Oct 2026 14:26:06 +0800 Subject: [PATCH 11/11] Add tests for RuntimeTypeChecker's cloneWith method and property validation --- tests/Internal/RuntimeTypeCheckerTest.php | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/Internal/RuntimeTypeCheckerTest.php b/tests/Internal/RuntimeTypeCheckerTest.php index 6f386d3..c66efca 100644 --- a/tests/Internal/RuntimeTypeCheckerTest.php +++ b/tests/Internal/RuntimeTypeCheckerTest.php @@ -8,6 +8,7 @@ use TypePHP\Internal\Checker\ParamChecker; use TypePHP\Internal\Checker\SelfOutChecker; use TypePHP\Internal\Diagnostic\ErrorMessage; +use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\RuntimeTypeChecker; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -17,6 +18,10 @@ use TypePHP\Tests\Fixtures\Services\UserService; use TypePHP\Tests\Fixtures\Types\ConfiguredProperty; +if (PHP_VERSION_ID >= 80500) { + require_once __DIR__ . '/../Fixtures/Php85/CloneWithFunctionalFixtures.php'; +} + class StaticPropFixture { /** @@ -559,4 +564,156 @@ function vendorAction(): void expect(RuntimeTypeChecker::checkReturn('runtimeUnconstrainedReturn', 100))->toBe(100); }); + + test('returns cloned value untouched when cloned or source is not an object', function () { + expect(RuntimeTypeChecker::cloneWith(123, new stdClass()))->toBe(123); + expect(RuntimeTypeChecker::cloneWith('string', null))->toBe('string'); + expect(RuntimeTypeChecker::cloneWith(null, null))->toBeNull(); + + TemplateManager::$pendingCloneSource = null; + $obj = new stdClass(); + expect(RuntimeTypeChecker::cloneWith($obj, null))->toBe($obj); + expect(RuntimeTypeChecker::cloneWith($obj, 456))->toBe($obj); + }); + + test('copies generic bindings using explicit original object argument', function () { + $original = new Container(new Dog()); + TemplateManager::bindInstance($original, Container::class . '<' . Dog::class . '>'); + + $cloned = new Container(new Dog()); + $result = RuntimeTypeChecker::cloneWith($cloned, $original); + + expect($result)->toBe($cloned) + ->and(TypePHP\TypePHP::getGenericType($cloned))->toBe(Dog::class) + ; + }); + + test('falls back to TemplateManager::$pendingCloneSource and resets it to null', function () { + $original = new Container(new Dog()); + TemplateManager::bindInstance($original, Container::class . '<' . Dog::class . '>'); + + TemplateManager::$pendingCloneSource = $original; + + $cloned = new Container(new Dog()); + $result = RuntimeTypeChecker::cloneWith($cloned, original: null); + + expect($result)->toBe($cloned) + ->and(TypePHP\TypePHP::getGenericType($cloned))->toBe(Dog::class) + ->and(TemplateManager::$pendingCloneSource)->toBeNull() + ; + }); + + test('skips property validation when overriddenProperties is null or empty array', function () { + $obj = new stdClass(); + + expect(RuntimeTypeChecker::cloneWith($obj, $obj, null))->toBe($obj) + ->and(RuntimeTypeChecker::cloneWith($obj, $obj, []))->toBe($obj) + ; + }); + + test('validates valid property overrides on CloneWithModelFixture (PHP 8.5+)', function () { + if (PHP_VERSION_ID < 80500) { + expect(true)->toBeTrue(); + + return; + } + + $model = new TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture(); + $cloned = clone $model; + + $result = RuntimeTypeChecker::cloneWith( + $cloned, + $model, + ['score' => 50, 'name' => 'Bob'], + __FILE__, + 100 + ); + + expect($result)->toBe($cloned); + }); + + test('throws TypeError when overridden property violates positive-int on CloneWithModelFixture (PHP 8.5+)', function () { + if (PHP_VERSION_ID < 80500) { + expect(true)->toBeTrue(); + + return; + } + + $model = new TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture(); + $cloned = clone $model; + + expect(function () use ($cloned, $model) { + RuntimeTypeChecker::cloneWith( + $cloned, + $model, + ['score' => -25], + __FILE__, + 42 + ); + })->toThrow(function (TypeError $e) { + expect($e->getMessage())->toContain('positive-int') + ->and($e->getFile())->toBe(__FILE__) + ->and($e->getLine())->toBe(42) + ; + }); + }); + + test('throws TypeError when overridden property violates non-empty-string on CloneWithModelFixture (PHP 8.5+)', function () { + if (PHP_VERSION_ID < 80500) { + expect(true)->toBeTrue(); + + return; + } + + $model = new TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture(); + $cloned = clone $model; + + expect(function () use ($cloned, $model) { + RuntimeTypeChecker::cloneWith( + $cloned, + $model, + ['name' => ''], + __FILE__, + 50 + ); + })->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('validates overridden properties on CloneWithReadonlyOrder (PHP 8.5+)', function () { + if (PHP_VERSION_ID < 80500) { + expect(true)->toBeTrue(); + + return; + } + + $order = new TypePHP\Tests\Fixtures\Php85\CloneWithReadonlyOrder(100, 'SKU-ORIGINAL'); + $cloned = clone $order; + + expect(function () use ($cloned, $order) { + RuntimeTypeChecker::cloneWith( + $cloned, + $order, + ['id' => -999], + __FILE__, + 60 + ); + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('skips overridden property validation when inline properties are disabled in config (PHP 8.5+)', function () { + if (PHP_VERSION_ID < 80500) { + expect(true)->toBeTrue(); + + return; + } + + Config::set(['inline_vars' => ['properties' => false]]); + + $model = new TypePHP\Tests\Fixtures\Php85\CloneWithModelFixture(); + $cloned = clone $model; + + $result = RuntimeTypeChecker::cloneWith($cloned, $model, ['score' => -50]); + + expect($result)->toBe($cloned); + }); });