diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 422ac616..54625247 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -7,6 +7,7 @@ use PhpParser\Node; use PhpParser\NodeVisitorAbstract; use TypePHP\Internal\Docblock\DocblockExtractor; +use TypePHP\Internal\Util\Config; /** * @internal AST Node Visitor that injects contract checks, scope tracking, property hook validation, and parameter/return wrappers into functions and methods. @@ -71,6 +72,20 @@ public function enterNode(Node $node): ?array $this->scopeManager->pushScope(); } + if ($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignOp) { + $this->markWriteContext($node->var); + } elseif ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) { + $this->markWriteContext($node->var); + } elseif ($node instanceof Node\Stmt\Unset_) { + foreach ($node->vars as $v) { + $this->markWriteContext($v); + } + } + + if ($node instanceof Node\Stmt\Class_) { + $this->processClassPropertyDefaults($node); + } + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { $classContext = $this->classStack !== [] ? end($this->classStack) : null; FunctionContractInjector::inject($node, $classContext); @@ -135,6 +150,29 @@ public function leaveNode(Node $node): ?Node } } + if ($node instanceof Node\Expr\StaticPropertyFetch && $node->name instanceof Node\VarLikeIdentifier) { + $propName = $node->name->toString(); + + if ($node->getAttribute('typephp_checked') !== true && $node->getAttribute('typephp_write_context') !== true) { + $node->setAttribute('typephp_checked', true); + $classArg = $node->class instanceof Node\Name + ? new Node\Expr\ClassConstFetch($node->class, 'class') + : $node->class; + + return new Node\Expr\StaticCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'), + 'checkStaticProperty', + [ + new Node\Arg($classArg), + new Node\Arg(new Node\Scalar\String_($propName)), + new Node\Arg($node), + new Node\Arg(new Node\Scalar\MagicConst\File()), + new Node\Arg(new Node\Scalar\LNumber($node->getStartLine())), + ] + ); + } + } + if ($this->isScopeBoundary($node)) { $this->scopeManager->popScope(); } @@ -142,6 +180,100 @@ public function leaveNode(Node $node): ?Node return null; } + private function markWriteContext(Node $node): void + { + $node->setAttribute('typephp_write_context', true); + if ($node instanceof Node\Expr\ArrayDimFetch || $node instanceof Node\Expr\PropertyFetch) { + $this->markWriteContext($node->var); + } + } + + private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void + { + if (! Config::isInlinePropertiesEnabled()) { + return; + } + + $defaultProps = []; + $hasConstructor = false; + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\ClassMethod && strtolower($stmt->name->toString()) === '__construct') { + $hasConstructor = true; + } elseif ($stmt instanceof Node\Stmt\Property && ! $stmt->isStatic()) { + $doc = $stmt->getDocComment(); + if ($doc !== null && str_contains($doc->getText(), '@var') && ! str_contains($doc->getText(), '@typephp-ignore')) { + foreach ($stmt->props as $p) { + $isExplicitNull = $p->default instanceof Node\Expr\ConstFetch && strtolower($p->default->name->toString()) === 'null'; + if ($p->default !== null && ! $isExplicitNull) { + $defaultProps[] = [ + 'name' => $p->name->toString(), + 'line' => $p->getStartLine(), + ]; + } + } + } + } + } + + if ($defaultProps === []) { + return; + } + + if (! $hasConstructor) { + $ctorStmts = []; + if ($node->extends !== null) { + $ctorStmts[] = new Node\Stmt\If_( + new Node\Expr\FuncCall(new Node\Name('method_exists'), [ + new Node\Arg(new Node\Expr\ClassConstFetch(new Node\Name('parent'), 'class')), + new Node\Arg(new Node\Scalar\String_('__construct')), + ]), + ['stmts' => [ + new Node\Stmt\Expression(new Node\Expr\StaticCall(new Node\Name('parent'), '__construct', [new Node\Arg(new Node\Expr\Variable('_typephp_ctor_args'), false, true)])), + ]] + ); + } + + foreach ($defaultProps as $dp) { + $checkCall = NodeBuilder::createPropertyCheckCall( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $dp['name']), + new Node\Expr\Variable('this'), + $dp['name'] + ); + $stmt = new Node\Stmt\Expression(NodeBuilder::createTernaryThrowExpr($checkCall, $dp['line'])); + $stmt->setAttribute('typephp_injected', true); + $ctorStmts[] = $stmt; + } + + $ctor = new Node\Stmt\ClassMethod('__construct', [ + 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC, + 'params' => [new Node\Param(new Node\Expr\Variable('_typephp_ctor_args'), null, null, false, true)], + 'stmts' => $ctorStmts, + ]); + $ctor->setAttribute('typephp_injected', true); + $node->stmts[] = $ctor; + } else { + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\ClassMethod && strtolower($stmt->name->toString()) === '__construct') { + $injected = []; + foreach ($defaultProps as $dp) { + $checkCall = NodeBuilder::createPropertyCheckCall( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $dp['name']), + new Node\Expr\Variable('this'), + $dp['name'] + ); + $checkStmt = new Node\Stmt\Expression(NodeBuilder::createTernaryThrowExpr($checkCall, $dp['line'])); + $checkStmt->setAttribute('typephp_injected', true); + $injected[] = $checkStmt; + } + $stmt->stmts = [...$injected, ...($stmt->stmts ?? [])]; + + break; + } + } + } + } + private function trackDeclarationEntry(Node $node): void { if ($node instanceof Node\Stmt\Namespace_) { diff --git a/src/Internal/Ast/PropertyHookInjector.php b/src/Internal/Ast/PropertyHookInjector.php index 93f62784..1750726e 100644 --- a/src/Internal/Ast/PropertyHookInjector.php +++ b/src/Internal/Ast/PropertyHookInjector.php @@ -44,9 +44,7 @@ private static function shouldSkipInjection(Node\Stmt\Property $node): bool return false; } - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - - return $shouldRespectIgnore && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable')); + return Config::isRespectIgnoreTagsEnabled() && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable')); } private static function processGetHook(Node\PropertyHook $hook, string $propertyName): void diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index c8a87e4b..9ef77f63 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -130,11 +130,7 @@ public static function checkVariable( ?string $caller = null, mixed $thisOrClass = null ): mixed { - $rawConfig = Config::get()['inline_vars'] ?? []; - /** @var array $config */ - $config = \is_array($rawConfig) ? $rawConfig : []; - - if (! self::hasActiveInlineChecks($config)) { + if (! Config::hasActiveInlineChecks()) { return $value; } @@ -150,7 +146,7 @@ public static function checkVariable( $typeNode = self::resolveCallerContext($typeNode, $caller, $thisOrClass); } - if (! self::shouldValidateType($typeNode, $config)) { + if (! self::shouldValidateType($typeNode)) { return $value; } @@ -166,8 +162,20 @@ public static function checkVariable( return CallableWrapper::wrapTypeNode($typeNode, $value, $cbPrefix, $registry); } - $checkGenerics = (bool) ($config['generics'] ?? true); - if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) { + if ($typeNode instanceof GenericTypeNode) { + $baseName = strtolower($typeNode->type->name); + $isCollection = isset(self::ARRAY_TYPES[$baseName]); + + if (! $isCollection) { + if (! Config::isInlineGenericsEnabled() && Config::isInlineObjectsEnabled()) { + $typeNode = $typeNode->type; + } elseif (! Config::isInlineGenericsEnabled() && ! Config::isInlineObjectsEnabled()) { + return $value; + } + } + } + + if ($typeNode instanceof GenericTypeNode && Config::isInlineGenericsEnabled() && \is_object($value)) { $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context); if ($err !== null) { return $err; @@ -225,11 +233,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - $rawConfig = Config::get()['inline_vars'] ?? []; - /** @var array $config */ - $config = \is_array($rawConfig) ? $rawConfig : []; - - if (! ($config['properties'] ?? true)) { + if (! Config::isInlinePropertiesEnabled()) { return $value; } @@ -240,7 +244,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - if (! self::shouldValidateType($typeNode, $config)) { + if (! self::shouldValidateType($typeNode)) { return $value; } @@ -260,20 +264,6 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - /** - * Checks if at least one inline variable category is active. - * - * @param array $config - */ - private static function hasActiveInlineChecks(array $config): bool - { - return (bool) ($config['generics'] ?? true) - || (bool) ($config['callables'] ?? true) - || (bool) ($config['scalars'] ?? false) - || (bool) ($config['arrays'] ?? false) - || (bool) ($config['objects'] ?? false); - } - /** * Resolves caller class or function context and applies templates & type aliases to the AST. */ @@ -550,15 +540,12 @@ private static function getTypeParserComponents(): array return [$typeParser, $lexer]; } - /** - * @param array $config - */ - private static function shouldValidateType(TypeNode $node, array $config): bool + private static function shouldValidateType(TypeNode $node): bool { - $checkArrays = (bool) ($config['arrays'] ?? false); + $checkArrays = Config::isInlineArraysEnabled(); if ($node instanceof CallableTypeNode) { - return (bool) ($config['callables'] ?? true); + return Config::isInlineCallablesEnabled(); } if ($node instanceof ObjectShapeNode || $node instanceof ArrayShapeNode || $node instanceof ArrayTypeNode) { @@ -573,7 +560,7 @@ private static function shouldValidateType(TypeNode $node, array $config): bool } if ($lower === 'callable') { - return (bool) ($config['callables'] ?? true); + return Config::isInlineCallablesEnabled(); } if (isset(self::ARRAY_TYPES[$lower])) { @@ -581,10 +568,10 @@ private static function shouldValidateType(TypeNode $node, array $config): bool } if (isset(self::SCALAR_TYPES[$lower])) { - return (bool) ($config['scalars'] ?? false); + return Config::isInlineScalarsEnabled(); } - return (bool) ($config['objects'] ?? false); + return Config::isInlineObjectsEnabled(); } if ($node instanceof GenericTypeNode) { @@ -593,20 +580,20 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return $checkArrays; } - if ((bool) ($config['generics'] ?? true)) { + if (Config::isInlineGenericsEnabled()) { return true; } - return (bool) ($config['objects'] ?? false); + return Config::isInlineObjectsEnabled(); } if ($node instanceof NullableTypeNode) { - return self::shouldValidateType($node->type, $config); + return self::shouldValidateType($node->type); } if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { foreach ($node->types as $t) { - if (self::shouldValidateType($t, $config)) { + if (self::shouldValidateType($t)) { return true; } } @@ -614,6 +601,6 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return false; } - return (bool) ($config['scalars'] ?? false); + return Config::isInlineScalarsEnabled(); } } diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index f8baed83..23b62689 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -113,6 +113,17 @@ private static function getTemplate(): string */ 'respect_ignore_tags' => true, + /* + |-------------------------------------------------------------------------- + | Ignore Tag Stack Trace Depth + |-------------------------------------------------------------------------- + | Controls how many stack frames above a failing type check TypePHP will + | inspect to find an enclosing @typephp-ignore or @typephp-disable tag. + | Default is 25 frames. Increase this if your application or test suite + | uses deep call stacks (e.g. pipelines, middlewares, or nested callers). + */ + 'ignore_trace_depth' => 25, + /* |-------------------------------------------------------------------------- | Array Validation Strategy diff --git a/src/Internal/Diagnostic/ErrorFactory.php b/src/Internal/Diagnostic/ErrorFactory.php index 3898544f..0d9b8a50 100644 --- a/src/Internal/Diagnostic/ErrorFactory.php +++ b/src/Internal/Diagnostic/ErrorFactory.php @@ -25,6 +25,7 @@ final class ErrorFactory 'iterator $', 'return iterator', 'generator sent value', + 'property ', ]; /** @@ -47,9 +48,9 @@ public static function createError(string $message): ErrorMessage * Prepares a TypeError exception before throwing by filtering internal library frames * and repointing the exception to the actual application caller location. */ - public static function prepareException(TypeError $e, ?int $line = null): TypeError + public static function prepareException(TypeError $e, ?int $line = null, ?string $file = null): TypeError { - $targetFile = null; + $targetFile = $file; $targetLine = $line; $message = $e->getMessage(); diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 9cb3be93..105a3ebe 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -28,6 +28,7 @@ use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\FileFilter; +use TypePHP\Internal\Util\IgnoreManager; use TypePHP\Internal\Util\StubManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -472,7 +473,7 @@ public static function parseProperty(string $className, string $propertyName): ? $typeNode = null; $isMagicProperty = false; - if ($doc === false && (bool) (Config::get()['magic_properties'] ?? true)) { + if ($doc === false && Config::isMagicPropertiesEnabled()) { $magicResolved = self::findMagicPropertyDoc($refClass, $propertyName); if ($magicResolved !== null) { $doc = $magicResolved['doc']; @@ -764,9 +765,7 @@ private static function extractRawParamName(object $paramNode): string private static function shouldIgnoreDoc(string $doc): bool { - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - - return $shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable')); + return Config::isRespectIgnoreTagsEnabled() && IgnoreManager::hasIgnoreDocTag($doc); } /** diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 41ba807f..1eb2704c 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -414,7 +414,7 @@ public static function popPendingInstantiation(): void */ public static function applyPendingInstantiation(object $instance): void { - if (self::$pendingInstantiations === []) { + if (! Config::isInlineGenericsEnabled() || self::$pendingInstantiations === []) { return; } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index e54f0e34..3f1f42bc 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -13,6 +13,7 @@ use TypePHP\Internal\Checker\ParamOutChecker; use TypePHP\Internal\Checker\ReturnChecker; use TypePHP\Internal\Checker\SelfOutChecker; +use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; @@ -35,12 +36,18 @@ final class RuntimeTypeChecker */ public static array $hasMethodTemplatesCache = []; + /** + * @var array + */ + private static array $checkedStaticProperties = []; + /** * Resets runtime caches. */ public static function reset(): void { self::$hasMethodTemplatesCache = []; + self::$checkedStaticProperties = []; IgnoreManager::reset(); CallerBoundaryResolver::reset(); ParamOutChecker::reset(); @@ -62,6 +69,10 @@ public static function isEnabled(): bool */ public static function withPendingGeneric(string $typeString, \Closure $factory, string $file = ''): mixed { + if (! Config::isEnabled() || ! Config::isInlineGenericsEnabled()) { + return $factory(); + } + TemplateManager::pushPendingInstantiation($typeString, $file); try { @@ -71,6 +82,39 @@ public static function withPendingGeneric(string $typeString, \Closure $factory, } } + /** + * Evaluates inline property validation dynamically based on configuration. + */ + public static function checkStaticProperty( + mixed $objectOrClass, + string $propName, + mixed $value, + string $file = '', + ?int $line = null + ): mixed { + if (! Config::isEnabled() || ! Config::isInlinePropertiesEnabled()) { + return $value; + } + + $className = \is_object($objectOrClass) ? $objectOrClass::class : (\is_string($objectOrClass) ? $objectOrClass : ''); + $key = $className . '::$' . $propName; + + if (isset(self::$checkedStaticProperties[$key])) { + return $value; + } + + self::$checkedStaticProperties[$key] = true; + + $res = self::checkProperty($value, $objectOrClass, $propName, $file); + if ($res instanceof ErrorMessage) { + $typeError = new \TypePHP\Exception\TypeError($res->getMessage()); + + throw ErrorFactory::prepareException($typeError, $line, $file !== '' ? $file : null); + } + + return $value; + } + /** * Delegates generic template binding for class instances. */ diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index bcff1a3f..1e60b101 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -64,6 +64,20 @@ final class Config private static string $arrayValidation = 'full'; + private static bool $inlineProperties = true; + + private static bool $inlineGenerics = true; + + private static bool $inlineCallables = true; + + private static bool $inlineScalars = true; + + private static bool $inlineArrays = true; + + private static bool $inlineObjects = true; + + private static int $ignoreTraceDepth = 25; + public static function isEnabled(): bool { if (self::$cachedConfig === null) { @@ -73,6 +87,82 @@ public static function isEnabled(): bool return self::$enabled; } + public static function getIgnoreTraceDepth(): int + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$ignoreTraceDepth; + } + + public static function isInlinePropertiesEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineProperties; + } + + public static function isInlineGenericsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineGenerics; + } + + public static function isInlineCallablesEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineCallables; + } + + public static function isInlineScalarsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineScalars; + } + + public static function isInlineArraysEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineArrays; + } + + public static function isInlineObjectsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineObjects; + } + + public static function hasActiveInlineChecks(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$inlineGenerics + || self::$inlineCallables + || self::$inlineScalars + || self::$inlineArrays + || self::$inlineObjects; + } + public static function isCacheCheckMtimeEnabled(): bool { if (self::$cachedConfig === null) { @@ -266,6 +356,7 @@ public static function get(): array 'magic_properties' => true, 'magic_methods' => true, 'respect_ignore_tags' => true, + 'ignore_trace_depth' => 25, 'respect_native_nullability' => true, 'vendor_boundary_only' => true, 'array_validation' => 'full', @@ -313,8 +404,8 @@ public static function get(): array /** @var array $currentStubs */ $currentStubs = \is_array($mergedConfig['stubs'] ?? null) ? $mergedConfig['stubs'] : []; - $mergedConfig['include'] = array_values(array_unique(array_merge($currentIncludes, $extensionIncludes))); - $mergedConfig['stubs'] = array_values(array_unique(array_merge($currentStubs, $extensionStubs))); + $mergedConfig['include'] = array_values(array_unique([...$currentIncludes, ...$extensionIncludes])); + $mergedConfig['stubs'] = array_values(array_unique([...$currentStubs, ...$extensionStubs])); self::syncFlags($mergedConfig); @@ -342,8 +433,8 @@ public static function set(array $config): void /** @var array $currentStubs */ $currentStubs = \is_array($mergedConfig['stubs'] ?? null) ? $mergedConfig['stubs'] : []; - $mergedConfig['include'] = array_values(array_unique(array_merge($currentIncludes, $extensionIncludes))); - $mergedConfig['stubs'] = array_values(array_unique(array_merge($currentStubs, $extensionStubs))); + $mergedConfig['include'] = array_values(array_unique([...$currentIncludes, ...$extensionIncludes])); + $mergedConfig['stubs'] = array_values(array_unique([...$currentStubs, ...$extensionStubs])); } self::$cachedConfig = $mergedConfig; @@ -384,7 +475,7 @@ private static function mergeConfig(array $base, array $overrides): array $baseInlineVars = $base['inline_vars']; /** @var array $overrideInlineVars */ $overrideInlineVars = $value; - $merged['inline_vars'] = array_merge($baseInlineVars, $overrideInlineVars); + $merged['inline_vars'] = [...$baseInlineVars, ...$overrideInlineVars]; } elseif (\in_array($key, ['include', 'exclude', 'extensions', 'stubs'], true) && \is_array($value)) { $merged[$key] = array_values($value); } else { @@ -410,11 +501,18 @@ public static function reset(): void self::$magicProperties = true; self::$magicMethods = true; self::$respectIgnoreTags = true; + self::$ignoreTraceDepth = 25; self::$respectNativeNullability = true; self::$vendorBoundaryOnly = true; self::$arrayValidation = 'full'; self::$cacheCheckMtime = true; self::$paramsOut = true; + self::$inlineProperties = true; + self::$inlineGenerics = true; + self::$inlineCallables = true; + self::$inlineScalars = true; + self::$inlineArrays = true; + self::$inlineObjects = true; DocblockParser::reset(); ParamChecker::reset(); @@ -448,9 +546,19 @@ private static function syncFlags(array $config): void self::$magicProperties = (bool) ($config['magic_properties'] ?? true); self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); + self::$ignoreTraceDepth = isset($config['ignore_trace_depth']) && is_numeric($config['ignore_trace_depth']) && (int) $config['ignore_trace_depth'] > 0 + ? (int) $config['ignore_trace_depth'] + : 25; self::$respectNativeNullability = (bool) ($config['respect_native_nullability'] ?? true); self::$vendorBoundaryOnly = (bool) ($config['vendor_boundary_only'] ?? true); self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full'; self::$cacheCheckMtime = (bool) ($config['cache_check_mtime'] ?? true); + $inline = \is_array($config['inline_vars'] ?? null) ? $config['inline_vars'] : []; + self::$inlineProperties = (bool) ($inline['properties'] ?? true); + self::$inlineGenerics = (bool) ($inline['generics'] ?? true); + self::$inlineCallables = (bool) ($inline['callables'] ?? true); + self::$inlineScalars = (bool) ($inline['scalars'] ?? true); + self::$inlineArrays = (bool) ($inline['arrays'] ?? true); + self::$inlineObjects = (bool) ($inline['objects'] ?? true); } } diff --git a/src/Internal/Util/IgnoreManager.php b/src/Internal/Util/IgnoreManager.php index e41a262d..56e97a23 100644 --- a/src/Internal/Util/IgnoreManager.php +++ b/src/Internal/Util/IgnoreManager.php @@ -90,7 +90,8 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal return self::$callerCache[$callerFunction] = self::checkFunctionIgnored($callerFunction); } - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + $depth = Config::getIgnoreTraceDepth(); + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $depth); for ($i = 1; $i < \count($trace); $i++) { $frame = $trace[$i]; @@ -98,7 +99,20 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal $function = $frame['function']; $file = $frame['file'] ?? ''; - if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) { + // Skip internal library frames and test runner framework internals + if ( + $class !== '' && ( + str_starts_with($class, 'TypePHP\\Internal\\') + || $class === 'TypePHP\\TypePHP' + || str_starts_with($class, 'PHPUnit\\') + || str_starts_with($class, 'Pest\\') + || str_starts_with($class, 'P\\') + ) + ) { + continue; + } + + if (str_starts_with($function, '__pest_')) { continue; } @@ -145,18 +159,37 @@ public static function isCallerIgnored(?string $callerClass = null, ?string $cal return false; } + /** + * Checks if a docblock contains a standalone @typephp-ignore or @typephp-disable tag. + */ + public static function hasIgnoreDocTag(string|false|null $doc): bool + { + if ($doc === null || $doc === false || $doc === '') { + return false; + } + + if (! str_contains($doc, '@typephp-ignore') && ! str_contains($doc, '@typephp-disable')) { + return false; + } + + return (bool) preg_match( + '/(?:^\s*\*\s*|\/\*\*\s*|(?:\/\/|#)\s*)@(typephp-ignore|typephp-disable)(?:\s|\*\/|$)/m', + $doc + ); + } + private static function checkMethodIgnored(string $class, string $method): bool { if (StubManager::hasMethodStub($class, $method)) { $stubDoc = StubManager::getMethodDoc($class, $method); - if ($stubDoc !== null && (str_contains($stubDoc, '@typephp-ignore') || str_contains($stubDoc, '@typephp-disable'))) { + if (self::hasIgnoreDocTag($stubDoc)) { return true; } } if (StubManager::hasClassStub($class)) { $classStubDoc = StubManager::getClassDoc($class); - if ($classStubDoc !== null && (str_contains($classStubDoc, '@typephp-ignore') || str_contains($classStubDoc, '@typephp-disable'))) { + if (self::hasIgnoreDocTag($classStubDoc)) { return true; } } @@ -171,20 +204,12 @@ private static function checkMethodIgnored(string $class, string $method): bool if ($refClass->hasMethod($method)) { $refMethod = $refClass->getMethod($method); - $doc = $refMethod->getDocComment(); - if ($doc !== false && $doc !== null && ( - str_contains($doc, '@typephp-ignore') - || str_contains($doc, '@typephp-disable') - )) { + if (self::hasIgnoreDocTag($refMethod->getDocComment())) { return true; } } - $classDoc = $refClass->getDocComment(); - if ($classDoc !== false && $classDoc !== null && ( - str_contains($classDoc, '@typephp-ignore') - || str_contains($classDoc, '@typephp-disable') - )) { + if (self::hasIgnoreDocTag($refClass->getDocComment())) { return true; } } catch (Throwable $e) { @@ -198,7 +223,7 @@ private static function checkFunctionIgnored(string $function): bool { if (StubManager::hasFunctionStub($function)) { $stubDoc = StubManager::getFunctionDoc($function); - if ($stubDoc !== null && (str_contains($stubDoc, '@typephp-ignore') || str_contains($stubDoc, '@typephp-disable'))) { + if (self::hasIgnoreDocTag($stubDoc)) { return true; } } @@ -209,11 +234,7 @@ private static function checkFunctionIgnored(string $function): bool try { $refFunc = new ReflectionFunction($function); - $doc = $refFunc->getDocComment(); - if ($doc !== false && $doc !== null && ( - str_contains($doc, '@typephp-ignore') - || str_contains($doc, '@typephp-disable') - )) { + if (self::hasIgnoreDocTag($refFunc->getDocComment())) { return true; } } catch (Throwable $e) { diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index 44814802..6fbd2728 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -290,9 +290,15 @@ public static function mayPathBeIncluded(string $normalizedPath): bool } } - if (str_starts_with($canon, 'var/') - || (! str_starts_with($canon, '/var/') && str_contains($canon, '/var/')) - || (str_starts_with($canon, '/var/') && str_contains(substr($canon, 4), '/var/')) + $isSystemVar = str_starts_with($canon, '/var/') || str_starts_with($canon, '/private/var/'); + $canonAfterSystemVar = str_starts_with($canon, '/private/var/') + ? substr($canon, 12) + : (str_starts_with($canon, '/var/') ? substr($canon, 4) : $canon); + + if ( + str_starts_with($canon, 'var/') + || (! $isSystemVar && str_contains($canon, '/var/')) + || ($isSystemVar && str_contains($canonAfterSystemVar, '/var/')) ) { if (! self::hasIncludeMatchingPrefix('var/', $includes)) { return false; diff --git a/tests/TypeChecking/Boundaries/PropertyDefaultValidationTest.php b/tests/TypeChecking/Boundaries/PropertyDefaultValidationTest.php new file mode 100644 index 00000000..a15aa94a --- /dev/null +++ b/tests/TypeChecking/Boundaries/PropertyDefaultValidationTest.php @@ -0,0 +1,67 @@ + + */ + public static array $protected = [ + 'name', + 'email', + 12345, + ]; +} + +class InstancePropertyDefaultInvalid +{ + /** + * @var positive-int + */ + public int $score = -1; +} + +class InstancePropertyDefaultValid +{ + /** + * @var positive-int + */ + public int $score = 100; +} + +describe('Property Default Value Validation at Declaration & Instantiation', function () { + test('rejects invalid static property default integer upon access or loading', function () { + expect(fn () => StaticPropertyDefaultInvalid::$score) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('rejects invalid static property default array list upon access or loading', function () { + expect(fn () => StaticPropertyDefaultListInvalid::$protected) + ->toThrow(TypeError::class, 'string') + ; + }); + + test('rejects invalid instance property default value upon instantiation', function () { + expect(fn () => new InstancePropertyDefaultInvalid()) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('accepts valid instance property default values cleanly', function () { + $user = new InstancePropertyDefaultValid(); + + expect($user->score)->toBe(100); + }); +}); diff --git a/tests/TypeChecking/Configuration/DeepIgnoreTraceDepthTest.php b/tests/TypeChecking/Configuration/DeepIgnoreTraceDepthTest.php new file mode 100644 index 00000000..8a080707 --- /dev/null +++ b/tests/TypeChecking/Configuration/DeepIgnoreTraceDepthTest.php @@ -0,0 +1,97 @@ += $targetDepth) { + return $leaf(); + } + + return self::recurse($currentDepth + 1, $targetDepth, $leaf); + } +} + +class DeepIgnoringCaller +{ + /** + * @typephp-ignore + */ + public function runDeep(int $framesDeep): int + { + $target = new DeepIgnoreTargetService(); + + return DeepPipelineRunner::recurse(1, $framesDeep, function () use ($target) { + return $target->executeLeaf(-1); + }); + } +} + +class DeepNormalCaller +{ + public function runDeep(int $framesDeep): int + { + $target = new DeepIgnoreTargetService(); + + return DeepPipelineRunner::recurse(1, $framesDeep, function () use ($target) { + return $target->executeLeaf(-1); + }); + } +} + +describe('Deep Stack Trace @typephp-ignore Resolution (11+ Frames Deep)', function () { + afterEach(function () { + Config::reset(); + }); + + test('honors @typephp-ignore when caller is 12 frames above the failing check', function () { + $caller = new DeepIgnoringCaller(); + + $result = $caller->runDeep(12); + + expect($result)->toBe(-1); + }); + + test('still throws TypeError when caller 12 frames deep does not have ignore tag', function () { + $caller = new DeepNormalCaller(); + + expect(fn () => $caller->runDeep(12)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); + + test('allows custom ignore_trace_depth configuration', function () { + Config::set(['ignore_trace_depth' => 30]); + + $caller = new DeepIgnoringCaller(); + + $result = $caller->runDeep(20); + expect($result)->toBe(-1); + }); + + test('shallow configured depth throws when ignore tag is beyond window', function () { + Config::set(['ignore_trace_depth' => 5]); + + $caller = new DeepIgnoringCaller(); + + expect(fn () => $caller->runDeep(10)) + ->toThrow(TypeError::class, 'positive-int') + ; + }); +}); diff --git a/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php b/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php index f9346f74..3c920618 100644 --- a/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php +++ b/tests/TypeChecking/Generics/ConstructorGenericPrebindingTest.php @@ -5,6 +5,7 @@ namespace TypePHP\Tests\TypeChecking\Generics; use TypePHP\Exception\TypeError; +use TypePHP\Internal\Util\Config; use TypePHP\TypePHP; class ConstructorPrebindAnimal @@ -82,3 +83,42 @@ public function getContent(): array ); }); }); + +describe('Configuration Toggles (inline_vars.generics => false)', function () { + afterEach(function () { + Config::reset(); + }); + + test('bypasses eager constructor prebinding and allows dynamic inference when generics toggle is false', function () { + Config::set([ + 'inline_vars' => [ + 'generics' => false, + 'objects' => true, + ], + ]); + + /** @var ConstructorPrebindBox $box */ + $box = new ConstructorPrebindBox(['1', '2', '3']); + + expect($box)->toBeInstanceOf(ConstructorPrebindBox::class) + ->and($box->getContent())->toBe(['1', '2', '3']) + ; + }); + + test('still enforces object class check when generics is false and objects is true', function () { + Config::set([ + 'inline_vars' => [ + 'generics' => false, + 'objects' => true, + ], + ]); + + expect(function () { + /** @var ConstructorPrebindBox $box */ + $box = new ConstructorPrebindCar(); + })->toThrow( + TypeError::class, + 'must be of type TypePHP\Tests\TypeChecking\Generics\ConstructorPrebindBox' + ); + }); +}); diff --git a/tests/Unit/LineNumberPreservationTest.php b/tests/Unit/LineNumberPreservationTest.php index c55b77e6..bcb7d66b 100644 --- a/tests/Unit/LineNumberPreservationTest.php +++ b/tests/Unit/LineNumberPreservationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use TypePHP\Internal\Io\StreamWrapper; +use TypePHP\Internal\Util\Config; describe('Line Number Preservation', function () { test('transforms code without shifting original line numbers for parameter checks', function () { @@ -208,4 +209,112 @@ public function show(): void ->and($transformed)->not()->toContain('/*') ; }); + + test('preserves exact line numbers when a multi-line array return is followed by subsequent method calls', function () { + $source = <<<'PHP' +> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'is_locked' => ['boolean'], + ]; + } + + /** + * @param array $data + */ + public function insert(array $data): void + { + // logic + } +} + +$targetCall = true; +PHP; + + $transformed = StreamWrapper::transformSource($source, 'test_multiline_return_drift.php'); + + $origLines = explode("\n", str_replace("\r\n", "\n", $source)); + $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); + + expect(\count($transLines))->toBe(\count($origLines)); + + $origIndex = array_search('$targetCall = true;', array_map('trim', $origLines), true); + $transIndex = array_search('$targetCall = true;', array_map('trim', $transLines), true); + + expect($transIndex)->toBe($origIndex) + ->and($origIndex)->toBe(27) + ; + }); + + test('points to exact return statement start line when multi-line return array contract fails', function () { + $sysTemp = realpath(sys_get_temp_dir()); + $baseTemp = str_replace('\\', '/', $sysTemp !== false ? $sysTemp : sys_get_temp_dir()); + $tempDir = $baseTemp . '/typephp_multiline_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + $canonicalDir = str_replace('\\', '/', realpath($tempDir) ?: $tempDir); + $scriptPath = $canonicalDir . '/multiline_return_test.php'; + + $code = <<<'PHP' +> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string'], + ]; + } +} + +(new ReturnLineCheckFixture())->rules(); +PHP; + + file_put_contents($scriptPath, $code); + + try { + Config::set([ + 'include' => [ + $canonicalDir . '/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + StreamWrapper::register(); + + require $scriptPath; + $caught = false; + } catch (TypePHP\Exception\TypeError $e) { + $caught = true; + expect($e->getLine())->toBe(12); + } finally { + Config::reset(); + if (file_exists($scriptPath)) { + @unlink($scriptPath); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + } + + expect($caught)->toBeTrue(); + }); });