diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02aa11a..3c7ecbf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,48 @@ permissions: contents: read jobs: + style: + name: Check Code Style + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo + coverage: none + + - name: Install Dependencies + uses: ramsey/composer-install@v3 + + - name: Check Code Style (Pint) + run: ./vendor/bin/pint --test + + analyze: + name: Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo + coverage: none + + - name: Install Dependencies + uses: ramsey/composer-install@v3 + + - name: Run Static Analysis (PHPStan) + run: ./vendor/bin/phpstan analyse --no-progress + test: name: PHP ${{ matrix.php }} (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -38,16 +80,9 @@ jobs: with: dependency-versions: ${{ matrix.php == '8.2' && 'lowest' || 'highest' }} - - name: Run Static Analysis (PHPStan) - run: ./vendor/bin/phpstan analyse --no-progress - if: matrix.os == 'ubuntu-latest' && matrix.php == '8.3' - - name: Warm Up TypePHP Cache run: php bin/typephp cache:warm - - name: Run Runtime Type Checking Engine Suite (Pest) - run: ./vendor/bin/pest tests/TypeChecking --compact - - name: Run Full Test Suite with Coverage (Pest) run: ./vendor/bin/pest --coverage-clover=clover.xml --compact if: matrix.os == 'ubuntu-latest' && matrix.php == '8.4' diff --git a/phpunit.xml b/phpunit.xml index 484c56f5..5ab78a90 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,7 @@ src/bootstrap.php + src/internal/Cli \ No newline at end of file diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index 4ba46a4a..48718acb 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -6,7 +6,10 @@ use PhpParser\Node; use PhpParser\NodeVisitorAbstract; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Parser\TokenIterator; use TypePHP\Internal\Docblock\DocblockExtractor; use TypePHP\Internal\Docblock\DocblockNormalizer; @@ -96,12 +99,21 @@ public function enterNode(Node $node): ?array if ($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignOp) { $this->markWriteContext($node->var); + } elseif ($node instanceof Node\Expr\AssignRef) { + $this->markWriteContext($node->var); + $this->markWriteContext($node->expr); } 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); } + } elseif ($node instanceof Node\Expr\Isset_) { + foreach ($node->vars as $v) { + $this->markWriteContext($v); + } + } elseif ($node instanceof Node\Expr\Empty_) { + $this->markWriteContext($node->expr); } if ($node instanceof Node\Stmt\Class_) { @@ -207,6 +219,12 @@ 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); + } elseif ($node instanceof Node\Expr\Array_ || $node instanceof Node\Expr\List_) { + foreach ($node->items as $item) { + if ($item !== null) { + $this->markWriteContext($item->value); + } + } } } @@ -515,9 +533,9 @@ private function propagateShapeTypesToDestructuring(Node\Expr\List_|Node\Expr\Ar $autoIdx = 0; foreach ($parsed->items as $item) { $key = match (true) { - $item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode => $item->keyName->value, - $item->keyName instanceof \PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode => (int) $item->keyName->value, - $item->keyName instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode => $item->keyName->name, + $item->keyName instanceof ConstExprStringNode => $item->keyName->value, + $item->keyName instanceof ConstExprIntegerNode => (int) $item->keyName->value, + $item->keyName instanceof IdentifierTypeNode => $item->keyName->name, $item->keyName !== null => (string) $item->keyName, default => $autoIdx, }; @@ -600,6 +618,31 @@ private function handleAssign(Node\Expr\Assign $node): void $node->expr = $this->wrapVariableCheck($expr, $typeString, $varName, $node->var->getStartLine()); } + } elseif (($chainInfo = $this->extractMemberChain($node->var)) !== null) { + $root = $chainInfo['root']; + $chain = $chainInfo['chain']; + + if ($root instanceof Node\Expr\Variable && \is_string($root->name)) { + $varName = $root->name; + $typeString = $this->scopeManager->getVarTypeFromScope($varName); + + if ($typeString !== null && $this->shouldHandleMemberAssignCheck($typeString, $chain)) { + $node->expr = $this->wrapMemberAssignCheck($node->expr, $root, $chain, $typeString, $varName, $node->var->getStartLine()); + + return; + } + } + + // Fallback for direct single property access + if ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { + $node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()); + } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { + $classArg = $node->var->class instanceof Node\Name + ? new Node\Expr\ClassConstFetch($node->var->class, 'class') + : $node->var->class; + + $node->expr = $this->wrapPropertyCheck($node->expr, $classArg, $node->var->name->toString(), $node->var->getStartLine()); + } } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { $node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()); } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { @@ -630,6 +673,37 @@ private function handleAssignOp(Node\Expr\AssignOp $node): ?Node\Expr\Assign $this->wrapVariableCheck($binaryExpr, $typeString, $varName, $node->var->getStartLine()) ); } + } elseif (($chainInfo = $this->extractMemberChain($node->var)) !== null) { + $root = $chainInfo['root']; + $chain = $chainInfo['chain']; + + if ($root instanceof Node\Expr\Variable && \is_string($root->name)) { + $varName = $root->name; + $typeString = $this->scopeManager->getVarTypeFromScope($varName); + + if ($typeString !== null && $this->shouldHandleMemberAssignCheck($typeString, $chain)) { + return new Node\Expr\Assign( + $node->var, + $this->wrapMemberAssignCheck($binaryExpr, $root, $chain, $typeString, $varName, $node->var->getStartLine()) + ); + } + } + + if ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { + return new Node\Expr\Assign( + $node->var, + $this->wrapPropertyCheck($binaryExpr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()) + ); + } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { + $classArg = $node->var->class instanceof Node\Name + ? new Node\Expr\ClassConstFetch($node->var->class, 'class') + : $node->var->class; + + return new Node\Expr\Assign( + $node->var, + $this->wrapPropertyCheck($binaryExpr, $classArg, $node->var->name->toString(), $node->var->getStartLine()) + ); + } } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { return new Node\Expr\Assign( $node->var, @@ -665,6 +739,37 @@ private function handleIncDec(Node\Expr\PreInc|Node\Expr\PostInc|Node\Expr\PreDe $this->wrapVariableCheck($binaryExpr, $typeString, $varName, $node->var->getStartLine()) ); } + } elseif (($chainInfo = $this->extractMemberChain($node->var)) !== null) { + $root = $chainInfo['root']; + $chain = $chainInfo['chain']; + + if ($root instanceof Node\Expr\Variable && \is_string($root->name)) { + $varName = $root->name; + $typeString = $this->scopeManager->getVarTypeFromScope($varName); + + if ($typeString !== null && $this->shouldHandleMemberAssignCheck($typeString, $chain)) { + return new Node\Expr\Assign( + $node->var, + $this->wrapMemberAssignCheck($binaryExpr, $root, $chain, $typeString, $varName, $node->var->getStartLine()) + ); + } + } + + if ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { + return new Node\Expr\Assign( + $node->var, + $this->wrapPropertyCheck($binaryExpr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()) + ); + } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { + $classArg = $node->var->class instanceof Node\Name + ? new Node\Expr\ClassConstFetch($node->var->class, 'class') + : $node->var->class; + + return new Node\Expr\Assign( + $node->var, + $this->wrapPropertyCheck($binaryExpr, $classArg, $node->var->name->toString(), $node->var->getStartLine()) + ); + } } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { return new Node\Expr\Assign( $node->var, @@ -684,6 +789,101 @@ private function handleIncDec(Node\Expr\PreInc|Node\Expr\PostInc|Node\Expr\PreDe return null; } + /** + * Unpacks any nested chain of ArrayDimFetch and PropertyFetch down to its root expression. + * + * @return array{root: Node\Expr, chain: list}|null + */ + private function extractMemberChain(Node\Expr $target): ?array + { + $curr = $target; + $chain = []; + + while ($curr instanceof Node\Expr\ArrayDimFetch || $curr instanceof Node\Expr\PropertyFetch) { + if ($curr instanceof Node\Expr\ArrayDimFetch) { + array_unshift($chain, ['dim', $curr->dim]); + $curr = $curr->var; + } elseif ($curr instanceof Node\Expr\PropertyFetch) { + if ($curr->name instanceof Node\Identifier) { + array_unshift($chain, ['prop', $curr->name->toString()]); + $curr = $curr->var; + } else { + return null; + } + } + } + + if ($chain === []) { + return null; + } + + return [ + 'root' => $curr, + 'chain' => $chain, + ]; + } + + /** + * @param list $chain + */ + private function shouldHandleMemberAssignCheck(string $typeString, array $chain): bool + { + foreach ($chain as $step) { + if ($step[0] === 'dim') { + return true; + } + } + + return str_contains($typeString, '{'); + } + + /** + * @param list $chain + */ + private function wrapMemberAssignCheck( + Node\Expr $valueExpr, + Node\Expr $rootExpr, + array $chain, + string $typeString, + string $varName, + int $line + ): Node\Expr\Ternary { + $chainItems = []; + foreach ($chain as $step) { + $kind = new Node\Scalar\String_($step[0]); + if ($step[0] === 'dim') { + $dimExpr = $step[1] instanceof Node\Expr ? $step[1] : new Node\Expr\ConstFetch(new Node\Name('null')); + } elseif (\is_string($step[1])) { + $dimExpr = new Node\Scalar\String_($step[1]); + } elseif ($step[1] instanceof Node\Expr) { + $dimExpr = $step[1]; + } else { + $dimExpr = new Node\Scalar\String_(''); + } + + $chainItems[] = new Node\ArrayItem( + new Node\Expr\Array_([ + new Node\ArrayItem($kind), + new Node\ArrayItem($dimExpr), + ]) + ); + } + + $chainAst = new Node\Expr\Array_($chainItems); + + $checkCall = NodeBuilder::createMemberAssignCheckCall( + $rootExpr, + $chainAst, + $valueExpr, + $typeString, + $varName, + $this->getCurrentCallerExpr(), + $this->getCurrentThisExpr() + ); + + return NodeBuilder::createTernaryThrowExpr($checkCall, $line); + } + private function wrapVariableCheck(Node\Expr $expr, string $typeString, string $varName, int $line): Node\Expr\Ternary { $checkCall = NodeBuilder::createVariableCheckCall( @@ -731,6 +931,7 @@ private function wrapClone(Node\Expr\Clone_ $node): ?Node\Expr\FuncCall private function isSoftScopeBoundary(Node $node): bool { return $node instanceof Node\Expr\ArrowFunction + || $node instanceof Node\Stmt\Block || $node instanceof Node\Stmt\If_ || $node instanceof Node\Stmt\Else_ || $node instanceof Node\Stmt\ElseIf_ diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index 85a4b641..5c9b6ab7 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -68,17 +68,14 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $paramCount = \count($node->params); - $hasParam = ($isMagicGet || $isMagicCall) || self::hasParamContracts( - $docText, - $isClassMethod, - $hasInheritance, - $paramCount, - $isPrivate, - $isConstructor, - $hasPropertyWithDoc, - $classContext === null, - $node->attrGroups !== [] - ); + $hasReturnDoc = str_contains($docText, '@return') + || str_contains($docText, '@phpstan-return') + || str_contains($docText, '@psalm-return'); + + $hasReturn = ! $isMagicLifecycle + && ! $isNativeNever + && ! ($isNativeVoid && ! $hasReturnDoc) + && ($isMagicGet || $isMagicCall || self::hasReturnContracts($docText, $isClassMethod, $isPrivate)); $byRefParams = []; foreach ($node->params as $p) { @@ -96,14 +93,19 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $hasSelfOutDoc = str_contains($docText, 'self-out') || str_contains($docText, 'this-out'); $hasSelfOut = $isClassMethod && ! $node->isStatic() && ($hasSelfOutDoc || ($hasClassTemplates && $hasRealInheritance)); - $hasReturnDoc = str_contains($docText, '@return') - || str_contains($docText, '@phpstan-return') - || str_contains($docText, '@psalm-return'); - - $hasReturn = ! $isMagicLifecycle - && ! $isNativeNever - && ! ($isNativeVoid && ! $hasReturnDoc) - && ($isMagicGet || $isMagicCall || self::hasReturnContracts($docText, $isClassMethod, $isPrivate)); + $hasParam = ($isMagicGet || $isMagicCall) || ($paramCount > 0 && ( + str_contains($docText, ' is ') || (($hasReturnDoc || $hasSelfOutDoc) && str_contains($docText, '$')) + )) || self::hasParamContracts( + $docText, + $isClassMethod, + $hasInheritance, + $paramCount, + $isPrivate, + $isConstructor, + $hasPropertyWithDoc, + $classContext === null, + $node->attrGroups !== [] + ); if (! $hasParam && ! $hasReturn && ! $hasParamOut && ! $hasSelfOut) { return; @@ -129,7 +131,8 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $needsReturnVars, $hasReturn, $hasParamOut ? $byRefParams : [], - $hasSelfOut + $hasSelfOut, + $node->byRef ); } @@ -599,9 +602,9 @@ public static function buildTypeErrorThrowExpr(Node\Expr $errorVar, ?int $line = ); } - public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\Expression + public static function buildTypeErrorThrowStmt(Node\Expr $errorVar, ?int $line = null): Node\Stmt\Expression { - return new Node\Stmt\Expression(self::buildTypeErrorThrowExpr($errorVar)); + return new Node\Stmt\Expression(self::buildTypeErrorThrowExpr($errorVar, $line)); } public static function buildReturnCheckCall(Node\Expr $exprToWrap, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Expr\FuncCall @@ -641,6 +644,23 @@ public static function buildVoidReturnGuard(Node\Expr\FuncCall $checkCall): arra return [$ifStmt, $retStmt]; } + /** + * @return array + */ + public static function buildByRefReturnGuard(Node\Expr\FuncCall $checkCall, Node\Stmt\Return_ $returnStmt, ?int $line = null): array + { + $ifStmt = new Node\Stmt\If_( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpRet'), $checkCall), + new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') + ), + ['stmts' => [self::buildTypeErrorThrowStmt(new Node\Expr\Variable('__typephpRet'), $line)]] + ); + $ifStmt->setAttribute('typephp_injected', true); + + return [$ifStmt, $returnStmt]; + } + public static function buildTernaryReturnExpr(Node\Expr\FuncCall $checkCall, ?int $line = null): Node\Expr\Ternary { return new Node\Expr\Ternary( @@ -760,10 +780,11 @@ private static function wrapNonGeneratorReturns( bool $needsReturnVars = false, bool $hasReturn = true, array $byRefParams = [], - bool $hasSelfOut = false + bool $hasSelfOut = false, + bool $isByRefReturn = false ): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid, $needsReturnVars, $hasReturn, $byRefParams, $hasSelfOut, $isByRefReturn) extends NodeVisitorAbstract { /** * @param array $byRefParams */ @@ -773,7 +794,8 @@ public function __construct( private bool $needsReturnVars, private bool $hasReturn, private array $byRefParams, - private bool $hasSelfOut + private bool $hasSelfOut, + private bool $isByRefReturn ) { } @@ -815,6 +837,13 @@ public function enterNode(Node $n): int|array|null } $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); + + if ($this->isByRefReturn) { + $byRefGuardStmts = FunctionContractInjector::buildByRefReturnGuard($checkCall, $n, $n->getStartLine()); + + return [...$exitStmts, ...$byRefGuardStmts]; + } + $n->expr = FunctionContractInjector::buildTernaryReturnExpr($checkCall, $n->getStartLine()); return $exitStmts !== [] ? [...$exitStmts, $n] : null; @@ -851,6 +880,12 @@ public function enterNode(Node $n): int|array|null if ($isNativeVoid) { $newStmts = [...$newStmts, ...$exitStmts, ...self::buildVoidReturnGuard($checkCall)]; + } elseif ($isByRefReturn) { + $fallbackLine = $lastStmt instanceof Node\Stmt ? $lastStmt->getStartLine() : null; + $retStmt = new Node\Stmt\Return_(null); + $retStmt->setAttribute('typephp_injected', true); + $byRefGuardStmts = self::buildByRefReturnGuard($checkCall, $retStmt, $fallbackLine); + $newStmts = [...$newStmts, ...$exitStmts, ...$byRefGuardStmts]; } else { $fallbackLine = $lastStmt instanceof Node\Stmt ? $lastStmt->getStartLine() : null; $ternaryExpr = self::buildTernaryReturnExpr($checkCall, $fallbackLine); diff --git a/src/Internal/Ast/NodeBuilder.php b/src/Internal/Ast/NodeBuilder.php index d02fe97e..bb467198 100644 --- a/src/Internal/Ast/NodeBuilder.php +++ b/src/Internal/Ast/NodeBuilder.php @@ -51,6 +51,37 @@ public static function createVariableCheckCall( ); } + public static function createMemberAssignCheckCall( + Node\Expr $rootExpr, + Node\Expr $chainExpr, + Node\Expr $valueExpr, + string $typeString, + string $varName, + ?Node\Expr $callerExpr = null, + ?Node\Expr $thisArg = null + ): Node\Expr\FuncCall { + $args = [ + new Node\Arg($rootExpr), + new Node\Arg($chainExpr), + new Node\Arg($valueExpr), + new Node\Arg(new Node\Scalar\String_($typeString)), + new Node\Arg(new Node\Scalar\String_($varName)), + new Node\Arg(new Node\Scalar\MagicConst\File()), + ]; + + if ($callerExpr !== null) { + $args[] = new Node\Arg($callerExpr); + if ($thisArg !== null) { + $args[] = new Node\Arg($thisArg); + } + } + + return new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkMemberAssign'), + $args + ); + } + public static function createTernaryThrowExpr(Node\Expr\FuncCall $checkCall, int $startLine = -1): Node\Expr\Ternary { $args = [ diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 2562b913..7cccb1f2 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -25,6 +25,7 @@ use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; use TypePHP\Internal\Diagnostic\ErrorFactory; +use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockNormalizer; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; @@ -226,6 +227,81 @@ public static function checkVariable( return $value; } + /** + * Directly validates a mutated collection element in O(1) for hybrid validation mode. + * + * @param list $path + */ + public static function validateCollectionElementMutation( + mixed $value, + array $path, + string $typeString, + string $varName, + string $file, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + if (\count($path) !== 1 || $path[0][0] !== 'dim') { + return null; + } + + try { + $normalized = DocblockNormalizer::normalize($typeString); + [$typeNode] = self::parseTypeString($normalized); + + if ($file !== '') { + $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); + } + + $key = $path[0][1]; + $keyStr = \is_string($key) ? $key : (\is_int($key) ? (string) $key : ''); + + if ($typeNode instanceof GenericTypeNode) { + $base = strtolower($typeNode->type->name); + + if ($base === 'list' || $base === 'non-empty-list') { + if ($key !== null && ! \is_int($key)) { + return ErrorFactory::createError("Variable \${$varName} must be a list, non-integer key '{$keyStr}' given"); + } + + $valueType = $typeNode->genericTypes[0] ?? null; + if ($valueType !== null) { + $context = "Variable \${$varName}" . ($key !== null ? "[{$key}]" : '[]'); + + return $registry->validate($value, $valueType, $context); + } + } elseif (isset(self::ARRAY_TYPES[$base]) || $base === 'non-empty-array') { + $genericCount = \count($typeNode->genericTypes); + + if ($genericCount >= 2 && $key !== null) { + $keyType = $typeNode->genericTypes[0]; + $keyErr = $registry->validate($key, $keyType, "Variable \${$varName} key"); + if ($keyErr !== null) { + return $keyErr; + } + } + + $valueType = $genericCount >= 2 + ? $typeNode->genericTypes[1] + : ($typeNode->genericTypes[0] ?? null); + + if ($valueType !== null) { + $context = "Variable \${$varName}" . ($key !== null ? "['{$keyStr}']" : '[]'); + + return $registry->validate($value, $valueType, $context); + } + } + } elseif ($typeNode instanceof ArrayTypeNode) { + $context = "Variable \${$varName}" . ($key !== null ? "[{$keyStr}]" : '[]'); + + return $registry->validate($value, $typeNode->type, $context); + } + } catch (\Throwable $e) { + // Silently fall back to prospective whole-structure validation + } + + return null; + } + /** * Recursively wraps callable items nested inside array shapes. * diff --git a/src/Internal/Checker/MemberAssignChecker.php b/src/Internal/Checker/MemberAssignChecker.php new file mode 100644 index 00000000..85a74163 --- /dev/null +++ b/src/Internal/Checker/MemberAssignChecker.php @@ -0,0 +1,68 @@ + $chain + */ + public static function check( + mixed $root, + array $chain, + mixed $value, + string $typeString, + string $varName, + string $file, + TypeValidatorRegistry $registry, + ?string $caller = null, + mixed $thisOrClass = null + ): mixed { + if (Config::isArrayValidationHybrid() && ! str_contains($typeString, '{') && \count($chain) === 1 && $chain[0][0] === 'dim') { + $directErr = InlineChecker::validateCollectionElementMutation( + $value, + $chain, + $typeString, + $varName, + $file, + $registry + ); + + if ($directErr instanceof ErrorMessage) { + return $directErr; + } + + return $value; + } + + $copy = StructureMutator::mutateCopy($root, $chain, $value); + + $res = InlineChecker::checkVariable( + $copy, + $typeString, + $varName, + $file, + $registry, + $caller, + $thisOrClass + ); + + if ($res instanceof ErrorMessage) { + return $res; + } + + return $value; + } +} diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 29af383f..1d83cf74 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -1288,9 +1288,11 @@ private static function resolveClassStringTemplate( $isClassLevelTemplate = ! TemplateManager::isMethodTemplate($function, $templateName) && isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; + $redact = $isSensitive || Config::isRedactValuesEnabled(); + if (! TemplateManager::isBound($function, $targetObj, $templateName)) { if (! \is_string($val) || ! ClassNameValidator::isValidClassString($val)) { - return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val, $isSensitive) . ' given'); + return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val, $redact) . ' given'); } if ($templateNode->bound !== null) { @@ -1299,7 +1301,7 @@ private static function resolveClassStringTemplate( $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); if (! self::checkClassStringSatisfiesBound($val, $resolvedBound)) { $boundDisplay = (string) $resolvedBound; - $displayVal = $isSensitive ? 'string given' : "'" . $val . "' given"; + $displayVal = $redact ? 'string given' : "'" . $val . "' given"; return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundDisplay . ', ' . $displayVal); } @@ -1312,7 +1314,7 @@ private static function resolveClassStringTemplate( $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); if ($expectedTypeNode !== null) { if (! \is_string($val) || ! self::checkClassStringSatisfiesBound($val, $expectedTypeNode)) { - $valStr = TypeFormatter::formatGivenValue($val, $isSensitive); + $valStr = TypeFormatter::formatGivenValue($val, $redact); $targetDisplay = (string) $expectedTypeNode; return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a class-string of ' . $targetDisplay . ', ' . $valStr . ' given'); diff --git a/src/Internal/Cli/CommandRunner.php b/src/Internal/Cli/CommandRunner.php index 939d6602..9ed35718 100644 --- a/src/Internal/Cli/CommandRunner.php +++ b/src/Internal/Cli/CommandRunner.php @@ -8,6 +8,8 @@ final class CommandRunner { private const KNOWN_COMMANDS = [ 'config:init', + 'report', + 'report:clear', 'cache:clear', 'cache:warm', 'cache:rebuild', @@ -41,6 +43,14 @@ public static function run(array $args, $outputStream = STDOUT, $errorStream = S return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); } + if ($firstArg === 'report' || $firstArg === 'report:show') { + return (new ReportCommand())->execute($args, $outputStream, $errorStream); + } + + if ($firstArg === 'report:clear') { + return (new ReportClearCommand())->execute($args, $outputStream, $errorStream); + } + if ($firstArg === 'cache:rebuild') { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index b553f5b3..1d02f102 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -53,6 +53,58 @@ private static function getTemplate(): string */ 'enabled' => true, + /* + |-------------------------------------------------------------------------- + | Auto-Boot on Composer Autoload + |-------------------------------------------------------------------------- + | When true (default), TypePHP automatically hooks into the stream wrapper + | as soon as 'vendor/autoload.php' is required. + | + | Set to false to disable auto-booting project-wide. You can then manually + | boot TypePHP where desired (e.g. in 'tests/bootstrap.php') via: + | \TypePHP\TypePHP::boot(); + | + | Can also be configured in root composer.json: + | "extra": { "typephp": { "auto-boot": false } } + */ + 'auto_boot' => true, + + /* + |-------------------------------------------------------------------------- + | Violation Handling Strategy & Audit Reporting + |-------------------------------------------------------------------------- + | Controls what happens when a type contract is violated at runtime: + | + | - 'throw' : (Default / Strict) Immediately throws TypePHP\Exception\TypeError. + | - 'report' : (Audit Mode) Does not throw; records each unique violation + | and dumps a structured JSON audit report to 'report_file' at shutdown. + | - 'warn' : Emits PHP E_USER_WARNING once per unique violation to your logs + | and allows execution to continue normally without crashing. + | + | 'report_file' : Sets the target JSON output path when 'report' mode is active. + | 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown + | if the violation report is non-empty. Useful for CI gates. + */ + 'on_violation' => 'throw', + 'report_file' => null, + 'fail_on_report' => false, + + /* + |-------------------------------------------------------------------------- + | Global Value Redaction + |-------------------------------------------------------------------------- + | When true, TypePHP redacts all raw parameter, return, property, and + | variable values from TypeError exception messages and audit reports, + | displaying only their types (e.g. 'string given' instead of 'secret_pwd'). + | + | Useful for production environments, staging logs, and HIPAA/GDPR compliance. + | + | Can also be set via environment variable: TYPEPHP_REDACT_VALUES=true + | Can also be configured in root composer.json: + | "extra": { "typephp": { "redact-values": true } } + */ + 'redact_values' => false, + /* |-------------------------------------------------------------------------- | Function Boundary Contracts (@param, @return, @param-out, @self-out) diff --git a/src/Internal/Cli/HelpCommand.php b/src/Internal/Cli/HelpCommand.php index a82d39ba..35b49b1d 100644 --- a/src/Internal/Cli/HelpCommand.php +++ b/src/Internal/Cli/HelpCommand.php @@ -15,12 +15,15 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE fwrite($outputStream, " vendor/bin/typephp \n\n"); fwrite($outputStream, ' ' . $c('COMMANDS', 'yellow') . "\n"); fwrite($outputStream, ' ' . $c('config:init', 'green') . " Generate default typephp.php configuration file\n"); + fwrite($outputStream, ' ' . $c('report', 'green') . " Display audit report summary in terminal\n"); + fwrite($outputStream, ' ' . $c('report:clear', 'green') . " Delete generated report file and shards\n"); fwrite($outputStream, ' ' . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); fwrite($outputStream, ' ' . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); fwrite($outputStream, ' ' . $c('cache:rebuild', 'green') . " Clear and immediately warm up cache\n"); fwrite($outputStream, ' ' . $c('help', 'green') . " Display this help menu\n\n"); fwrite($outputStream, ' ' . $c('EXAMPLES', 'yellow') . "\n"); fwrite($outputStream, " vendor/bin/typephp config:init\n"); + fwrite($outputStream, " vendor/bin/typephp report\n"); fwrite($outputStream, " vendor/bin/typephp index.php\n"); fwrite($outputStream, " vendor/bin/typephp cache:rebuild\n\n"); diff --git a/src/Internal/Cli/ReportClearCommand.php b/src/Internal/Cli/ReportClearCommand.php new file mode 100644 index 00000000..4534e833 --- /dev/null +++ b/src/Internal/Cli/ReportClearCommand.php @@ -0,0 +1,52 @@ + 0) { + fwrite($outputStream, ' ' . $c('✓', 'green') . ' Cleared report file and temporary shards (' . $c((string) $deletedCount, 'bold') . " file(s) removed).\n\n"); + } else { + fwrite($outputStream, ' ' . $c('•', 'cyan') . " No report files or shards found to clear.\n\n"); + } + + return 0; + } +} diff --git a/src/Internal/Cli/ReportCommand.php b/src/Internal/Cli/ReportCommand.php new file mode 100644 index 00000000..55fedbd6 --- /dev/null +++ b/src/Internal/Cli/ReportCommand.php @@ -0,0 +1,97 @@ +>}|null $data */ + $data = json_decode($rawContent, true); + + if (! \is_array($data) || ! isset($data['violations']) || ! \is_array($data['violations']) || $data['violations'] === []) { + fwrite($outputStream, ' ' . $c('✓', 'green') . " No violations recorded in report.\n\n"); + + return 0; + } + + $violations = $data['violations']; + $totalCount = \count($violations); + + /** @var array>> $grouped */ + $grouped = []; + $filesAffectedMap = []; + + foreach ($violations as $v) { + $file = isset($v['file']) && \is_string($v['file']) ? $v['file'] : 'unknown'; + $grouped[$file][] = $v; + $filesAffectedMap[$file] = true; + } + + $filesAffected = isset($data['summary']['files_affected']) && \is_int($data['summary']['files_affected']) + ? $data['summary']['files_affected'] + : \count($filesAffectedMap); + + $relativeReportPath = ViolationCollector::normalizeRelativePath($reportFile); + + fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Total Violations: ' . $c((string) $totalCount, 'bold') . "\n"); + fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Files Affected: ' . $c((string) $filesAffected, 'bold') . "\n"); + fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Report Source: ' . $c($relativeReportPath, 'gray') . "\n\n"); + + foreach ($grouped as $file => $items) { + fwrite($outputStream, ' ' . $c($file, 'bold') . "\n"); + + foreach ($items as $item) { + $line = isset($item['line']) && \is_int($item['line']) ? $item['line'] : 1; + $kind = isset($item['kind']) && \is_string($item['kind']) ? $item['kind'] : 'type'; + $target = isset($item['target']) && \is_string($item['target']) ? $item['target'] : ''; + $expected = isset($item['expected']) && \is_string($item['expected']) ? $item['expected'] : ''; + $given = isset($item['given']) && \is_string($item['given']) ? $item['given'] : ''; + + $lineFormatted = str_pad("Line {$line}", 10); + $kindFormatted = str_pad($kind, 11); + $targetFormatted = str_pad($target, 12); + + fwrite($outputStream, ' ' . $c($lineFormatted, 'gray') . ' ' . $c($kindFormatted, 'yellow') . ' ' . $c($targetFormatted, 'cyan') . ' expected ' . $c($expected, 'green') . ', ' . $c($given, 'red') . "\n"); + } + + fwrite($outputStream, "\n"); + } + + return 1; + } +} diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index cabd02b5..827e2749 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -4,6 +4,8 @@ namespace TypePHP\Internal\Diagnostic; +use TypePHP\Internal\Util\Config; + /** * @internal */ @@ -11,7 +13,7 @@ final class TypeFormatter { public static function formatGivenValue(mixed $value, bool $isSensitive = false): string { - if ($isSensitive) { + if ($isSensitive || Config::isRedactValuesEnabled()) { return get_debug_type($value); } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 83f50143..b1856f39 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -508,7 +508,10 @@ public static function parseProperty(string $className, string $propertyName): ? $aliases = []; $classTemplates = []; - self::parseClassLevelDocs($declaringClass, $classTemplates, $aliases); + self::parseClassLevelDocs($refClass, $classTemplates, $aliases); + if ($declaringClass->getName() !== $refClass->getName()) { + self::parseClassLevelDocs($declaringClass, $classTemplates, $aliases); + } if (! $isMagicProperty) { $phpDocNode = DocblockExtractor::parseDocString($doc); @@ -601,12 +604,16 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri return ['doc' => $stubDoc, 'declaringClass' => $current]; } - foreach ($current->getTraits() as $trait) { + foreach (self::getTraitsRecursive($current) as $trait) { if ($trait->hasProperty($propertyName)) { $traitProp = $trait->getProperty($propertyName); + $declaringClass = $traitProp->getDeclaringClass(); $doc = $traitProp->getDocComment(); + if ($doc === false && $declaringClass->getName() !== $trait->getName() && $declaringClass->hasProperty($propertyName)) { + $doc = $declaringClass->getProperty($propertyName)->getDocComment(); + } if ($doc !== false) { - return ['doc' => $doc, 'declaringClass' => $trait]; + return ['doc' => $doc, 'declaringClass' => $declaringClass]; } } } @@ -615,7 +622,7 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri $refProp = $current->getProperty($propertyName); $doc = $refProp->getDocComment(); if ($doc !== false) { - return ['doc' => $doc, 'declaringClass' => $current]; + return ['doc' => $doc, 'declaringClass' => $refProp->getDeclaringClass()]; } } $parent = $current->getParentClass(); @@ -633,7 +640,7 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri $interfaceProp = $interface->getProperty($propertyName); $doc = $interfaceProp->getDocComment(); if ($doc !== false) { - return ['doc' => $doc, 'declaringClass' => $interface]; + return ['doc' => $doc, 'declaringClass' => $interfaceProp->getDeclaringClass()]; } } } @@ -641,6 +648,38 @@ private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, stri return null; } + /** + * Recursively collects all traits used by a class or trait, deepest (innermost) first. + * + * @param \ReflectionClass $class + * @param array $visited + * + * @return array> + */ + private static function getTraitsRecursive(\ReflectionClass $class, array &$visited = []): array + { + $traits = []; + foreach ($class->getTraits() as $trait) { + $name = $trait->getName(); + if (isset($visited[$name])) { + continue; + } + $visited[$name] = true; + + foreach (self::getTraitsRecursive($trait, $visited) as $nestedName => $nestedTrait) { + if (! isset($traits[$nestedName])) { + $traits[$nestedName] = $nestedTrait; + } + } + + if (! isset($traits[$name])) { + $traits[$name] = $trait; + } + } + + return $traits; + } + /** * @param \ReflectionClass $refClass * diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 69f78d3e..3735121d 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -47,6 +47,8 @@ final class StreamWrapper implements StreamWrapperInterface */ private $dirHandle = null; + private bool $eof = false; + private static bool $isRegistered = false; private static bool $cacheEnabled = true; @@ -347,6 +349,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ : fopen($target, $mode) ); $this->handle = $handle !== false ? $handle : null; + $this->eof = false; self::register(); return $this->handle !== null; @@ -361,6 +364,23 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $success; } + /** + * Determines whether the stream is being probed by Phar or PharData internal archive routines. + */ + private static function isPharCall(): bool + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4); + + for ($i = 1; $i < \count($trace); $i++) { + $class = $trace[$i]['class'] ?? ''; + if ($class === 'PharData' || $class === 'Phar') { + return true; + } + } + + 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. @@ -391,9 +411,10 @@ private function openDirectHandle(string $targetFile, string $mode, int $options : fopen($targetFile, $mode, $useIncludePath) ); $this->handle = $handle !== false ? $handle : null; + $this->eof = false; self::register(); - if ($this->handle === null && ! $isInclude) { + if ($this->handle === null && ! $isInclude && ! self::isPharCall()) { trigger_error("fopen({$targetFile}): Failed to open stream: No such file or directory", E_USER_WARNING); } @@ -408,7 +429,15 @@ public function stream_read(int $count): string $res = fread($this->handle, $count); - return $res !== false ? $res : ''; + if ($res === false || $res === '') { + if (feof($this->handle)) { + $this->eof = true; + } + + return ''; + } + + return $res; } public function stream_write(string $data): int @@ -471,7 +500,7 @@ public function stream_eof(): bool return true; } - return feof($this->handle); + return $this->eof; } /** @@ -502,7 +531,12 @@ public function stream_seek(int $offset, int $whence = SEEK_SET): bool return false; } - return @fseek($this->handle, $offset, $whence) === 0; + $res = @fseek($this->handle, $offset, $whence) === 0; + if ($res) { + $this->eof = false; + } + + return $res; } public function stream_set_option(int $option, int $arg1, int $arg2): bool @@ -515,6 +549,7 @@ public function stream_close(): void if ($this->handle !== null) { fclose($this->handle); $this->handle = null; + $this->eof = false; } } diff --git a/src/Internal/Reporting/ViolationCollector.php b/src/Internal/Reporting/ViolationCollector.php new file mode 100644 index 00000000..0776e03d --- /dev/null +++ b/src/Internal/Reporting/ViolationCollector.php @@ -0,0 +1,378 @@ + + */ + private static array $violations = []; + + /** + * In-memory set of emitted warning hashes to prevent log spamming in loops. + * + * @var array + */ + private static array $warnedHashes = []; + + private static bool $shutdownRegistered = false; + + /** + * Resets in-memory state. Useful for test isolation. + */ + public static function reset(): void + { + self::$violations = []; + self::$warnedHashes = []; + } + + /** + * Handles a validation failure according to the configured on_violation mode ('throw', 'warn', 'report'). + * + * @param 'parameter'|'return'|'property'|'variable'|'param-out'|'self-out'|'callback'|'yield'|'send' $kind + */ + public static function handle( + ErrorMessage $error, + string $kind, + mixed $passThroughValue, + ?string $file = null, + ?int $line = null, + ?string $function = null, + ?string $target = null + ): mixed { + $mode = Config::getOnViolation(); + + if ($mode === 'throw') { + return $error; + } + + $message = $error->getMessage(); + [$resolvedFile, $resolvedLine] = self::resolveCallSite($file, $line); + $parsed = self::parseMetadata($message, $kind, $function, $target); + + $record = new ViolationRecord( + file: self::normalizeRelativePath($resolvedFile), + line: $resolvedLine, + function: $parsed['function'], + kind: $kind, + target: $parsed['target'], + expected: $parsed['expected'], + given: $parsed['given'], + message: $message + ); + + $hash = $record->getHash(); + + if ($mode === 'warn') { + self::emitWarningOnce($hash, $message); + + return $passThroughValue; + } + + if ($mode === 'report') { + self::recordViolation($hash, $record); + + return $passThroughValue; + } + + return $error; + } + + /** + * Emits PHP E_USER_WARNING once per unique violation hash. + */ + private static function emitWarningOnce(string $hash, string $message): void + { + if (isset(self::$warnedHashes[$hash])) { + return; + } + + self::$warnedHashes[$hash] = true; + trigger_error("[TypePHP Violation] {$message}", E_USER_WARNING); + } + + /** + * Records violation and ensures shutdown exporter is registered. + */ + private static function recordViolation(string $hash, ViolationRecord $record): void + { + self::$violations[$hash] = $record; + + if (! self::$shutdownRegistered) { + self::registerShutdownHandler(); + self::$shutdownRegistered = true; + } + } + + /** + * Returns all in-memory violations recorded in the current process. + * + * @return list + */ + public static function getViolations(): array + { + return array_values(self::$violations); + } + + /** + * Clears all in-memory violations. + */ + public static function clear(): void + { + self::$violations = []; + self::$warnedHashes = []; + } + + /** + * Normalizes absolute system paths into clean project-relative paths with forward slashes. + */ + public static function normalizeRelativePath(string $path): string + { + if ($path === '') { + return 'unknown'; + } + + $normPath = str_replace('\\', '/', $path); + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + + if (str_starts_with($normPath, $projectRoot . '/')) { + return substr($normPath, \strlen($projectRoot) + 1); + } + + return $normPath; + } + + /** + * Registers shutdown hook to write worker shards or master report and optionally exit with failure. + */ + private static function registerShutdownHandler(): void + { + register_shutdown_function(static function (): void { + if (Config::getOnViolation() !== 'report') { + return; + } + + $violationCount = \count(self::$violations); + if ($violationCount === 0) { + return; + } + + $reportFile = Config::getReportFile(); + $testToken = getenv('TEST_TOKEN'); + $pestWorkerId = getenv('PEST_PARALLEL_WORKER_ID'); + $isParaTest = getenv('PARATEST') !== false; + + $isParallelWorker = ($testToken !== false && $testToken !== '') + || ($pestWorkerId !== false && $pestWorkerId !== '') + || $isParaTest; + + if ($reportFile !== null) { + if ($isParallelWorker) { + self::writeWorkerShard($reportFile); + } else { + self::exportReport($reportFile); + } + } + + if (Config::isFailOnReportEnabled()) { + @fwrite(STDERR, "\n[TypePHP] {$violationCount} contract violation(s) recorded in report. Exiting with status 1.\n"); + exit(1); + } + }); + } + + /** + * Writes process-isolated shard during parallel testing without acquiring locks. + */ + public static function writeWorkerShard(string $reportFile): void + { + if (self::$violations === []) { + return; + } + + $reportDir = \dirname($reportFile); + $shardDir = $reportDir . '/.typephp-shards'; + + if (! is_dir($shardDir)) { + @mkdir($shardDir, 0777, true); + } + + $testToken = getenv('TEST_TOKEN'); + $pestWorkerId = getenv('PEST_PARALLEL_WORKER_ID'); + + $workerId = ($testToken !== false && $testToken !== '') + ? $testToken + : (($pestWorkerId !== false && $pestWorkerId !== '') ? $pestWorkerId : (string) getmypid()); + + $safeToken = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $workerId); + $shardFile = $shardDir . '/shard_' . $safeToken . '_' . bin2hex(random_bytes(4)) . '.json'; + $content = json_encode(array_values(self::$violations), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($content !== false) { + @file_put_contents($shardFile, $content); + } + } + + /** + * Merges in-memory violations with any worker shards and atomically exports the master report JSON. + */ + public static function exportReport(?string $filePath = null): ?string + { + $targetFile = $filePath !== null ? $filePath : Config::getReportFile(); + if ($targetFile === null) { + return null; + } + + $allViolations = self::$violations; + $reportDir = \dirname($targetFile); + $shardDir = $reportDir . '/.typephp-shards'; + + if (is_dir($shardDir)) { + $shardFiles = glob($shardDir . '/shard_*.json'); + if ($shardFiles !== false) { + foreach ($shardFiles as $sFile) { + $raw = @file_get_contents($sFile); + if ($raw !== false) { + /** @var list>|null $decoded */ + $decoded = json_decode($raw, true); + if (\is_array($decoded)) { + foreach ($decoded as $item) { + if (\is_array($item)) { + $record = ViolationRecord::fromArray($item); + if ($record !== null) { + $allViolations[$record->getHash()] = $record; + } + } + } + } + } + @unlink($sFile); + } + } + @rmdir($shardDir); + } + + if (! is_dir($reportDir)) { + @mkdir($reportDir, 0777, true); + } + + $violationsList = array_values($allViolations); + $filesAffected = []; + foreach ($violationsList as $v) { + $filesAffected[$v->file] = true; + } + + $document = [ + 'version' => '1.0', + 'generated_at' => gmdate('c'), + 'summary' => [ + 'total_violations' => \count($violationsList), + 'files_affected' => \count($filesAffected), + ], + 'violations' => $violationsList, + ]; + + $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json !== false) { + @file_put_contents($targetFile, $json); + + return $targetFile; + } + + return null; + } + + /** + * @return array{0: string, 1: int} + */ + private static function resolveCallSite(?string $explicitFile, ?int $explicitLine): array + { + if ($explicitFile !== null && $explicitFile !== '' && $explicitLine !== null && $explicitLine > 0) { + return [$explicitFile, $explicitLine]; + } + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); + foreach ($trace as $frame) { + if (! isset($frame['file']) || ! \is_string($frame['file'])) { + continue; + } + + $normalizedFile = str_replace('\\', '/', $frame['file']); + if (str_contains($normalizedFile, 'src/Internal/') || str_contains($normalizedFile, 'bin/typephp')) { + continue; + } + + $l = isset($frame['line']) && \is_int($frame['line']) ? $frame['line'] : 1; + + return [$frame['file'], $l]; + } + + return [$explicitFile !== null ? $explicitFile : 'unknown', $explicitLine !== null ? $explicitLine : 1]; + } + + /** + * @return array{function: string, target: string, expected: string, given: string} + */ + private static function parseMetadata(string $message, string $kind, ?string $explicitFunction, ?string $explicitTarget): array + { + $function = $explicitFunction !== null ? $explicitFunction : ''; + $target = $explicitTarget !== null ? $explicitTarget : ''; + $expected = ''; + $given = ''; + + if ($function === '') { + if (preg_match('/^([a-zA-Z0-9_\\\\]+(?:::[a-zA-Z0-9_\x80-\xff]+)?)\(\):/', $message, $m) === 1) { + $function = trim($m[1]); + } elseif (preg_match('/^Property\s+([a-zA-Z0-9_\\\\]+(?:::\$[a-zA-Z0-9_\x80-\xff]+)?)/', $message, $m) === 1) { + $function = trim($m[1]); + } elseif (preg_match('/^([^:]+):/', $message, $m) === 1) { + $function = trim($m[1]); + } + } + + if (preg_match('/(?:Argument|Parameter)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { + $target = $m[1]; + } elseif (preg_match('/(?:Property)\s+[^$]*(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { + $target = $m[1]; + } elseif (preg_match('/(?:Variable)\s+(\$[a-zA-Z0-9_]+)/', $message, $m) === 1 && $target === '') { + $target = $m[1]; + } elseif (str_contains($message, 'Return value') && $target === '') { + $target = 'return'; + } + + if (preg_match('/must be of type (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { + $expected = trim($m[1]); + $given = trim($m[2]); + } elseif (preg_match('/must be (.+?),\s*(.+?)\s*(?:given|returned)$/', $message, $m) === 1) { + $expected = trim($m[1]); + $given = trim($m[2]); + } elseif (preg_match('/is missing required (?:key|property)\s*\'([^\']+)\'/', $message, $m) === 1) { + $expected = "required '{$m[1]}'"; + $given = 'missing'; + } elseif (preg_match('/contains unsealed unexpected key\s*\'([^\']+)\'/', $message, $m) === 1) { + $expected = 'sealed shape'; + $given = "unexpected key '{$m[1]}'"; + } elseif (preg_match('/expects (.+?),\s*but\s*(.+?)\s*was\s*(?:given|returned)/', $message, $m) === 1) { + $expected = trim($m[1]); + $given = trim($m[2]); + } + + return [ + 'function' => $function !== '' ? $function : 'unknown', + 'target' => $target !== '' ? $target : $kind, + 'expected' => $expected !== '' ? $expected : 'valid type', + 'given' => $given !== '' ? $given : 'invalid value', + ]; + } +} diff --git a/src/Internal/Reporting/ViolationRecord.php b/src/Internal/Reporting/ViolationRecord.php new file mode 100644 index 00000000..00ccd2d7 --- /dev/null +++ b/src/Internal/Reporting/ViolationRecord.php @@ -0,0 +1,99 @@ +file}:{$this->line}:{$this->function}:{$this->kind}:{$this->target}:{$this->expected}:{$this->given}"); + } + + /** + * @return array{ + * file: string, + * line: int, + * function: string, + * kind: string, + * target: string, + * expected: string, + * given: string, + * message: string + * } + */ + public function toArray(): array + { + return [ + 'file' => $this->file, + 'line' => $this->line, + 'function' => $this->function, + 'kind' => $this->kind, + 'target' => $this->target, + 'expected' => $this->expected, + 'given' => $this->given, + 'message' => $this->message, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): ?self + { + if ( + ! isset($data['file'], $data['line'], $data['message']) + || ! \is_string($data['file']) + || ! is_numeric($data['line']) + || ! \is_string($data['message']) + ) { + return null; + } + + /** @var 'parameter'|'return'|'property'|'variable'|'param-out'|'self-out'|'callback'|'yield'|'send' $kind */ + $kind = isset($data['kind']) && \is_string($data['kind']) ? $data['kind'] : 'parameter'; + + return new self( + file: $data['file'], + line: (int) $data['line'], + function: isset($data['function']) && \is_string($data['function']) ? $data['function'] : 'unknown', + kind: $kind, + target: isset($data['target']) && \is_string($data['target']) ? $data['target'] : 'unknown', + expected: isset($data['expected']) && \is_string($data['expected']) ? $data['expected'] : 'valid type', + given: isset($data['given']) && \is_string($data['given']) ? $data['given'] : 'invalid value', + message: $data['message'] + ); + } +} diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index e7125634..556c433b 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -65,6 +65,8 @@ final class SpecialTypeResolver 'static' => true, 'parent' => true, '$this' => true, + 'static-closure' => true, + 'static-pure-closure' => true, 'positive-int' => true, 'negative-int' => true, 'non-positive-int' => true, diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 069fee61..ddafe3e1 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -9,6 +9,7 @@ use TypePHP\Internal\Ast\ScopeCleaner; use TypePHP\Internal\Checker\GeneratorChecker; use TypePHP\Internal\Checker\InlineChecker; +use TypePHP\Internal\Checker\MemberAssignChecker; use TypePHP\Internal\Checker\ParamChecker; use TypePHP\Internal\Checker\ParamOutChecker; use TypePHP\Internal\Checker\ReturnChecker; @@ -17,6 +18,7 @@ use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; +use TypePHP\Internal\Reporting\ViolationCollector; use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\IgnoreManager; @@ -54,6 +56,7 @@ public static function reset(): void CallerBoundaryResolver::reset(); ParamOutChecker::reset(); SelfOutChecker::reset(); + ViolationCollector::reset(); } /** @@ -64,6 +67,49 @@ public static function isEnabled(): bool return Config::isEnabled(); } + /** + * Evaluates member mutation assignments ($arr[$key] = $val or $obj->prop = $val) + * against the root variable's type contract atomically before writing. + * + * @param list $chain + */ + public static function checkMemberAssign( + mixed $root, + array $chain, + mixed $value, + string $typeString, + string $varName, + string $file, + ?string $caller = null, + mixed $thisOrClass = null + ): mixed { + if (! Config::isEnabled()) { + return $value; + } + + $res = MemberAssignChecker::check( + $root, + $chain, + $value, + $typeString, + $varName, + $file, + self::getRegistry(), + $caller, + $thisOrClass + ); + + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($caller ?? '')) { + return $value; + } + + return ViolationCollector::handle($res, 'variable', $value, $file, null, $caller, '$' . $varName); + } + + return $res; + } + /** * Pre-binds generic template state on a class before its constructor executes. * @@ -129,11 +175,18 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t $err = TemplateManager::bindInstanceFromNode($instance, $typeNode, $context, $forceBind); - if ($err !== null && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($context))) { - return null; + if ($err !== null) { + if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($context)) { + return null; + } + + /** @var ErrorMessage|null $handled */ + $handled = ViolationCollector::handle($err, 'parameter', null, function: $context); + + return $handled; } - return $err; + return null; } /** @@ -161,8 +214,12 @@ public static function checkVariable( $thisOrClass ); - if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($caller ?? ''))) { - return $value; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($caller !== null ? $caller : '')) { + return $value; + } + + return ViolationCollector::handle($res, 'variable', $value, $file, null, $caller, '$' . $varName); } return $res; @@ -184,8 +241,12 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string $res = InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); - if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($className . '::$' . $propName))) { - return $value; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($className . '::$' . $propName)) { + return $value; + } + + return ViolationCollector::handle($res, 'property', $value, $file, null, $className . '::$' . $propName, '$' . $propName); } return $res; @@ -229,6 +290,9 @@ public static function setupScope(string $function, array $vars, object|string|n $contract = DocblockParser::parse($effectiveFunction); if (! $isMagicCall && ($contract['allParamsUnconstrained'] ?? false)) { + ParamChecker::$noParamContractCache[$effectiveFunction] = true; + ParamChecker::$noParamContractCache[$function] = true; + return null; } @@ -248,7 +312,10 @@ public static function setupScope(string $function, array $vars, object|string|n TemplateManager::popCallFrame($effectiveFunction); - return $err; + $handled = ViolationCollector::handle($err, 'parameter', null, function: $effectiveFunction); + if ($handled instanceof ErrorMessage) { + return $handled; + } } $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null; @@ -285,11 +352,18 @@ public static function checkParams(string $function, array $vars, object|string| $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); - if ($err !== null && IgnoreManager::isCallerIgnored()) { - return null; + if ($err !== null) { + if (IgnoreManager::isCallerIgnored()) { + return null; + } + + /** @var ErrorMessage|null $handled */ + $handled = ViolationCollector::handle($err, 'parameter', null, function: $effectiveFunction); + + return $handled; } - return $err; + return null; } /** @@ -310,11 +384,18 @@ public static function checkParamOut(string $function, string $paramName, mixed $res = ParamOutChecker::checkParamOut($function, $paramName, $value, $thisOrClass, self::getRegistry(), $effectiveFunction); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { - return null; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored()) { + return null; + } + + /** @var ErrorMessage|null $handled */ + $handled = ViolationCollector::handle($res, 'param-out', null, function: $effectiveFunction, target: '$' . $paramName); + + return $handled; } - return $res; + return null; } /** @@ -348,7 +429,7 @@ public static function checkSelfOut(string $function, object $thisObj, ?array $v return; } - $vars ??= []; + $vars = $vars !== null ? $vars : []; SelfOutChecker::checkSelfOut($function, $thisObj, $vars, self::getRegistry(), $effectiveFunction); } @@ -385,7 +466,7 @@ public static function checkReturn(string $function, mixed $value, object|string return $value; } - $vars ??= []; + $vars = $vars !== null ? $vars : []; $res = ReturnChecker::checkReturn( $function, @@ -398,8 +479,12 @@ public static function checkReturn(string $function, mixed $value, object|string $contract ); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { - return $value; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored()) { + return $value; + } + + return ViolationCollector::handle($res, 'return', $value, function: $effectiveFunction, target: 'return'); } return $res; @@ -423,8 +508,12 @@ public static function checkSend(string $function, mixed $sendValue, object|stri $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { - return $sendValue; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored()) { + return $sendValue; + } + + return ViolationCollector::handle($res, 'send', $sendValue, function: $effectiveFunction); } return $res; @@ -448,8 +537,12 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { - return $value; + if ($res instanceof ErrorMessage) { + if (IgnoreManager::isCallerIgnored()) { + return $value; + } + + return ViolationCollector::handle($res, 'yield', $value, function: $effectiveFunction); } return $res; diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index e237116c..a090758d 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -11,6 +11,7 @@ use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Io\CacheManager; use TypePHP\Internal\Io\StreamWrapper; +use TypePHP\Internal\Reporting\ViolationCollector; use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; @@ -40,6 +41,16 @@ final class Config private static bool $enabled = true; + private static bool $autoBoot = true; + + private static string $onViolation = 'throw'; + + private static ?string $reportFile = null; + + private static bool $failOnReport = false; + + private static bool $redactValues = false; + private static bool $params = true; private static bool $returns = true; @@ -91,6 +102,91 @@ public static function isEnabled(): bool return self::$enabled; } + public static function isAutoBootEnabled(): bool + { + $envAutoBoot = getenv('TYPEPHP_AUTO_BOOT'); + if ($envAutoBoot !== false && trim((string) $envAutoBoot) !== '') { + return filter_var($envAutoBoot, FILTER_VALIDATE_BOOLEAN); + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$autoBoot; + } + + public static function getOnViolation(): string + { + $envMode = getenv('TYPEPHP_ON_VIOLATION'); + if ($envMode !== false && trim((string) $envMode) !== '') { + $normalized = strtolower(trim((string) $envMode)); + if ($normalized === 'warning') { + return 'warn'; + } + + return $normalized; + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$onViolation; + } + + public static function getReportFile(): ?string + { + $envFile = getenv('TYPEPHP_REPORT_FILE'); + if ($envFile !== false && trim((string) $envFile) !== '') { + return trim((string) $envFile); + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$reportFile; + } + + public static function isFailOnReportEnabled(): bool + { + $envFail = getenv('TYPEPHP_FAIL_ON_REPORT'); + if ($envFail !== false && trim((string) $envFail) !== '') { + return filter_var($envFail, FILTER_VALIDATE_BOOLEAN); + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$failOnReport; + } + + public static function isRedactValuesEnabled(): bool + { + $envRedact = getenv('TYPEPHP_REDACT_VALUES'); + if ($envRedact !== false && trim((string) $envRedact) !== '') { + return filter_var($envRedact, FILTER_VALIDATE_BOOLEAN); + } + + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$redactValues; + } + + public static function isReportMode(): bool + { + return self::getOnViolation() === 'report'; + } + + public static function isWarnMode(): bool + { + return self::getOnViolation() === 'warn'; + } + public static function getIgnoreTraceDepth(): int { if (self::$cachedConfig === null) { @@ -358,7 +454,7 @@ public static function getProjectRoot(?string $startingDir = null): string } /** - * Loads and caches global configuration from 'typephp.php', explicitly registered extensions, and base defaults. + * Loads and caches global configuration from 'typephp.php', 'composer.json' extra, extensions, and base defaults. * * @return array */ @@ -370,6 +466,11 @@ public static function get(): array $defaultConfig = [ 'enabled' => true, + 'auto_boot' => true, + 'on_violation' => 'throw', + 'report_file' => null, + 'fail_on_report' => false, + 'redact_values' => false, 'params' => true, 'returns' => true, 'params_out' => true, @@ -414,6 +515,37 @@ public static function get(): array } } + $composerJsonFile = $projectRoot . '/composer.json'; + if (file_exists($composerJsonFile)) { + $content = @file_get_contents($composerJsonFile); + if ($content !== false && (str_contains($content, 'typephp') || str_contains($content, 'auto-boot') || str_contains($content, 'on-violation') || str_contains($content, 'fail-on-report') || str_contains($content, 'redact-values'))) { + $data = json_decode($content, true); + if ( + \is_array($data) + && isset($data['extra']) + && \is_array($data['extra']) + && isset($data['extra']['typephp']) + && \is_array($data['extra']['typephp']) + ) { + if (! isset($userConfig['auto_boot']) && isset($data['extra']['typephp']['auto-boot'])) { + $userConfig['auto_boot'] = filter_var($data['extra']['typephp']['auto-boot'], FILTER_VALIDATE_BOOLEAN); + } + if (! isset($userConfig['on_violation']) && isset($data['extra']['typephp']['on-violation']) && \is_string($data['extra']['typephp']['on-violation'])) { + $userConfig['on_violation'] = $data['extra']['typephp']['on-violation']; + } + if (! isset($userConfig['report_file']) && isset($data['extra']['typephp']['report-file']) && \is_string($data['extra']['typephp']['report-file'])) { + $userConfig['report_file'] = $data['extra']['typephp']['report-file']; + } + if (! isset($userConfig['fail_on_report']) && isset($data['extra']['typephp']['fail-on-report'])) { + $userConfig['fail_on_report'] = filter_var($data['extra']['typephp']['fail-on-report'], FILTER_VALIDATE_BOOLEAN); + } + if (! isset($userConfig['redact_values']) && isset($data['extra']['typephp']['redact-values'])) { + $userConfig['redact_values'] = filter_var($data['extra']['typephp']['redact-values'], FILTER_VALIDATE_BOOLEAN); + } + } + } + } + /** @var array> $configuredExtensions */ $configuredExtensions = \is_array($userConfig['extensions'] ?? null) ? $userConfig['extensions'] @@ -477,6 +609,7 @@ public static function set(array $config): void CacheManager::reset(); IgnoreManager::reset(); RuntimeTypeChecker::reset(); + ViolationCollector::reset(); } /** @@ -525,6 +658,11 @@ public static function reset(): void self::$cachedConfig = null; self::$projectRoot = null; self::$enabled = true; + self::$autoBoot = true; + self::$onViolation = 'throw'; + self::$reportFile = null; + self::$failOnReport = false; + self::$redactValues = false; self::$params = true; self::$returns = true; self::$selfOut = true; @@ -561,6 +699,7 @@ public static function reset(): void CacheManager::reset(); IgnoreManager::reset(); RuntimeTypeChecker::reset(); + ViolationCollector::reset(); } /** @@ -571,6 +710,21 @@ public static function reset(): void private static function syncFlags(array $config): void { self::$enabled = (bool) ($config['enabled'] ?? true); + self::$autoBoot = (bool) ($config['auto_boot'] ?? true); + self::$onViolation = \is_string($config['on_violation'] ?? null) && trim($config['on_violation']) !== '' + ? strtolower(trim($config['on_violation'])) + : 'throw'; + if (self::$onViolation === 'warning') { + self::$onViolation = 'warn'; + } + + self::$reportFile = \is_string($config['report_file'] ?? null) && trim($config['report_file']) !== '' + ? trim($config['report_file']) + : null; + + self::$failOnReport = (bool) ($config['fail_on_report'] ?? false); + self::$redactValues = (bool) ($config['redact_values'] ?? false); + self::$params = (bool) ($config['params'] ?? true); self::$paramsOut = (bool) ($config['params_out'] ?? true); self::$selfOut = (bool) ($config['self_out'] ?? true); diff --git a/src/Internal/Util/StructureMutator.php b/src/Internal/Util/StructureMutator.php new file mode 100644 index 00000000..327b5ebc --- /dev/null +++ b/src/Internal/Util/StructureMutator.php @@ -0,0 +1,88 @@ + $chain + */ + public static function mutateCopy(mixed $root, array $chain, mixed $value): mixed + { + $copy = \is_object($root) ? clone $root : $root; + + $current = &$copy; + $count = \count($chain); + + for ($i = 0; $i < $count; $i++) { + [$kind, $key] = $chain[$i]; + $isLast = ($i === $count - 1); + + if ($kind === 'dim') { + if ($key === null) { + if (! \is_array($current)) { + $current = []; + } + if ($isLast) { + $current[] = $value; + } else { + $current[] = []; + $keys = array_keys($current); + $lastKey = end($keys); + $current = &$current[$lastKey]; + } + } else { + $arrayKey = \is_int($key) ? $key : (\is_string($key) ? $key : ''); + if (! \is_array($current)) { + $current = []; + } + + if ($isLast) { + $current[$arrayKey] = $value; + } else { + if (! isset($current[$arrayKey]) || (! \is_array($current[$arrayKey]) && ! \is_object($current[$arrayKey]))) { + $current[$arrayKey] = []; + } elseif (\is_object($current[$arrayKey])) { + $current[$arrayKey] = clone $current[$arrayKey]; + } + $current = &$current[$arrayKey]; + } + } + } elseif ($kind === 'prop') { + $propName = \is_string($key) ? $key : (\is_int($key) ? (string) $key : ''); + if (! \is_object($current)) { + $current = new stdClass(); + } + + if ($isLast) { + // @phpstan-ignore property.dynamicName + $current->$propName = $value; + } else { + // @phpstan-ignore property.dynamicName + $propVal = $current->$propName ?? null; + if (! \is_object($propVal) && ! \is_array($propVal)) { + // @phpstan-ignore property.dynamicName + $current->$propName = new stdClass(); + } elseif (\is_object($propVal)) { + // @phpstan-ignore property.dynamicName + $current->$propName = clone $propVal; + } + // @phpstan-ignore property.dynamicName + $current = &$current->$propName; + } + } + } + unset($current); + + return $copy; + } +} diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 8188b54f..32ccd7b6 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -742,14 +742,16 @@ private function validateIntRange(mixed $value, GenericTypeNode $node, string $c $minVal = $minNode !== null ? $this->resolveIntBound($minNode) : null; $maxVal = $maxNode !== null ? $this->resolveIntBound($maxNode) : null; + $redact = $isSensitive || Config::isRedactValuesEnabled(); + if ($minVal !== null && $value < $minVal) { - $valDisplay = $isSensitive ? 'int given' : "$value given"; + $valDisplay = $redact ? 'int given' : "$value given"; return ErrorFactory::createError($context . " must be >= $minVal, $valDisplay"); } if ($maxVal !== null && $value > $maxVal) { - $valDisplay = $isSensitive ? 'int given' : "$value given"; + $valDisplay = $redact ? 'int given' : "$value given"; return ErrorFactory::createError($context . " must be <= $maxVal, $valDisplay"); } @@ -773,6 +775,8 @@ private function validateClassString(mixed $value, GenericTypeNode $node, string private function validateClassStringBound(string $value, TypeNode $targetNode, string $context, bool $isSensitive = false): ?ErrorMessage { + $redact = $isSensitive || Config::isRedactValuesEnabled(); + if ($targetNode instanceof IdentifierTypeNode) { $targetName = $targetNode->name; $lower = strtolower($targetName); @@ -782,7 +786,7 @@ private function validateClassStringBound(string $value, TypeNode $targetNode, s if (class_exists($targetName) || interface_exists($targetName) || trait_exists($targetName) || enum_exists($targetName)) { if (! is_a($value, $targetName, allow_string: true)) { - $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + $valDisplay = $redact ? 'string given' : "'$value' given"; return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", $valDisplay"); } @@ -798,7 +802,7 @@ private function validateClassStringBound(string $value, TypeNode $targetNode, s } } - $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + $valDisplay = $redact ? 'string given' : "'$value' given"; return ErrorFactory::createError($context . ' must be a class-string of ' . (string) $targetNode . ", $valDisplay"); } diff --git a/src/Internal/Validator/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index 8ea6215e..9dc16c9a 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -33,6 +33,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), 'object', 'self', 'static', 'parent', '$this' => \is_object($value), 'callable', 'pure-callable' => CallableWrapper::isCallable($value), + 'static-closure', 'static-pure-closure' => $value instanceof \Closure && (new \ReflectionFunction($value))->getClosureThis() === null, 'iterable' => is_iterable($value), 'resource' => \is_resource($value), 'null' => $value === null, @@ -78,6 +79,10 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali }; if (! $ok) { + if (($lower === 'static-closure' || $lower === 'static-pure-closure') && $value instanceof \Closure && (new \ReflectionFunction($value))->getClosureThis() !== null) { + return ErrorFactory::createError($context . ' must be a static Closure (not bound to $this)'); + } + return ErrorFactory::createError($context . ' must be of type ' . $identifierNode->name . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } diff --git a/src/Internal/Validator/ObjectShapeValidator.php b/src/Internal/Validator/ObjectShapeValidator.php index 9e810589..76a78d3f 100644 --- a/src/Internal/Validator/ObjectShapeValidator.php +++ b/src/Internal/Validator/ObjectShapeValidator.php @@ -48,11 +48,15 @@ public function validate( $shapeNode = $node; if ($value instanceof \stdClass) { + $knownProps = []; + foreach ($shapeNode->items as $item) { $propName = $item->keyName instanceof IdentifierTypeNode ? $item->keyName->name : (string) $item->keyName; + $knownProps[$propName] = true; + if (! isset($value->$propName) && ! property_exists($value, $propName)) { if (! $item->optional) { return ErrorFactory::createError($context . " is missing required property '$propName'"); @@ -69,6 +73,13 @@ public function validate( } } + $actualProps = get_object_vars($value); + foreach ($actualProps as $propName => $_) { + if (! isset($knownProps[$propName])) { + return ErrorFactory::createError($context . " contains unexpected property '{$propName}'"); + } + } + return null; } diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index 721573dd..dfef09bc 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -15,10 +15,12 @@ use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\Checker\ConditionalChecker; use TypePHP\Internal\Diagnostic\ErrorFactory; +use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; +use TypePHP\Internal\Reporting\ViolationCollector; use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -292,7 +294,10 @@ private static function validateCallbackReturn( $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); if ($err !== null) { if (! CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'callback', $result); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } } @@ -314,7 +319,12 @@ private static function enforceClosureConstraints(string $identifierName, mixed return; } - throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given')); + $err = new ErrorMessage($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given'); + $handled = ViolationCollector::handle($err, 'callback', null); + + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } if (str_contains($identifierName, 'static') && $callable instanceof Closure) { @@ -324,7 +334,12 @@ private static function enforceClosureConstraints(string $identifierName, mixed return; } - throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be a static Closure (not bound to $this)')); + $err = new ErrorMessage($prefix . ' must be a static Closure (not bound to $this)'); + $handled = ViolationCollector::handle($err, 'callback', null); + + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } } @@ -345,7 +360,7 @@ private static function validateCallbackArguments( $argCount = \count($argValues); foreach ($typeNode->parameters as $index => $paramNode) { - $rawParamName = ltrim($paramNode->parameterName ?? '', '$'); + $rawParamName = ltrim($paramNode->parameterName !== null ? $paramNode->parameterName : '', '$'); if ($paramNode->isVariadic) { for ($vIdx = $index; $vIdx < $argCount; $vIdx++) { @@ -355,7 +370,10 @@ private static function validateCallbackArguments( continue; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'callback', null); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } @@ -381,7 +399,10 @@ private static function validateCallbackArguments( continue; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'callback', null); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } } @@ -407,7 +428,7 @@ private static function validateCallbackByRefMutations( continue; } - $rawParamName = ltrim($paramNode->parameterName ?? '', '$'); + $rawParamName = ltrim($paramNode->parameterName !== null ? $paramNode->parameterName : '', '$'); if ($paramNode->isVariadic) { for ($vIdx = $index; $vIdx < $argCount; $vIdx++) { @@ -417,7 +438,10 @@ private static function validateCallbackByRefMutations( continue; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'callback', null); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } @@ -443,7 +467,10 @@ private static function validateCallbackByRefMutations( continue; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'callback', null); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } } diff --git a/src/Internal/Wrapper/IterableWrapper.php b/src/Internal/Wrapper/IterableWrapper.php index aa2f239b..c42c209e 100644 --- a/src/Internal/Wrapper/IterableWrapper.php +++ b/src/Internal/Wrapper/IterableWrapper.php @@ -11,9 +11,11 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Exception\TypeError as TypePHPTypeError; use TypePHP\Internal\Diagnostic\ErrorFactory; +use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; +use TypePHP\Internal\Reporting\ViolationCollector; use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -145,7 +147,10 @@ private static function createValidationCallback( return; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'yield', null, function: $function); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } @@ -160,7 +165,10 @@ private static function createValidationCallback( return; } - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + $handled = ViolationCollector::handle($err, 'yield', null, function: $function); + if ($handled instanceof ErrorMessage) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } }; diff --git a/src/TypePHP.php b/src/TypePHP.php index 6111698e..d7919d6e 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -6,6 +6,8 @@ use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Io\StreamWrapper; +use TypePHP\Internal\Reporting\ViolationCollector; +use TypePHP\Internal\Reporting\ViolationRecord; use TypePHP\Internal\Util\Config; final class TypePHP @@ -18,6 +20,32 @@ public static function boot(): void StreamWrapper::register(Config::get()); } + /** + * Returns all type contract violations recorded during execution in report mode. + * + * @return list + */ + public static function getViolations(): array + { + return ViolationCollector::getViolations(); + } + + /** + * Clears all recorded type contract violations. + */ + public static function clearViolations(): void + { + ViolationCollector::clear(); + } + + /** + * Consolidates worker shards and exports the master JSON report file. + */ + public static function exportReport(?string $filePath = null): ?string + { + return ViolationCollector::exportReport($filePath); + } + /** * Returns the bound generic type for a template parameter on an object instance (Reified Generics). * If no template name is specified on a single-template class, returns the bound type automatically. diff --git a/src/bootstrap.php b/src/bootstrap.php index 93e0060d..dc3cd26f 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -4,6 +4,8 @@ namespace TypePHP; +use TypePHP\Internal\Util\Config; + if (class_exists(TypePHP::class) && ! \defined('TYPEPHP_BOOTED')) { \define('TYPEPHP_BOOTED', true); @@ -62,7 +64,7 @@ } } - if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling && ! $isParallelParent) { + if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling && ! $isParallelParent && Config::isAutoBootEnabled()) { TypePHP::boot(); } } diff --git a/tests/Feature/ViolationReportingTest.php b/tests/Feature/ViolationReportingTest.php new file mode 100644 index 00000000..51c80055 --- /dev/null +++ b/tests/Feature/ViolationReportingTest.php @@ -0,0 +1,519 @@ += 80400) { + require_once __DIR__ . '/../Fixtures/PropertyHooks/ReportingHookedFixture.php'; +} + +class ReportingModelFixture +{ + /** + * @var positive-int + */ + public int $score = 10; + + /** + * @var non-empty-string + */ + public static string $category = 'General'; + + /** + * @param positive-int $id + * @param non-empty-string $name + * + * @return non-empty-string + */ + public function formatUser(int $id, string $name): string + { + return "{$name}_{$id}"; + } + + /** + * @return positive-int + */ + public function invalidReturnMethod(): int + { + return -999; + } +} + +/** + * @param positive-int $id + * @param non-empty-string $label + */ +function testReportingParamFunc(int $id, string $label): string +{ + return "{$label}: {$id}"; +} + +/** + * @return list + */ +function testReportingReturnFunc(bool $invalid = false): array +{ + if ($invalid) { + return [10, -5, 30]; + } + + return [10, 20, 30]; +} + +/** + * @param mixed &$code + * + * @param-out positive-int $code + */ +function testReportingParamOutFunc(mixed &$code): void +{ + $code = -50; +} + +/** + * @param callable(positive-int): non-empty-string $callback + */ +function testReportingCallableFunc(callable $callback): string +{ + return $callback(-10); +} + +/** + * @return Generator + */ +function testReportingGeneratorFunc(): Generator +{ + yield 'a' => 10; + yield '' => -20; + yield 'c' => 30; +} + +/** + * @param array{account: array{profile: array{score: positive-int}}} $payload + */ +function testReportingDeepShapeFunc(array $payload): int +{ + return $payload['account']['profile']['score']; +} + +/** + * @param non-empty-string $apiKey + */ +function testReportingSensitiveParamFunc( + #[SensitiveParameter] + string $apiKey +): string { + return 'authenticated'; +} + +describe('Violation Reporting & Audit Mode (on_violation => report)', function () { + beforeEach(function () { + putenv('TYPEPHP_ON_VIOLATION='); + putenv('TYPEPHP_REPORT_FILE='); + putenv('TYPEPHP_FAIL_ON_REPORT='); + unset( + $_ENV['TYPEPHP_ON_VIOLATION'], + $_SERVER['TYPEPHP_ON_VIOLATION'], + $_ENV['TYPEPHP_REPORT_FILE'], + $_SERVER['TYPEPHP_REPORT_FILE'], + $_ENV['TYPEPHP_FAIL_ON_REPORT'], + $_SERVER['TYPEPHP_FAIL_ON_REPORT'] + ); + Config::reset(); + TypePHP::clearViolations(); + }); + + afterEach(function () { + putenv('TYPEPHP_ON_VIOLATION='); + putenv('TYPEPHP_REPORT_FILE='); + putenv('TYPEPHP_FAIL_ON_REPORT='); + unset( + $_ENV['TYPEPHP_ON_VIOLATION'], + $_SERVER['TYPEPHP_ON_VIOLATION'], + $_ENV['TYPEPHP_REPORT_FILE'], + $_SERVER['TYPEPHP_REPORT_FILE'], + $_ENV['TYPEPHP_FAIL_ON_REPORT'], + $_SERVER['TYPEPHP_FAIL_ON_REPORT'] + ); + TypePHP::clearViolations(); + Config::reset(); + }); + + describe('Parameter Contracts in Report Mode', function () { + test('allows function to execute to completion without throwing when parameters violate contract', function () { + Config::set(['on_violation' => 'report']); + + $result = testReportingParamFunc(-10, ''); + + expect($result)->toBe(': -10'); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('parameter') + ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->given)->toBe('negative int (-10)') + ; + }); + + test('allows method to execute to completion without throwing on invalid arguments', function () { + Config::set(['on_violation' => 'report']); + $service = new ReportingModelFixture(); + + $result = $service->formatUser(-42, 'Alice'); + expect($result)->toBe('Alice_-42'); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('parameter') + ->and($violations[0]->function)->toContain('formatUser') + ->and($violations[0]->target)->toBe('$id') + ; + }); + }); + + describe('Return Contracts in Report Mode', function () { + test('returns invalid value directly without throwing when function return contract fails', function () { + Config::set(['on_violation' => 'report']); + + $result = testReportingReturnFunc(invalid: true); + expect($result)->toBe([10, -5, 30]); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('return') + ->and($violations[0]->target)->toBe('return') + ->and($violations[0]->expected)->toBe('positive-int') + ; + }); + + test('returns invalid value on method return contract failure without throwing', function () { + Config::set(['on_violation' => 'report']); + $service = new ReportingModelFixture(); + + $result = $service->invalidReturnMethod(); + expect($result)->toBe(-999); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('return') + ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->given)->toBe('negative int (-999)') + ; + }); + }); + + describe('Property Assignments in Report Mode', function () { + test('allows invalid property assignment on object instance and records violation', function () { + Config::set(['on_violation' => 'report']); + $model = new ReportingModelFixture(); + + $model->score = -50; + expect($model->score)->toBe(-50); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('property') + ->and($violations[0]->target)->toBe('$score') + ->and($violations[0]->expected)->toBe('positive-int') + ; + }); + + test('allows invalid static property assignment and records violation', function () { + Config::set(['on_violation' => 'report']); + + ReportingModelFixture::$category = ''; + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('property') + ->and($violations[0]->target)->toBe('$category') + ->and($violations[0]->expected)->toBe('non-empty-string') + ; + }); + }); + + describe('Inline @var Variable Assignments in Report Mode', function () { + test('allows invalid variable assignment and records violation', function () { + Config::set(['on_violation' => 'report']); + + /** @var positive-int $userCount */ + $userCount = -100; + expect($userCount)->toBe(-100); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('variable') + ->and($violations[0]->target)->toBe('$userCount') + ->and($violations[0]->expected)->toBe('positive-int') + ; + }); + }); + + describe('By-Reference @param-out Contracts in Report Mode', function () { + test('allows invalid by-reference post-condition mutation without throwing', function () { + Config::set(['on_violation' => 'report']); + + $code = 'init'; + testReportingParamOutFunc($code); + + expect($code)->toBe(-50); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('param-out') + ->and($violations[0]->target)->toBe('$code') + ->and($violations[0]->expected)->toBe('positive-int') + ; + }); + }); + + describe('Wrapped Callables & Closures in Report Mode', function () { + test('allows wrapped callback to receive invalid argument and return invalid value without throwing', function () { + Config::set(['on_violation' => 'report']); + + $badCallback = fn (int $n): string => ''; + + $result = testReportingCallableFunc($badCallback); + expect($result)->toBe(''); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('callback') + ; + }); + }); + + describe('Iterators & Generators in Report Mode', function () { + test('iterates through entire generator with invalid keys/values without interrupting loop', function () { + Config::set(['on_violation' => 'report']); + + $collected = []; + foreach (testReportingGeneratorFunc() as $k => $v) { + $collected[$k] = $v; + } + + expect($collected)->toBe([ + 'a' => 10, + '' => -20, + 'c' => 30, + ]); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('yield') + ; + }); + }); + + describe('Generics & Template Bounds in Report Mode', function () { + test('records violation when adding incompatible item to prebound generic collection without throwing', function () { + Config::set(['on_violation' => 'report']); + + /** @var GenericCollection $dogs */ + $dogs = new GenericCollection(); + + $dogs->add(new Car()); + + expect($dogs->count())->toBe(1); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('parameter') + ->and($violations[0]->expected)->toContain(Dog::class) + ; + }); + }); + + describe('Deeply Nested Shapes in Report Mode', function () { + test('records breadcrumb path on deep nested shape failure without throwing', function () { + Config::set(['on_violation' => 'report']); + + $payload = [ + 'account' => [ + 'profile' => [ + 'score' => -10, + ], + ], + ]; + + $res = testReportingDeepShapeFunc($payload); + expect($res)->toBe(-10); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->kind)->toBe('parameter') + ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->given)->toBe('negative int (-10)') + ; + }); + }); + + describe('PHP 8.2+ #[SensitiveParameter] Redaction in Report Mode', function () { + test('never leaks raw secret string in report record for sensitive parameters', function () { + if (PHP_VERSION_ID < 80200) { + expect(true)->toBeTrue(); + + return; + } + + Config::set(['on_violation' => 'report']); + + $rawSecretKey = ''; + $res = testReportingSensitiveParamFunc($rawSecretKey); + expect($res)->toBe('authenticated'); + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->given)->toBe('string') + ->and($violations[0]->given)->not()->toContain('super_secret') + ; + }); + }); + + describe('PHP 8.4 Property Hooks in Report Mode', function () { + test('allows hook execution on invalid property hook value and records violation', function () { + if (PHP_VERSION_ID < 80400) { + expect(true)->toBeTrue(); + + return; + } + + Config::set(['on_violation' => 'report']); + $fixture = new \TypePHP\Tests\Fixtures\PropertyHooks\ReportingHookedFixture(); + + $fixture->hookedScore = -5; + expect($fixture->hookedScore)->toBe(-99); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->kind)->toBe('property') + ; + }); + }); + + describe('High-Volume Loop Stress Testing & Deduplication', function () { + test('records exactly 1 violation in memory when loop executes 5,000 failing calls', function () { + Config::set(['on_violation' => 'report']); + + for ($i = 0; $i < 5000; $i++) { + testReportingParamFunc(-5, 'StressTest'); + } + + $violations = TypePHP::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->expected)->toBe('positive-int') + ; + }); + + test('emits E_USER_WARNING exactly once when loop executes 5,000 failing calls in warn mode', function () { + Config::set(['on_violation' => 'warn']); + + $warnCount = 0; + set_error_handler(function () use (&$warnCount): bool { + $warnCount++; + + return true; + }); + + try { + for ($i = 0; $i < 5000; $i++) { + testReportingParamFunc(-5, 'StressWarn'); + } + } finally { + restore_error_handler(); + } + + expect($warnCount)->toBe(1); + }); + }); + + describe('JSON Report Export & Corrupted Shard Recovery', function () { + test('exports structured JSON report matching document schema via TypePHP::exportReport', function () { + $tempDir = sys_get_temp_dir() . '/typephp_feature_report_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFilePath = $tempDir . '/typephp-report.json'; + + Config::set([ + 'on_violation' => 'report', + 'report_file' => $reportFilePath, + ]); + + testReportingParamFunc(-10, 'Alpha'); + testReportingReturnFunc(invalid: true); + + $model = new ReportingModelFixture(); + $model->score = -99; + + $exportedPath = TypePHP::exportReport($reportFilePath); + + expect($exportedPath)->toBe($reportFilePath) + ->and(file_exists($reportFilePath))->toBeTrue() + ; + + /** @var array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list>} $doc */ + $doc = json_decode((string) file_get_contents($reportFilePath), true); + + expect($doc)->toHaveKey('version') + ->and($doc['version'])->toBe('1.0') + ->and($doc['summary']['total_violations'])->toBe(3) + ->and($doc['summary']['files_affected'])->toBeGreaterThanOrEqual(1) + ->and($doc['violations'])->toHaveCount(3) + ; + + @unlink($reportFilePath); + @rmdir($tempDir); + }); + + test('handles corrupted shard files gracefully during exportReport without crashing', function () { + $tempDir = sys_get_temp_dir() . '/typephp_corrupt_shard_' . uniqid(); + $shardDir = $tempDir . '/.typephp-shards'; + mkdir($shardDir, 0777, true); + $reportFilePath = $tempDir . '/typephp-report.json'; + + Config::set([ + 'on_violation' => 'report', + 'report_file' => $reportFilePath, + ]); + + testReportingParamFunc(-10, 'ValidRecord'); + + // Intentionally write broken JSON into shard folder + file_put_contents($shardDir . '/shard_broken_123.json', '{{{ corrupted invalid json'); + + $exported = TypePHP::exportReport($reportFilePath); + + expect($exported)->toBe($reportFilePath) + ->and(file_exists($reportFilePath))->toBeTrue() + ; + + /** @var array{version: string, summary: array{total_violations: int}, violations: list>} $doc */ + $doc = json_decode((string) file_get_contents($reportFilePath), true); + expect($doc['summary']['total_violations'])->toBe(1); + + @unlink($reportFilePath); + @rmdir($tempDir); + }); + }); + + describe('Mode: throw (Default Verification)', function () { + test('throws TypeError immediately when on_violation is throw (default behavior)', function () { + Config::set(['on_violation' => 'throw']); + + expect(fn () => testReportingParamFunc(-10, 'Alpha')) + ->toThrow(TypeError::class, 'positive-int') + ; + + expect(TypePHP::getViolations())->toBeEmpty(); + }); + }); +}); diff --git a/tests/Fixtures/CrossNamespace/App/CrossNamespaceAccount.php b/tests/Fixtures/CrossNamespace/App/CrossNamespaceAccount.php new file mode 100644 index 00000000..abb3035a --- /dev/null +++ b/tests/Fixtures/CrossNamespace/App/CrossNamespaceAccount.php @@ -0,0 +1,12 @@ +owner = new CrossNamespaceUser(); + } +} diff --git a/tests/Fixtures/CrossNamespace/Model/CrossNamespaceBase.php b/tests/Fixtures/CrossNamespace/Model/CrossNamespaceBase.php new file mode 100644 index 00000000..f14c49dc --- /dev/null +++ b/tests/Fixtures/CrossNamespace/Model/CrossNamespaceBase.php @@ -0,0 +1,13 @@ + -99; + set(int $value) { + $this->hookedScore = $value; + } + } +} diff --git a/tests/Fixtures/Traits/Nested/Contracts/NestedEntity.php b/tests/Fixtures/Traits/Nested/Contracts/NestedEntity.php new file mode 100644 index 00000000..232d6fed --- /dev/null +++ b/tests/Fixtures/Traits/Nested/Contracts/NestedEntity.php @@ -0,0 +1,9 @@ +|null + */ + public ?array $group = null; + + /** + * @var NestedEntity|null + */ + public ?object $singleEntity = null; + + /** + * @param list $entities + */ + public function setGroup(array $entities): void + { + $this->group = $entities; + } + + public function setSingle(NestedEntity $entity): void + { + $this->singleEntity = $entity; + } +} diff --git a/tests/Fixtures/Traits/Nested/Traits/NestedOuterTrait.php b/tests/Fixtures/Traits/Nested/Traits/NestedOuterTrait.php new file mode 100644 index 00000000..5acaa90a --- /dev/null +++ b/tests/Fixtures/Traits/Nested/Traits/NestedOuterTrait.php @@ -0,0 +1,10 @@ +and($ternary->if)->toBeInstanceOf(Node\Expr\Throw_::class) ; }); + + test('createMemberAssignCheckCall creates FuncCall node for RuntimeTypeChecker::checkMemberAssign', function () { + $root = new Node\Expr\Variable('stats'); + $chain = new Node\Expr\Array_([ + new Node\Expr\ArrayItem( + new Node\Expr\Array_([ + new Node\Expr\ArrayItem(new Node\Scalar\String_('dim')), + new Node\Expr\ArrayItem(new Node\Scalar\String_('count')), + ]) + ), + ]); + $val = new Node\Scalar\String_('test'); + + $call = NodeBuilder::createMemberAssignCheckCall($root, $chain, $val, 'array{count: int}', 'stats'); + + expect($call)->toBeInstanceOf(Node\Expr\FuncCall::class) + ->and($call->name->toString())->toBe('\TypePHP\Internal\RuntimeTypeChecker::checkMemberAssign') + ->and($call->args)->toHaveCount(6) + ; + + $caller = new Node\Scalar\String_('App\Service::run'); + $thisArg = new Node\Expr\Variable('this'); + $callWithCaller = NodeBuilder::createMemberAssignCheckCall($root, $chain, $val, 'array{count: int}', 'stats', $caller, $thisArg); + + expect($callWithCaller->args)->toHaveCount(8); + }); }); diff --git a/tests/Internal/Checker/MemberAssignCheckerTest.php b/tests/Internal/Checker/MemberAssignCheckerTest.php new file mode 100644 index 00000000..2e0b166c --- /dev/null +++ b/tests/Internal/Checker/MemberAssignCheckerTest.php @@ -0,0 +1,254 @@ +registry = new TypeValidatorRegistry(); + }); + + test('accepts valid array shape key assignment and returns value', function () { + $root = ['count' => 5, 'name' => 'Alice']; + $chain = [['dim', 'count']]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 10, + 'array{count: int, name: string}', + 'stats', + __FILE__, + $this->registry + ); + + expect($result)->toBe(10) + ->and($root['count'])->toBe(5) + ; + }); + + test('returns ErrorMessage on invalid array shape key assignment', function () { + $root = ['count' => 5]; + $chain = [['dim', 'count']]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 'not_an_int', + 'array{count: int}', + 'stats', + __FILE__, + $this->registry + ); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain("['count'] must be of type int") + ; + }); + + test('returns ErrorMessage when assigning unexpected key to sealed array shape', function () { + $root = ['count' => 5]; + $chain = [['dim', 'extra']]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 'forbidden', + 'array{count: int}', + 'stats', + __FILE__, + $this->registry + ); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain("contains unsealed unexpected key 'extra'") + ; + }); + + test('fast-path O(1) in hybrid mode validates leaf directly on large list', function () { + Config::set(['array_validation' => 'hybrid']); + + $root = range(1, 200); + $chain = [['dim', 50]]; + + $valid = MemberAssignChecker::check( + $root, + $chain, + 100, + 'list', + 'list', + __FILE__, + $this->registry + ); + expect($valid)->toBe(100); + + $invalid = MemberAssignChecker::check( + $root, + $chain, + -50, + 'list', + 'list', + __FILE__, + $this->registry + ); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('positive-int') + ; + }); + + test('fast-path in hybrid mode rejects non-integer keys on list types', function () { + Config::set(['array_validation' => 'hybrid']); + + $root = [1, 2, 3]; + $chain = [['dim', 'string_key']]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 10, + 'list', + 'items', + __FILE__, + $this->registry + ); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('must be a list, non-integer key') + ; + }); + + test('fast-path in hybrid mode validates key on generic maps (array)', function () { + Config::set(['array_validation' => 'hybrid']); + + $root = ['a' => 1]; + $chain = [['dim', 123]]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 10, + 'array', + 'map', + __FILE__, + $this->registry + ); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('key must be of type string') + ; + }); + + test('validates multi-level nested array shape assignment', function () { + $root = ['user' => ['id' => 1, 'role' => 'admin']]; + $chain = [['dim', 'user'], ['dim', 'id']]; + + $valid = MemberAssignChecker::check( + $root, + $chain, + 42, + 'array{user: array{id: positive-int, role: non-empty-string}}', + 'config', + __FILE__, + $this->registry + ); + expect($valid)->toBe(42); + + $invalid = MemberAssignChecker::check( + $root, + $chain, + -99, + 'array{user: array{id: positive-int, role: non-empty-string}}', + 'config', + __FILE__, + $this->registry + ); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain("['user']['id']") + ; + }); + + test('validates object shape property assignment on stdClass', function () { + $root = (object) ['count' => 5]; + $chain = [['prop', 'count']]; + + $valid = MemberAssignChecker::check( + $root, + $chain, + 10, + 'object{count: int}', + 'stats', + __FILE__, + $this->registry + ); + expect($valid)->toBe(10) + ->and($root->count)->toBe(5) + ; + + $invalid = MemberAssignChecker::check( + $root, + $chain, + 'not_an_int', + 'object{count: int}', + 'stats', + __FILE__, + $this->registry + ); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('->count must be of type int') + ; + }); + + test('returns ErrorMessage when assigning unexpected property to sealed object shape', function () { + $root = (object) ['count' => 5]; + $chain = [['prop', 'unexpected_field']]; + + $result = MemberAssignChecker::check( + $root, + $chain, + 'forbidden', + 'object{count: int}', + 'stats', + __FILE__, + $this->registry + ); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain("contains unexpected property 'unexpected_field'") + ; + }); + + test('validates multi-level nested object shape property assignment', function () { + $root = (object) ['user' => (object) ['id' => 1]]; + $chain = [['prop', 'user'], ['prop', 'id']]; + + $valid = MemberAssignChecker::check( + $root, + $chain, + 100, + 'object{user: object{id: positive-int}}', + 'data', + __FILE__, + $this->registry + ); + expect($valid)->toBe(100); + + $invalid = MemberAssignChecker::check( + $root, + $chain, + -50, + 'object{user: object{id: positive-int}}', + 'data', + __FILE__, + $this->registry + ); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('->user->id') + ; + }); +}); diff --git a/tests/Internal/Cli/CommandRunnerTest.php b/tests/Internal/Cli/CommandRunnerTest.php index 8e85ee0e..0cc0ec2a 100644 --- a/tests/Internal/Cli/CommandRunnerTest.php +++ b/tests/Internal/Cli/CommandRunnerTest.php @@ -5,6 +5,7 @@ namespace TypePHP\Tests\Command; use TypePHP\Internal\Cli\CommandRunner; +use TypePHP\Internal\Util\Config; describe('CommandRunner Unit Tests', function () { test('routes help command successfully', function () { @@ -17,9 +18,85 @@ expect($exitCode)->toBe(0) ->and($output)->toContain('USAGE') + ->and($output)->toContain('report') ; }); + test('routes report command when report file is missing', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['report'], $stream, $stream); + + rewind($stream); + $output = stream_get_contents($stream); + fclose($stream); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('Violation Audit Report') + ; + }); + + test('routes report:clear command successfully', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['report:clear'], $stream, $stream); + + rewind($stream); + $output = stream_get_contents($stream); + fclose($stream); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('Report Clear') + ; + }); + + test('routes report command and displays formatted violations with exit code 1', function () { + $tempDir = sys_get_temp_dir() . '/typephp_cli_report_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/typephp-report.json'; + + $reportData = [ + 'version' => '1.0', + 'generated_at' => gmdate('c'), + 'summary' => ['total_violations' => 1, 'files_affected' => 1], + 'violations' => [ + [ + 'file' => 'src/Services/OrderService.php', + 'line' => 42, + 'function' => 'App\\Services\\OrderService::checkout', + 'kind' => 'parameter', + 'target' => '$total', + 'expected' => 'positive-int', + 'given' => 'negative int (-50)', + 'message' => 'Argument $total must be of type positive-int, negative int (-50) given', + ], + ], + ]; + + file_put_contents($reportFile, json_encode($reportData)); + Config::set(['report_file' => $reportFile]); + + $stream = fopen('php://memory', 'r+'); + + try { + $exitCode = CommandRunner::run(['report'], $stream, $stream); + + rewind($stream); + $rawOutput = (string) stream_get_contents($stream); + $output = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Total Violations: 1') + ->and($output)->toContain('src/Services/OrderService.php') + ->and($output)->toContain('Line 42') + ->and($output)->toContain('$total') + ->and($output)->toContain('positive-int') + ; + } finally { + fclose($stream); + @unlink($reportFile); + @rmdir($tempDir); + } + }); + test('routes config:init command successfully', function () { $stream = fopen('php://memory', 'r+'); $exitCode = CommandRunner::run(['config:init'], $stream, $stream); diff --git a/tests/Internal/Reporting/ViolationCollectorTest.php b/tests/Internal/Reporting/ViolationCollectorTest.php new file mode 100644 index 00000000..77d6a37a --- /dev/null +++ b/tests/Internal/Reporting/ViolationCollectorTest.php @@ -0,0 +1,401 @@ +file)->toBe('src/Services/PaymentService.php') + ->and($record->line)->toBe(42) + ->and($record->function)->toBe('App\\Services\\PaymentService::charge') + ->and($record->kind)->toBe('parameter') + ->and($record->target)->toBe('$amount') + ->and($record->expected)->toBe('positive-int') + ->and($record->given)->toBe('negative int (-50)') + ->and($record->getHash())->toBeString() + ->and(\strlen($record->getHash()))->toBe(32) + ; + }); + + test('serializes to array and json matching document schema', function () { + $record = new ViolationRecord( + file: 'src/Models/User.php', + line: 10, + function: 'App\\Models\\User::setName', + kind: 'parameter', + target: '$name', + expected: 'non-empty-string', + given: "empty string ('')", + message: "Argument \$name must be of type non-empty-string, empty string ('') given" + ); + + $array = $record->toArray(); + expect($array)->toBe([ + 'file' => 'src/Models/User.php', + 'line' => 10, + 'function' => 'App\\Models\\User::setName', + 'kind' => 'parameter', + 'target' => '$name', + 'expected' => 'non-empty-string', + 'given' => "empty string ('')", + 'message' => "Argument \$name must be of type non-empty-string, empty string ('') given", + ]); + + $json = json_encode($record, JSON_UNESCAPED_SLASHES); + expect($json)->toBeString() + ->and($json)->toContain('"file":"src/Models/User.php"') + ->and($json)->toContain('"target":"$name"') + ; + }); + + test('fromArray reconstitutes a ViolationRecord and handles fallbacks on missing data', function () { + $data = [ + 'file' => 'src/Order.php', + 'line' => 25, + 'function' => 'App\\Order::process', + 'kind' => 'return', + 'target' => 'return', + 'expected' => 'bool', + 'given' => 'int (0)', + 'message' => 'Return value must be of type bool, int (0) returned', + ]; + + $record = ViolationRecord::fromArray($data); + expect($record)->not()->toBeNull() + ->and($record?->file)->toBe('src/Order.php') + ->and($record?->line)->toBe(25) + ->and($record?->kind)->toBe('return') + ; + + expect(ViolationRecord::fromArray([]))->toBeNull(); + expect(ViolationRecord::fromArray(['file' => 'src/File.php']))->toBeNull(); + }); +}); + +describe('ViolationCollector', function () { + beforeEach(function () { + Config::reset(); + ViolationCollector::reset(); + }); + + afterEach(function () { + Config::reset(); + ViolationCollector::reset(); + }); + + describe('Mode: throw', function () { + test('returns ErrorMessage directly without recording or warning', function () { + Config::set(['on_violation' => 'throw']); + $err = new ErrorMessage('Argument $id must be of type positive-int, negative int (-1) given'); + + $result = ViolationCollector::handle($err, 'parameter', 42); + + expect($result)->toBe($err) + ->and(ViolationCollector::getViolations())->toBeEmpty() + ; + }); + }); + + describe('Mode: warn', function () { + test('emits PHP E_USER_WARNING and returns pass-through value', function () { + Config::set(['on_violation' => 'warn']); + $err = new ErrorMessage('Argument $code must be of type positive-int, int (-5) given'); + + $warningCaught = ''; + set_error_handler(function (int $errno, string $errstr) use (&$warningCaught): bool { + $warningCaught = $errstr; + + return true; + }); + + try { + $result = ViolationCollector::handle($err, 'parameter', 'fallback_val', 'src/Action.php', 15); + } finally { + restore_error_handler(); + } + + expect($result)->toBe('fallback_val') + ->and($warningCaught)->toContain('[TypePHP Violation]') + ->and($warningCaught)->toContain('Argument $code must be of type positive-int') + ; + }); + + test('deduplicates warnings so repeated violations in loops only trigger E_USER_WARNING once', function () { + Config::set(['on_violation' => 'warn']); + $err = new ErrorMessage('Argument $loop must be of type positive-int, negative int (-1) given'); + + $warningCount = 0; + set_error_handler(function () use (&$warningCount): bool { + $warningCount++; + + return true; + }); + + try { + for ($i = 0; $i < 50; $i++) { + ViolationCollector::handle($err, 'parameter', 100, 'src/Loop.php', 20); + } + } finally { + restore_error_handler(); + } + + expect($warningCount)->toBe(1); + }); + + test('does not record violations in memory in warn mode even when report_file is configured', function () { + Config::set([ + 'on_violation' => 'warn', + 'report_file' => 'var/report.json', + ]); + $err = new ErrorMessage('Argument $id must be of type positive-int, int (-5) given'); + + set_error_handler(static fn (): bool => true); + + try { + ViolationCollector::handle($err, 'parameter', 42, 'src/Service.php', 10); + } finally { + restore_error_handler(); + } + + expect(ViolationCollector::getViolations())->toBeEmpty(); + }); + }); + + describe('Mode: report', function () { + test('records violation in memory, suppresses warnings, and returns pass-through value', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage('App\\Services\\UserService::find(): Argument $id must be of type positive-int, negative int (-10) given'); + + $result = ViolationCollector::handle($err, 'parameter', 'original_val', 'src/Services/UserService.php', 42); + + expect($result)->toBe('original_val'); + + $violations = ViolationCollector::getViolations(); + expect($violations)->toHaveCount(1) + ->and($violations[0]->file)->toBe('src/Services/UserService.php') + ->and($violations[0]->line)->toBe(42) + ->and($violations[0]->function)->toBe('App\\Services\\UserService::find') + ->and($violations[0]->kind)->toBe('parameter') + ->and($violations[0]->target)->toBe('$id') + ->and($violations[0]->expected)->toBe('positive-int') + ->and($violations[0]->given)->toBe('negative int (-10)') + ; + }); + + test('deduplicates in-memory violations on identical occurrences', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage('Property App\\Model::$score must be of type positive-int, zero int (0) given'); + + for ($i = 0; $i < 100; $i++) { + ViolationCollector::handle($err, 'property', 0, 'src/Model.php', 5); + } + + expect(ViolationCollector::getViolations())->toHaveCount(1); + }); + + test('clear resets all in-memory violations', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage('Argument $id must be of type positive-int, int (-1) given'); + + ViolationCollector::handle($err, 'parameter', 1, 'src/File.php', 1); + expect(ViolationCollector::getViolations())->toHaveCount(1); + + ViolationCollector::clear(); + expect(ViolationCollector::getViolations())->toBeEmpty(); + }); + }); + + describe('Relative Path Normalization', function () { + test('normalizes paths inside project root to relative forward-slash paths', function () { + $root = Config::getProjectRoot(); + $absolutePath = $root . '/src/Internal/Util/Config.php'; + + $normalized = ViolationCollector::normalizeRelativePath($absolutePath); + expect($normalized)->toBe('src/Internal/Util/Config.php'); + }); + + test('handles Windows backslashes in normalization', function () { + $root = Config::getProjectRoot(); + $windowsPath = str_replace('/', '\\', $root . '/tests/Feature/Test.php'); + + $normalized = ViolationCollector::normalizeRelativePath($windowsPath); + expect($normalized)->toBe('tests/Feature/Test.php'); + }); + + test('returns original path when outside project root or empty', function () { + expect(ViolationCollector::normalizeRelativePath(''))->toBe('unknown'); + expect(ViolationCollector::normalizeRelativePath('/opt/external/Other.php'))->toBe('/opt/external/Other.php'); + }); + }); + + describe('Parallel Testing Sharding & Report Export', function () { + test('writes process-isolated shard without locking during parallel testing', function () { + $tempDir = sys_get_temp_dir() . '/typephp_shard_test_' . uniqid(); + mkdir($tempDir, 0777, true); + $reportFile = $tempDir . '/typephp-report.json'; + + putenv('TEST_TOKEN=3'); + + try { + Config::set([ + 'on_violation' => 'report', + 'report_file' => $reportFile, + ]); + + $err = new ErrorMessage('Argument $id must be of type positive-int, negative int (-1) given'); + ViolationCollector::handle($err, 'parameter', null, 'src/Action.php', 10); + + ViolationCollector::writeWorkerShard($reportFile); + + $shardDir = $tempDir . '/.typephp-shards'; + expect(is_dir($shardDir))->toBeTrue(); + + $shards = glob($shardDir . '/shard_3_*.json'); + expect($shards)->not()->toBeFalse() + ->and(\count($shards))->toBe(1) + ; + + $content = file_get_contents($shards[0]); + expect($content)->toContain('src/Action.php') + ->and($content)->toContain('positive-int') + ; + } finally { + putenv('TEST_TOKEN'); + $files = glob($tempDir . '/.typephp-shards/*'); + if ($files !== false) { + foreach ($files as $f) { + @unlink($f); + } + } + @rmdir($tempDir . '/.typephp-shards'); + @rmdir($tempDir); + } + }); + + test('exportReport merges and deduplicates worker shards into master JSON document', function () { + $tempDir = sys_get_temp_dir() . '/typephp_export_test_' . uniqid(); + $shardDir = $tempDir . '/.typephp-shards'; + mkdir($shardDir, 0777, true); + $reportFile = $tempDir . '/typephp-report.json'; + + $shard1 = [ + [ + 'file' => 'src/Payment.php', + 'line' => 20, + 'function' => 'App\\Payment::charge', + 'kind' => 'parameter', + 'target' => '$amount', + 'expected' => 'positive-int', + 'given' => 'negative int (-10)', + 'message' => 'Argument $amount must be of type positive-int, negative int (-10) given', + ], + ]; + + $shard2 = [ + [ + 'file' => 'src/Payment.php', + 'line' => 20, + 'function' => 'App\\Payment::charge', + 'kind' => 'parameter', + 'target' => '$amount', + 'expected' => 'positive-int', + 'given' => 'negative int (-10)', + 'message' => 'Argument $amount must be of type positive-int, negative int (-10) given', + ], + [ + 'file' => 'src/User.php', + 'line' => 45, + 'function' => 'App\\User::setEmail', + 'kind' => 'parameter', + 'target' => '$email', + 'expected' => 'non-empty-string', + 'given' => "empty string ('')", + 'message' => "Argument \$email must be of type non-empty-string, empty string ('') given", + ], + ]; + + file_put_contents($shardDir . '/shard_1_abc.json', json_encode($shard1)); + file_put_contents($shardDir . '/shard_2_def.json', json_encode($shard2)); + + try { + $exportedPath = ViolationCollector::exportReport($reportFile); + + expect($exportedPath)->toBe($reportFile) + ->and(file_exists($reportFile))->toBeTrue() + ->and(is_dir($shardDir))->toBeFalse() + ; + + /** @var array{version: string, generated_at: string, summary: array{total_violations: int, files_affected: int}, violations: list>} $doc */ + $doc = json_decode((string) file_get_contents($reportFile), true); + + expect($doc['version'])->toBe('1.0') + ->and($doc['summary']['total_violations'])->toBe(2) + ->and($doc['summary']['files_affected'])->toBe(2) + ->and($doc['violations'])->toHaveCount(2) + ; + } finally { + if (file_exists($reportFile)) { + @unlink($reportFile); + } + @rmdir($tempDir); + } + }); + }); + + describe('Metadata Parser', function () { + test('parses return value diagnostics', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage('App\\Service::execute(): Return value must be of type string, int (123) returned'); + + ViolationCollector::handle($err, 'return', 123, 'src/Service.php', 30); + + $violations = ViolationCollector::getViolations(); + expect($violations[0]->kind)->toBe('return') + ->and($violations[0]->target)->toBe('return') + ->and($violations[0]->expected)->toBe('string') + ->and($violations[0]->given)->toBe('int (123)') + ; + }); + + test('parses missing required property diagnostics', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage("App\\Data::validate(): Argument \$data is missing required key 'id'"); + + ViolationCollector::handle($err, 'parameter', [], 'src/Data.php', 12); + + $violations = ViolationCollector::getViolations(); + expect($violations[0]->expected)->toBe("required 'id'") + ->and($violations[0]->given)->toBe('missing') + ; + }); + + test('parses unsealed unexpected key diagnostics', function () { + Config::set(['on_violation' => 'report']); + $err = new ErrorMessage("Argument \$payload contains unsealed unexpected key 'forbidden'"); + + ViolationCollector::handle($err, 'parameter', [], 'src/Payload.php', 10); + + $violations = ViolationCollector::getViolations(); + expect($violations[0]->expected)->toBe('sealed shape') + ->and($violations[0]->given)->toBe("unexpected key 'forbidden'") + ; + }); + }); +}); diff --git a/tests/Internal/RuntimeTypeCheckerTest.php b/tests/Internal/RuntimeTypeCheckerTest.php index 2074f63f..6f386d32 100644 --- a/tests/Internal/RuntimeTypeCheckerTest.php +++ b/tests/Internal/RuntimeTypeCheckerTest.php @@ -5,6 +5,8 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use TypePHP\Exception\TypeError; +use TypePHP\Internal\Checker\ParamChecker; +use TypePHP\Internal\Checker\SelfOutChecker; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\RuntimeTypeChecker; use TypePHP\Internal\Util\Config; @@ -44,9 +46,6 @@ public function execute(int $id): int } } -use TypePHP\Internal\Checker\ParamChecker; -use TypePHP\Internal\Checker\SelfOutChecker; - class RuntimeCheckerIgnoredCaller { /** @@ -58,6 +57,42 @@ public static function run(callable $fn): mixed } } +/** + * Self-contained fixture: by-reference param-out + * + * @param mixed &$id + * + * @param-out positive-int $id + */ +function runtimeTestParamOutFixture(mixed &$id): void +{ +} + +/** + * Self-contained fixture: self-out state transitions + * + * @template T of 'unauthenticated'|'authenticated' + */ +class RuntimeTestSessionFixture +{ + /** + * @self-out self<'authenticated'> + */ + public function login(): void + { + } +} + +/** + * Self-contained fixture: generator with yield and send contracts + * + * @return Generator + */ +function runtimeTestGeneratorFixture(): Generator +{ + yield 'a' => 10; +} + /** * @param mixed $a */ @@ -155,6 +190,74 @@ function runtimeUnconstrainedReturn(): mixed expect(RuntimeTypeChecker::checkProperty(['a'], $fixture, 'numbers', __FILE__))->toBe(['a']); }); + test('checkMemberAssign validates array dimension assignment and handles disabled switch', function () { + $root = ['count' => 5]; + $chain = [['dim', 'count']]; + + $valid = RuntimeTypeChecker::checkMemberAssign($root, $chain, 10, 'array{count: int}', 'stats', __FILE__); + expect($valid)->toBe(10); + + $invalid = RuntimeTypeChecker::checkMemberAssign($root, $chain, 'not_an_int', 'array{count: int}', 'stats', __FILE__); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain("['count'] must be of type int") + ; + + // Atomic state preservation: original array must remain untouched + expect($root['count'])->toBe(5); + + // Master switch disabled + Config::set(['enabled' => false]); + expect(RuntimeTypeChecker::checkMemberAssign($root, $chain, 'not_an_int', 'array{count: int}', 'stats', __FILE__))->toBe('not_an_int'); + }); + + test('checkMemberAssign validates list append and multi-dimensional nested chains', function () { + $list = [1, 2]; + $chainAppend = [['dim', null]]; + + $validAppend = RuntimeTypeChecker::checkMemberAssign($list, $chainAppend, 3, 'list', 'list', __FILE__); + expect($validAppend)->toBe(3); + + $invalidAppend = RuntimeTypeChecker::checkMemberAssign($list, $chainAppend, -1, 'list', 'list', __FILE__); + expect($invalidAppend)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidAppend->getMessage())->toContain('positive-int') + ; + + $nested = ['user' => ['id' => 1]]; + $chainNested = [['dim', 'user'], ['dim', 'id']]; + $invalidNested = RuntimeTypeChecker::checkMemberAssign($nested, $chainNested, -99, 'array{user: array{id: positive-int}}', 'nested', __FILE__); + expect($invalidNested)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidNested->getMessage())->toContain("['user']['id']") + ; + }); + + test('checkMemberAssign validates object shape properties and preserves state atomically', function () { + $obj = (object) ['count' => 5]; + $chain = [['prop', 'count']]; + + $valid = RuntimeTypeChecker::checkMemberAssign($obj, $chain, 10, 'object{count: int}', 'obj', __FILE__); + expect($valid)->toBe(10); + + $invalid = RuntimeTypeChecker::checkMemberAssign($obj, $chain, 'not_an_int', 'object{count: int}', 'obj', __FILE__); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('->count must be of type int') + ; + + // Atomic state preservation: original stdClass must remain untouched + expect($obj->count)->toBe(5); + + // Nested object shape + $nestedObj = (object) ['user' => (object) ['id' => 1]]; + $nestedChain = [['prop', 'user'], ['prop', 'id']]; + $validNested = RuntimeTypeChecker::checkMemberAssign($nestedObj, $nestedChain, 100, 'object{user: object{id: positive-int}}', 'nestedObj', __FILE__); + expect($validNested)->toBe(100); + + $invalidNested = RuntimeTypeChecker::checkMemberAssign($nestedObj, $nestedChain, -50, 'object{user: object{id: positive-int}}', 'nestedObj', __FILE__); + expect($invalidNested)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidNested->getMessage())->toContain('->user->id') + ; + expect($nestedObj->user->id)->toBe(1); + }); + test('bindInstanceFromNode delegates to TemplateManager and respects disabled switch', function () { $dog = new Container(new Dog()); $node = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Dog::class)]); @@ -195,7 +298,7 @@ function runtimeUnconstrainedReturn(): mixed }); test('checkParamOut validates post-conditions and handles disabled switch', function () { - $target = 'TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture'; + $target = 'runtimeTestParamOutFixture'; expect(RuntimeTypeChecker::checkParamOut($target, 'id', 42))->toBeNull(); @@ -207,8 +310,8 @@ function runtimeUnconstrainedReturn(): mixed }); test('checkSelfOut executes state transition and handles disabled switch', function () { - $session = new TypePHP\Tests\TypeChecking\Generics\FixtureSession(); - $target = TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login'; + $session = new RuntimeTestSessionFixture(); + $target = RuntimeTestSessionFixture::class . '::login'; RuntimeTypeChecker::checkSelfOut($target, $session); expect(true)->toBeTrue(); @@ -295,18 +398,23 @@ function runtimeUnconstrainedReturn(): mixed ); expect($resProp)->toBe(['bad']); + $resMember = RuntimeCheckerIgnoredCaller::run( + fn () => RuntimeTypeChecker::checkMemberAssign(['count' => 5], [['dim', 'count']], 'not_an_int', 'array{count: int}', 'stats', __FILE__) + ); + expect($resMember)->toBe('not_an_int'); + $resParams = RuntimeCheckerIgnoredCaller::run( fn () => RuntimeTypeChecker::checkParams(UserService::class . '::find', ['id' => -1], new UserService()) ); expect($resParams)->toBeNull(); $resOut = RuntimeCheckerIgnoredCaller::run( - fn () => RuntimeTypeChecker::checkParamOut('TypePHP\Tests\Internal\Checker\internalParamOutScalarFixture', 'id', -50) + fn () => RuntimeTypeChecker::checkParamOut('runtimeTestParamOutFixture', 'id', -50) ); expect($resOut)->toBeNull(); RuntimeCheckerIgnoredCaller::run( - fn () => RuntimeTypeChecker::checkSelfOut(TypePHP\Tests\TypeChecking\Generics\FixtureSession::class . '::login', new TypePHP\Tests\TypeChecking\Generics\FixtureSession()) + fn () => RuntimeTypeChecker::checkSelfOut(RuntimeTestSessionFixture::class . '::login', new RuntimeTestSessionFixture()) ); $resRet = RuntimeCheckerIgnoredCaller::run( @@ -315,12 +423,12 @@ function runtimeUnconstrainedReturn(): mixed expect($resRet)->toBe(['id' => -1, 'name' => 'Alice']); $resSend = RuntimeCheckerIgnoredCaller::run( - fn () => RuntimeTypeChecker::checkSend('sampleGeneratorFixture', -50) + fn () => RuntimeTypeChecker::checkSend('runtimeTestGeneratorFixture', -50) ); expect($resSend)->toBe(-50); $resYield = RuntimeCheckerIgnoredCaller::run( - fn () => RuntimeTypeChecker::checkYield('sampleGeneratorFixture', 123, -50) + fn () => RuntimeTypeChecker::checkYield('runtimeTestGeneratorFixture', 123, -50) ); expect($resYield)->toBe(-50); }); @@ -385,6 +493,11 @@ function vendorAction(): void ); expect($resProp)->toBe(['bad']); + $resMember = Acme\VendorTest\VendorCaller::call( + fn () => RuntimeTypeChecker::checkMemberAssign(['count' => 5], [['dim', 'count']], 'not_an_int', 'array{count: int}', 'stats', __FILE__, $fnName) + ); + expect($resMember)->toBe('not_an_int'); + $resParams = Acme\VendorTest\VendorCaller::call( fn () => RuntimeTypeChecker::checkParams($fnName, ['a' => 1]) ); diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php index 8c6ea5db..ec388874 100644 --- a/tests/Internal/Util/ConfigTest.php +++ b/tests/Internal/Util/ConfigTest.php @@ -6,6 +6,23 @@ describe('Config Unit Tests', function () { afterEach(function () { + putenv('TYPEPHP_AUTO_BOOT='); + putenv('TYPEPHP_ON_VIOLATION='); + putenv('TYPEPHP_REPORT_FILE='); + putenv('TYPEPHP_FAIL_ON_REPORT='); + putenv('TYPEPHP_REDACT_VALUES='); + unset( + $_ENV['TYPEPHP_AUTO_BOOT'], + $_SERVER['TYPEPHP_AUTO_BOOT'], + $_ENV['TYPEPHP_ON_VIOLATION'], + $_SERVER['TYPEPHP_ON_VIOLATION'], + $_ENV['TYPEPHP_REPORT_FILE'], + $_SERVER['TYPEPHP_REPORT_FILE'], + $_ENV['TYPEPHP_FAIL_ON_REPORT'], + $_SERVER['TYPEPHP_FAIL_ON_REPORT'], + $_ENV['TYPEPHP_REDACT_VALUES'], + $_SERVER['TYPEPHP_REDACT_VALUES'] + ); Config::reset(); }); @@ -14,6 +31,16 @@ expect($config)->toBeArray() ->and($config)->toHaveKey('enabled') + ->and($config)->toHaveKey('auto_boot') + ->and($config['auto_boot'])->toBeTrue() + ->and($config)->toHaveKey('on_violation') + ->and($config['on_violation'])->toBe('throw') + ->and($config)->toHaveKey('report_file') + ->and($config['report_file'])->toBeNull() + ->and($config)->toHaveKey('fail_on_report') + ->and($config['fail_on_report'])->toBeFalse() + ->and($config)->toHaveKey('redact_values') + ->and($config['redact_values'])->toBeFalse() ->and($config)->toHaveKey('cache') ->and($config)->toHaveKey('cache_dir') ->and($config['cache_dir'])->toBeNull() @@ -26,11 +53,20 @@ 'inline_vars' => [ 'scalars' => false, ], + 'on_violation' => 'report', + 'report_file' => 'var/report.json', + 'fail_on_report' => true, + 'redact_values' => true, ]); $config = Config::get(); - expect($config['inline_vars']['scalars'])->toBeFalse(); + expect($config['inline_vars']['scalars'])->toBeFalse() + ->and($config['on_violation'])->toBe('report') + ->and($config['report_file'])->toBe('var/report.json') + ->and($config['fail_on_report'])->toBeTrue() + ->and($config['redact_values'])->toBeTrue() + ; }); test('resets configuration cache with reset', function () { @@ -44,6 +80,13 @@ test('initializes cachedConfig on demand across all getters when uninitialized and tests cached branch', function () { $getters = [ 'isEnabled', + 'isAutoBootEnabled', + 'getOnViolation', + 'getReportFile', + 'isFailOnReportEnabled', + 'isRedactValuesEnabled', + 'isReportMode', + 'isWarnMode', 'getIgnoreTraceDepth', 'isInlinePropertiesEnabled', 'isInlineGenericsEnabled', @@ -334,4 +377,281 @@ ->and($result)->not()->toBeEmpty() ; }); + + describe('Auto-Boot Configuration Logic', function () { + test('isAutoBootEnabled returns true by default', function () { + expect(Config::isAutoBootEnabled())->toBeTrue(); + }); + + test('isAutoBootEnabled respects runtime overrides with Config::set', function () { + Config::set(['auto_boot' => false]); + expect(Config::isAutoBootEnabled())->toBeFalse(); + + Config::set(['auto_boot' => true]); + expect(Config::isAutoBootEnabled())->toBeTrue(); + }); + + test('isAutoBootEnabled respects TYPEPHP_AUTO_BOOT environment variable', function () { + putenv('TYPEPHP_AUTO_BOOT=false'); + expect(Config::isAutoBootEnabled())->toBeFalse(); + + putenv('TYPEPHP_AUTO_BOOT=true'); + expect(Config::isAutoBootEnabled())->toBeTrue(); + + putenv('TYPEPHP_AUTO_BOOT=0'); + expect(Config::isAutoBootEnabled())->toBeFalse(); + + putenv('TYPEPHP_AUTO_BOOT=1'); + expect(Config::isAutoBootEnabled())->toBeTrue(); + }); + + test('isAutoBootEnabled reads extra.typephp.auto-boot from composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_composer_autoboot_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/autoboot-test', + 'extra' => [ + 'typephp' => [ + 'auto-boot' => false, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isAutoBootEnabled())->toBeFalse(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + + test('isAutoBootEnabled prioritizes typephp.php over composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_precedence_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/autoboot-test', + 'extra' => [ + 'typephp' => [ + 'auto-boot' => false, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + $typephpContent = <<<'PHP' + true, +]; +PHP; + file_put_contents($tempDir . '/typephp.php', $typephpContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + // typephp.php (true) overrides composer.json (false) + expect(Config::isAutoBootEnabled())->toBeTrue(); + } finally { + @unlink($tempDir . '/typephp.php'); + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + + test('isAutoBootEnabled prioritizes TYPEPHP_AUTO_BOOT environment variable over all config files', function () { + $tempDir = sys_get_temp_dir() . '/typephp_env_priority_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + $typephpContent = <<<'PHP' + false, +]; +PHP; + file_put_contents($tempDir . '/typephp.php', $typephpContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + // Environment variable (true) overrides typephp.php (false) + putenv('TYPEPHP_AUTO_BOOT=true'); + expect(Config::isAutoBootEnabled())->toBeTrue(); + } finally { + putenv('TYPEPHP_AUTO_BOOT'); + @unlink($tempDir . '/typephp.php'); + @rmdir($tempDir); + Config::reset(); + } + }); + }); + + describe('Violation Handling & Reporting Configuration', function () { + test('on_violation defaults to throw and normalizes warn/report values', function () { + expect(Config::getOnViolation())->toBe('throw') + ->and(Config::isReportMode())->toBeFalse() + ->and(Config::isWarnMode())->toBeFalse() + ; + + Config::set(['on_violation' => 'report']); + expect(Config::getOnViolation())->toBe('report') + ->and(Config::isReportMode())->toBeTrue() + ->and(Config::isWarnMode())->toBeFalse() + ; + + Config::set(['on_violation' => 'warn']); + expect(Config::getOnViolation())->toBe('warn') + ->and(Config::isReportMode())->toBeFalse() + ->and(Config::isWarnMode())->toBeTrue() + ; + + Config::set(['on_violation' => 'warning']); + expect(Config::getOnViolation())->toBe('warn') + ->and(Config::isWarnMode())->toBeTrue() + ; + }); + + test('report_file defaults to null and respects custom paths', function () { + expect(Config::getReportFile())->toBeNull(); + + Config::set(['report_file' => 'var/typephp-report.json']); + expect(Config::getReportFile())->toBe('var/typephp-report.json'); + + Config::set(['report_file' => ' ']); + expect(Config::getReportFile())->toBeNull(); + }); + + test('fail_on_report defaults to false and respects boolean overrides', function () { + expect(Config::isFailOnReportEnabled())->toBeFalse(); + + Config::set(['fail_on_report' => true]); + expect(Config::isFailOnReportEnabled())->toBeTrue(); + }); + + test('respects TYPEPHP_ON_VIOLATION, TYPEPHP_REPORT_FILE, and TYPEPHP_FAIL_ON_REPORT environment variables', function () { + putenv('TYPEPHP_ON_VIOLATION=report'); + putenv('TYPEPHP_REPORT_FILE=var/ci-report.json'); + putenv('TYPEPHP_FAIL_ON_REPORT=true'); + + expect(Config::getOnViolation())->toBe('report') + ->and(Config::isReportMode())->toBeTrue() + ->and(Config::getReportFile())->toBe('var/ci-report.json') + ->and(Config::isFailOnReportEnabled())->toBeTrue() + ; + + putenv('TYPEPHP_ON_VIOLATION=warning'); + expect(Config::getOnViolation())->toBe('warn') + ->and(Config::isWarnMode())->toBeTrue() + ; + }); + + test('reads reporting options from composer.json extra section', function () { + $tempDir = sys_get_temp_dir() . '/typephp_composer_report_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/report-config', + 'extra' => [ + 'typephp' => [ + 'on-violation' => 'report', + 'report-file' => 'var/composer-report.json', + 'fail-on-report' => true, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::getOnViolation())->toBe('report') + ->and(Config::getReportFile())->toBe('var/composer-report.json') + ->and(Config::isFailOnReportEnabled())->toBeTrue() + ; + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + }); + + describe('Global Value Redaction Configuration (redact_values)', function () { + test('isRedactValuesEnabled returns false by default', function () { + expect(Config::isRedactValuesEnabled())->toBeFalse(); + }); + + test('isRedactValuesEnabled respects runtime overrides with Config::set', function () { + Config::set(['redact_values' => true]); + expect(Config::isRedactValuesEnabled())->toBeTrue(); + + Config::set(['redact_values' => false]); + expect(Config::isRedactValuesEnabled())->toBeFalse(); + }); + + test('isRedactValuesEnabled respects TYPEPHP_REDACT_VALUES environment variable', function () { + putenv('TYPEPHP_REDACT_VALUES=true'); + expect(Config::isRedactValuesEnabled())->toBeTrue(); + + putenv('TYPEPHP_REDACT_VALUES=false'); + expect(Config::isRedactValuesEnabled())->toBeFalse(); + + putenv('TYPEPHP_REDACT_VALUES=1'); + expect(Config::isRedactValuesEnabled())->toBeTrue(); + + putenv('TYPEPHP_REDACT_VALUES=0'); + expect(Config::isRedactValuesEnabled())->toBeFalse(); + }); + + test('isRedactValuesEnabled reads extra.typephp.redact-values from composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_composer_redact_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/redact-test', + 'extra' => [ + 'typephp' => [ + 'redact-values' => true, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isRedactValuesEnabled())->toBeTrue(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + }); }); diff --git a/tests/Internal/Util/StructureMutatorTest.php b/tests/Internal/Util/StructureMutatorTest.php new file mode 100644 index 00000000..c69fb8e7 --- /dev/null +++ b/tests/Internal/Util/StructureMutatorTest.php @@ -0,0 +1,102 @@ + 5]; + $mutated = StructureMutator::mutateCopy($original, [['dim', 'count']], 10); + + expect($mutated)->toBe(['count' => 10]) + ->and($original)->toBe(['count' => 5]) + ; + }); + + test('appends to list on copy without altering original', function () { + $original = [1, 2]; + $mutated = StructureMutator::mutateCopy($original, [['dim', null]], 3); + + expect($mutated)->toBe([1, 2, 3]) + ->and($original)->toBe([1, 2]) + ; + }); + + test('mutates multi-dimensional array path on copy', function () { + $original = ['user' => ['id' => 1]]; + $mutated = StructureMutator::mutateCopy($original, [['dim', 'user'], ['dim', 'id']], 42); + + expect($mutated)->toBe(['user' => ['id' => 42]]) + ->and($original['user']['id'])->toBe(1) + ; + }); + + test('auto-vivifies missing intermediate array dimensions on copy', function () { + $original = []; + $mutated = StructureMutator::mutateCopy($original, [['dim', 'level1'], ['dim', 'level2']], 'created'); + + expect($mutated)->toBe(['level1' => ['level2' => 'created']]) + ->and($original)->toBe([]) + ; + }); + + test('mutates stdClass property on copy without altering original', function () { + $original = (object) ['name' => 'Alice']; + $mutated = StructureMutator::mutateCopy($original, [['prop', 'name']], 'Bob'); + + expect($mutated->name)->toBe('Bob') + ->and($original->name)->toBe('Alice') + ; + }); + + test('mutates nested object property path on copy and isolates intermediate objects', function () { + $geo = (object) ['city' => 'Tokyo']; + $original = (object) ['geo' => $geo]; + + $mutated = StructureMutator::mutateCopy($original, [['prop', 'geo'], ['prop', 'city']], 'Osaka'); + + expect($mutated->geo->city)->toBe('Osaka') + ->and($original->geo->city)->toBe('Tokyo') + ->and($mutated->geo)->not()->toBe($geo) + ; + }); + + test('auto-vivifies missing intermediate object properties on copy', function () { + $original = new stdClass(); + $mutated = StructureMutator::mutateCopy($original, [['prop', 'nested'], ['prop', 'title']], 'Generated'); + + expect($mutated->nested->title)->toBe('Generated') + ->and(property_exists($original, 'nested'))->toBeFalse() + ; + }); + + test('mutates hybrid array-of-objects structure on copy', function () { + $user = (object) ['id' => 1]; + $original = [ + 'users' => [$user], + ]; + + $mutated = StructureMutator::mutateCopy($original, [['dim', 'users'], ['dim', 0], ['prop', 'id']], 999); + + expect($mutated['users'][0]->id)->toBe(999) + ->and($original['users'][0]->id)->toBe(1) + ->and($mutated['users'][0])->not()->toBe($user) + ; + }); + + test('mutates hybrid object-of-arrays structure on copy', function () { + $original = (object) [ + 'meta' => ['tag' => 'old'], + ]; + + $mutated = StructureMutator::mutateCopy($original, [['prop', 'meta'], ['dim', 'tag']], 'new'); + + expect($mutated->meta['tag'])->toBe('new') + ->and($original->meta['tag'])->toBe('old') + ; + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index d5e8621a..c9008dbe 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,14 +2,45 @@ declare(strict_types=1); +use TypePHP\Internal\Reporting\ViolationCollector; use TypePHP\Internal\Util\Config; +function resetTypePHPTestEnvironment(): void +{ + putenv('TYPEPHP_AUTO_BOOT'); + putenv('TYPEPHP_AUTO_BOOT='); + putenv('TYPEPHP_ON_VIOLATION'); + putenv('TYPEPHP_ON_VIOLATION='); + putenv('TYPEPHP_REPORT_FILE'); + putenv('TYPEPHP_REPORT_FILE='); + putenv('TYPEPHP_FAIL_ON_REPORT'); + putenv('TYPEPHP_FAIL_ON_REPORT='); + putenv('TYPEPHP_REDACT_VALUES'); + putenv('TYPEPHP_REDACT_VALUES='); + + unset( + $_ENV['TYPEPHP_AUTO_BOOT'], + $_SERVER['TYPEPHP_AUTO_BOOT'], + $_ENV['TYPEPHP_ON_VIOLATION'], + $_SERVER['TYPEPHP_ON_VIOLATION'], + $_ENV['TYPEPHP_REPORT_FILE'], + $_SERVER['TYPEPHP_REPORT_FILE'], + $_ENV['TYPEPHP_FAIL_ON_REPORT'], + $_SERVER['TYPEPHP_FAIL_ON_REPORT'], + $_ENV['TYPEPHP_REDACT_VALUES'], + $_SERVER['TYPEPHP_REDACT_VALUES'] + ); + + ViolationCollector::reset(); + Config::reset(); +} + uses() ->beforeEach(function () { - Config::reset(); + resetTypePHPTestEnvironment(); }) ->afterEach(function () { - Config::reset(); + resetTypePHPTestEnvironment(); }) - ->in('Contract', 'Internal', 'Feature') + ->in(__DIR__) ; diff --git a/tests/TypeChecking/ArraysAndShapes/ArrayDimAssignmentTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayDimAssignmentTest.php new file mode 100644 index 00000000..222afaba --- /dev/null +++ b/tests/TypeChecking/ArraysAndShapes/ArrayDimAssignmentTest.php @@ -0,0 +1,190 @@ + 5]; + + expect(function () use (&$stats2) { + $stats2['count'] = 'not_an_int'; + })->toThrow(TypeError::class); + }); + + test('allows assigning a valid value to an array shape key', function () { + /** @var array{count: int, name: string} $stats */ + $stats = ['count' => 5, 'name' => 'Alice']; + + $stats['count'] = 10; + $stats['name'] = 'Bob'; + + expect($stats['count'])->toBe(10) + ->and($stats['name'])->toBe('Bob') + ; + }); + + test('throws TypeError when assigning an unexpected key to a sealed array shape', function () { + /** @var array{count: int} $stats */ + $stats = ['count' => 5]; + + expect(function () use (&$stats) { + $stats['unknown_extra'] = 'forbidden'; + })->toThrow(TypeError::class, "contains unsealed unexpected key 'unknown_extra'"); + }); + + test('throws TypeError when appending an invalid value to a list ($list[] = $val)', function () { + /** @var list $numbers */ + $numbers = [1, 2, 3]; + + expect(function () use (&$numbers) { + $numbers[] = -10; + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError when assigning to a multi-dimensional nested array shape key', function () { + /** @var array{user: array{id: positive-int, role: non-empty-string}} $config */ + $config = [ + 'user' => [ + 'id' => 10, + 'role' => 'admin', + ], + ]; + + expect(function () use (&$config) { + $config['user']['id'] = -99; + })->toThrow(TypeError::class, 'positive-int'); + + expect(function () use (&$config) { + $config['user']['role'] = ''; + })->toThrow(TypeError::class, 'non-empty-string'); + }); + + test('preserves array state when assignment throws TypeError (atomic failure)', function () { + /** @var array{count: int} $stats */ + $stats = ['count' => 5]; + + try { + $stats['count'] = 'invalid_string'; + } catch (TypeError $e) { + // Expected + } + + expect($stats['count'])->toBe(5); + }); +}); + +describe('Object Shape Property Assignment Validation ($obj->prop = $val)', function () { + test('throws TypeError when assigning an invalid value to an object shape property on stdClass', function () { + /** @var object{count: int} $stats */ + $stats = (object) ['count' => 5]; + + expect(function () use (&$stats) { + $stats->count = 'not_an_int'; + })->toThrow(TypeError::class); + }); + + test('allows assigning a valid value to an object shape property on stdClass', function () { + /** @var object{count: int, name: string} $stats */ + $stats = (object) ['count' => 5, 'name' => 'Alice']; + + $stats->count = 10; + $stats->name = 'Bob'; + + expect($stats->count)->toBe(10) + ->and($stats->name)->toBe('Bob') + ; + }); + + test('throws TypeError when assigning an unexpected property to a sealed object shape', function () { + /** @var object{count: int} $stats */ + $stats = (object) ['count' => 5]; + + expect(function () use (&$stats) { + $stats->unexpected_field = 'forbidden'; + })->toThrow(TypeError::class); + }); + + test('throws TypeError when assigning to a nested object shape property ($obj->user->id = $val)', function () { + /** @var object{user: object{id: positive-int}} $config */ + $config = (object) [ + 'user' => (object) ['id' => 1], + ]; + + expect(function () use (&$config) { + $config->user->id = -99; + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError on mixed array-inside-object shape assignment ($obj->items[] = $val)', function () { + /** @var object{items: list} $data */ + $data = (object) [ + 'items' => [1, 2, 3], + ]; + + expect(function () use (&$data) { + $data->items[] = -10; + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('throws TypeError on mixed object-inside-array shape assignment ($data[\'user\']->name = $val)', function () { + /** @var array{user: object{name: non-empty-string}} $data */ + $data = [ + 'user' => (object) ['name' => 'Alice'], + ]; + + expect(function () use (&$data) { + $data['user']->name = ''; + })->toThrow(TypeError::class, 'non-empty-string'); + }); +}); + +describe('Array Validation Strategy Configuration in Dimension Assignments (hybrid vs full)', function () { + test('catches invalid assignment on large array in hybrid mode in O(1) without missing interior elements', function () { + Config::set(['array_validation' => 'hybrid']); + + /** @var list $largeList */ + $largeList = range(1, 200); + + expect(function () use (&$largeList) { + $largeList[50] = -99; + })->toThrow(TypeError::class, 'positive-int'); + }); + + test('allows valid assignment on large array in hybrid mode', function () { + Config::set(['array_validation' => 'hybrid']); + + /** @var list $largeList */ + $largeList = range(1, 200); + + $largeList[50] = 500; + expect($largeList[50])->toBe(500); + }); + + test('catches invalid key type on generic map in hybrid mode in O(1)', function () { + Config::set(['array_validation' => 'hybrid']); + + /** @var array $map */ + $map = ['a' => 1, 'b' => 2]; + + expect(function () use (&$map) { + $map[123] = 10; + })->toThrow(TypeError::class, 'key must be of type string'); + }); + + test('validates exhaustive scan on large array in full mode', function () { + Config::set(['array_validation' => 'full']); + + /** @var list $largeList */ + $largeList = range(1, 200); + + expect(function () use (&$largeList) { + $largeList[50] = -99; + })->toThrow(TypeError::class, 'positive-int'); + }); +}); diff --git a/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php b/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php index 3e68820f..16de9614 100644 --- a/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php +++ b/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php @@ -34,4 +34,19 @@ $x = -50; })->toThrow(TypeError::class, 'Variable $x must be of type positive-int'); }); + + test('restores outer scope variable contract after exiting a bare block', function () { + /** @var positive-int $a */ + $a = 10; + { + /** @var non-empty-string $a */ + $a = 'inner'; + expect($a)->toBe('inner'); + } + + // Outside bare block, $a reverts back to outer contract: positive-int! + expect(function () use (&$a) { + $a = -1; + })->toThrow(TypeError::class, 'Variable $a must be of type positive-int'); + }); }); diff --git a/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php b/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php new file mode 100644 index 00000000..3c99c28a --- /dev/null +++ b/tests/TypeChecking/Boundaries/NativeStreamBehaviorTest.php @@ -0,0 +1,190 @@ +not()->toBeFalse(); + + $reads = []; + while (! feof($handle)) { + $reads[] = fgets($handle); + } + fclose($handle); + @unlink($path); + + expect($reads)->toBe(["a\n", "b\n", false]) + ->and(\count($reads))->toBe(3) + ; + }); + + test('feof() on empty 0-byte file is false on open and true after first read', function () { + $path = sys_get_temp_dir() . '/typephp_feof_empty_' . uniqid() . '.txt'; + file_put_contents($path, ''); + + $handle = fopen($path, 'r'); + expect(feof($handle))->toBeFalse(); + + $firstRead = fgets($handle); + expect($firstRead)->toBeFalse() + ->and(feof($handle))->toBeTrue() + ; + + fclose($handle); + @unlink($path); + }); + + test('fseek() resets feof() state to false', function () { + $path = sys_get_temp_dir() . '/typephp_fseek_test_' . uniqid() . '.txt'; + file_put_contents($path, "line1\nline2\n"); + + $handle = fopen($path, 'r'); + while (! feof($handle)) { + fgets($handle); + } + expect(feof($handle))->toBeTrue(); + + fseek($handle, 0); + expect(feof($handle))->toBeFalse(); + + $firstLine = fgets($handle); + expect($firstLine)->toBe("line1\n"); + + fclose($handle); + @unlink($path); + }); + }); + + describe('2. PharData Archive Creation without Spurious Warnings', function () { + test('creates and compresses PharData tar archive with zero warnings (User Bug Report)', function () { + $tempDir = sys_get_temp_dir() . '/typephp_phardata_' . uniqid(); + mkdir($tempDir, 0777, true); + + $sourceFile = $tempDir . '/sample.txt'; + file_put_contents($sourceFile, 'hello world content'); + + $tarPath = $tempDir . '/archive.tar'; + $gzPath = $tempDir . '/archive.tar.gz'; + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }); + + try { + $tar = new PharData($tarPath); + $tar->addFile($sourceFile, 'sample.txt'); + $tar->compress(Phar::GZ); + + expect(file_exists($tarPath))->toBeTrue() + ->and(file_exists($gzPath))->toBeTrue() + ; + } finally { + restore_error_handler(); + @unlink($sourceFile); + @unlink($tarPath); + @unlink($gzPath); + @rmdir($tempDir); + } + + expect($warnings)->toBeEmpty(); + }); + + test('@fopen() on missing file respects @ error suppression', function () { + $missing = sys_get_temp_dir() . '/non_existent_silent_' . uniqid() . '.txt'; + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + if ((error_reporting() & $errno) !== 0) { + $warnings[] = $errstr; + } + + return true; + }); + + try { + $fp = @fopen($missing, 'r'); + expect($fp)->toBeFalse(); + } finally { + restore_error_handler(); + } + + expect($warnings)->toBeEmpty(); + }); + + test('fopen() on missing file triggers warning message when not suppressed', function () { + $missing = sys_get_temp_dir() . '/non_existent_warning_' . uniqid() . '.txt'; + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }); + + try { + $fp = fopen($missing, 'r'); + expect($fp)->toBeFalse(); + } finally { + restore_error_handler(); + } + + $allWarnings = implode("\n", $warnings); + expect($allWarnings)->toContain('fopen(' . $missing . '): Failed to open stream'); + }); + }); + + describe('3. stream_select() & stream_cast() Interoperability', function () { + test('stream_select() works on handles opened through stream wrapper via stream_cast', function () { + $path = sys_get_temp_dir() . '/typephp_select_test_' . uniqid() . '.txt'; + file_put_contents($path, "content to select\n"); + + $handle = fopen($path, 'r'); + expect($handle)->not()->toBeFalse(); + + $read = [$handle]; + $write = null; + $except = null; + + $changed = @stream_select($read, $write, $except, 0, 50000); + + if ($changed !== false) { + expect($changed)->toBeGreaterThanOrEqual(0); + } + + fclose($handle); + @unlink($path); + }); + + test('stream_cast returns underlying resource when open and false when closed', function () { + $wrapper = new StreamWrapper(); + $path = sys_get_temp_dir() . '/typephp_cast_test_' . uniqid() . '.txt'; + file_put_contents($path, 'sample'); + + $openedPath = null; + $opened = $wrapper->stream_open($path, 'r', 0, $openedPath); + expect($opened)->toBeTrue(); + + $cast = $wrapper->stream_cast(STREAM_CAST_FOR_SELECT); + expect(\is_resource($cast))->toBeTrue(); + + $wrapper->stream_close(); + expect($wrapper->stream_cast(STREAM_CAST_FOR_SELECT))->toBeFalse(); + + @unlink($path); + }); + }); +}); diff --git a/tests/TypeChecking/Boundaries/ReturnByReferenceTest.php b/tests/TypeChecking/Boundaries/ReturnByReferenceTest.php new file mode 100644 index 00000000..06c0842c --- /dev/null +++ b/tests/TypeChecking/Boundaries/ReturnByReferenceTest.php @@ -0,0 +1,84 @@ +items; + } + + /** + * @return list + */ + public function &typedScores(): array + { + return $this->items; + } +} + +class ByRefGlobalHolder +{ + public static array $data = ['count' => 10]; + + public static function &getData(): array + { + return self::$data; + } +} + +/** + * Standalone function returning by reference + */ +$globalByRefList = [100]; + +function &testStandaloneReturnByRef(): array +{ + global $globalByRefList; + + return $globalByRefList; +} + +describe('Return By Reference (function &name())', function () { + test('preserves reference return and allows writing through reference without notices (User Bug Report)', function () { + $bag = new ByRefBagFixture(); + + $ref = &$bag->items(); + $ref[] = 2; + + expect($bag->items)->toBe([1, 2]); + }); + + test('preserves reference return on static methods', function () { + $ref = &ByRefGlobalHolder::getData(); + $ref['count'] = 42; + + expect(ByRefGlobalHolder::$data['count'])->toBe(42); + }); + + test('preserves reference return on standalone functions', function () { + global $globalByRefList; + $globalByRefList = [100]; + + $ref = &testStandaloneReturnByRef(); + $ref[] = 200; + + expect($globalByRefList)->toBe([100, 200]); + }); + + test('still enforces return type contract when by-ref variable violates contract on return', function () { + $bag = new ByRefBagFixture(); + $bag->items = [10, -5]; + + expect(fn () => $bag->typedScores()) + ->toThrow(TypeError::class, 'positive-int') + ; + }); +}); diff --git a/tests/TypeChecking/Boundaries/StaticPropertyCallableAndIteratorTest.php b/tests/TypeChecking/Boundaries/StaticPropertyCallableAndIteratorTest.php new file mode 100644 index 00000000..39d1b579 --- /dev/null +++ b/tests/TypeChecking/Boundaries/StaticPropertyCallableAndIteratorTest.php @@ -0,0 +1,239 @@ +|null + */ + public static mixed $iterableData = null; + + /** + * @var Traversable|null + */ + public static ?Traversable $traversableData = null; + + /** + * @var Iterator|null + */ + public static ?Iterator $iteratorData = null; + + public static function reset(): void + { + self::$closureHandler = null; + self::$staticClosure = null; + self::$callableHandler = null; + self::$iterableData = null; + self::$traversableData = null; + self::$iteratorData = null; + } + + public static function checkClosureIsset(): bool + { + return isset(self::$closureHandler); + } + + public static function checkClosureEmpty(): bool + { + return empty(self::$closureHandler); + } + + public static function checkCallableIsset(): bool + { + return isset(self::$callableHandler); + } + + public static function checkCallableEmpty(): bool + { + return empty(self::$callableHandler); + } + + public static function checkIterableIsset(): bool + { + return isset(self::$iterableData); + } + + public static function checkIterableEmpty(): bool + { + return empty(self::$iterableData); + } + + public function getBoundClosure(): Closure + { + return fn () => $this; + } +} + +describe('Static Properties with Closures, Callables, and Iterators', function () { + beforeEach(function () { + StaticCallbackAndIteratorHolder::reset(); + }); + + afterEach(function () { + StaticCallbackAndIteratorHolder::reset(); + }); + + describe('Closures on Static Properties', function () { + test('assigns valid closure and executes with isset and empty checks', function () { + expect(StaticCallbackAndIteratorHolder::checkClosureIsset())->toBeFalse() + ->and(StaticCallbackAndIteratorHolder::checkClosureEmpty())->toBeTrue() + ; + + StaticCallbackAndIteratorHolder::$closureHandler = fn (int $id): string => "order_{$id}"; + + expect(StaticCallbackAndIteratorHolder::checkClosureIsset())->toBeTrue() + ->and(StaticCallbackAndIteratorHolder::checkClosureEmpty())->toBeFalse() + ; + + $fn = StaticCallbackAndIteratorHolder::$closureHandler; + expect($fn(42))->toBe('order_42'); + }); + + test('throws TypeError when assigning non-closure to closure-typed static property', function () { + expect(function () { + StaticCallbackAndIteratorHolder::$closureHandler = 'strlen'; + })->toThrow(TypeError::class, 'Closure'); + }); + + test('validates static-closure on static property and rejects bound closures', function () { + StaticCallbackAndIteratorHolder::$staticClosure = static fn (): int => 100; + expect(StaticCallbackAndIteratorHolder::$staticClosure)->toBeInstanceOf(Closure::class); + + $holder = new StaticCallbackAndIteratorHolder(); + $boundClosure = $holder->getBoundClosure(); + + expect(function () use ($boundClosure) { + StaticCallbackAndIteratorHolder::$staticClosure = $boundClosure; + })->toThrow(TypeError::class, 'static-closure'); + + expect(function () use ($boundClosure) { + StaticCallbackAndIteratorHolder::$nonNullableStaticClosure = $boundClosure; + })->toThrow(TypeError::class, 'must be a static Closure (not bound to $this)'); + }); + }); + + describe('Callables on Static Properties', function () { + test('accepts closures, invokables, and array callables on callable static property', function () { + expect(StaticCallbackAndIteratorHolder::checkCallableIsset())->toBeFalse() + ->and(StaticCallbackAndIteratorHolder::checkCallableEmpty())->toBeTrue() + ; + + StaticCallbackAndIteratorHolder::$callableHandler = fn (int $a, int $b): int => $a * $b; + expect(StaticCallbackAndIteratorHolder::checkCallableIsset())->toBeTrue(); + + $fn = StaticCallbackAndIteratorHolder::$callableHandler; + expect($fn(3, 4))->toBe(12); + + StaticCallbackAndIteratorHolder::$callableHandler = new InvokableCalculatorFixture(); + $invokable = StaticCallbackAndIteratorHolder::$callableHandler; + expect($invokable(10, 20))->toBe(30); + + StaticCallbackAndIteratorHolder::$callableHandler = [StaticCallbackAndIteratorHolder::class, 'reset']; + expect(StaticCallbackAndIteratorHolder::checkCallableIsset())->toBeTrue(); + }); + + test('throws TypeError when assigning non-callable to callable static property', function () { + expect(function () { + StaticCallbackAndIteratorHolder::$callableHandler = 'non_existent_function_xyz'; + })->toThrow(TypeError::class, 'callable'); + + expect(function () { + StaticCallbackAndIteratorHolder::$callableHandler = new stdClass(); + })->toThrow(TypeError::class, 'callable'); + }); + }); + + describe('Iterators, Traversables, and Iterables on Static Properties', function () { + test('assigns valid typed iterable and validates iteration with isset and empty checks', function () { + expect(StaticCallbackAndIteratorHolder::checkIterableIsset())->toBeFalse() + ->and(StaticCallbackAndIteratorHolder::checkIterableEmpty())->toBeTrue() + ; + + StaticCallbackAndIteratorHolder::$iterableData = [ + 'first' => 10, + 'second' => 20, + ]; + + expect(StaticCallbackAndIteratorHolder::checkIterableIsset())->toBeTrue() + ->and(StaticCallbackAndIteratorHolder::checkIterableEmpty())->toBeFalse() + ; + + $collected = []; + foreach (StaticCallbackAndIteratorHolder::$iterableData as $k => $v) { + $collected[$k] = $v; + } + expect($collected)->toBe(['first' => 10, 'second' => 20]); + }); + + test('accepts ArrayIterator for iterable and Traversable static properties', function () { + $iterator = new ArrayIterator([ + 'alpha' => 100, + 'beta' => 200, + ]); + + StaticCallbackAndIteratorHolder::$iterableData = $iterator; + expect(StaticCallbackAndIteratorHolder::$iterableData)->toBe($iterator); + + $traversable = new ArrayIterator([10, 20, 30]); + StaticCallbackAndIteratorHolder::$traversableData = $traversable; + expect(StaticCallbackAndIteratorHolder::$traversableData)->toBe($traversable); + }); + + test('throws TypeError when assigning array violating typed iterable elements', function () { + expect(function () { + StaticCallbackAndIteratorHolder::$iterableData = [ + 'valid' => 10, + 'invalid' => -5, + ]; + })->toThrow(TypeError::class, "['invalid'] must be of type positive-int"); + }); + + test('throws TypeError when assigning non-iterable to iterable static property', function () { + expect(function () { + StaticCallbackAndIteratorHolder::$iterableData = 'not_iterable'; + })->toThrow(TypeError::class, 'iterable'); + + expect(function () { + StaticCallbackAndIteratorHolder::$traversableData = new stdClass(); + })->toThrow(TypeError::class); + }); + }); +}); diff --git a/tests/TypeChecking/Boundaries/StaticPropertyIssetTest.php b/tests/TypeChecking/Boundaries/StaticPropertyIssetTest.php new file mode 100644 index 00000000..f8372c95 --- /dev/null +++ b/tests/TypeChecking/Boundaries/StaticPropertyIssetTest.php @@ -0,0 +1,115 @@ + + */ + public static array $cache = []; + + public static function reset(): void + { + self::$name = null; + self::$counter = null; + self::$cache = []; + } + + public function get(): string + { + if (! isset(self::$name)) { + self::$name = self::class; + } + + return self::$name; + } + + public function checkMultipleIsset(): bool + { + return isset(self::$name, self::$counter); + } + + public function checkCacheKey(string $key): bool + { + return isset(self::$cache[$key]); + } + + public function checkEmpty(): bool + { + return empty(self::$name); + } + + public function &getRef(): ?string + { + return self::$name; + } +} + +describe('Static Property isset(), empty(), and by-ref Assign', function () { + beforeEach(function () { + HolderWithStaticIsset::reset(); + }); + + test('compiles and executes if (!isset(self::$prop)) without expression fatal error (User Bug Report)', function () { + $holder = new HolderWithStaticIsset(); + + expect($holder->get())->toBe(HolderWithStaticIsset::class); + }); + + test('supports multiple static properties in isset(self::$a, self::$b)', function () { + $holder = new HolderWithStaticIsset(); + + expect($holder->checkMultipleIsset())->toBeFalse(); + + HolderWithStaticIsset::$counter = 10; + $holder->get(); // initializes self::$name + + expect($holder->checkMultipleIsset())->toBeTrue(); + }); + + test('supports isset(self::$cache[$key]) on static array dimensions', function () { + $holder = new HolderWithStaticIsset(); + + expect($holder->checkCacheKey('token'))->toBeFalse(); + + HolderWithStaticIsset::$cache['token'] = 123; + expect($holder->checkCacheKey('token'))->toBeTrue(); + }); + + test('supports empty(self::$prop) on static property without compile error', function () { + $holder = new HolderWithStaticIsset(); + + expect($holder->checkEmpty())->toBeTrue(); + $holder->get(); + expect($holder->checkEmpty())->toBeFalse(); + }); + + test('supports assigning static property by reference ($ref = &self::$prop)', function () { + $holder = new HolderWithStaticIsset(); + $holder->get(); + + $ref = &$holder->getRef(); + $ref = 'mutated_via_ref'; + + expect($holder->get())->toBe('mutated_via_ref'); + }); + + test('still enforces type validation on normal reads of the static property outside isset', function () { + HolderWithStaticIsset::$counter = 42; + expect(HolderWithStaticIsset::$counter)->toBe(42); + + expect(function () { + HolderWithStaticIsset::$counter = -99; + })->toThrow(TypeError::class, 'positive-int'); + }); +}); diff --git a/tests/TypeChecking/Configuration/ConditionalReturnOnPrivateMethodTest.php b/tests/TypeChecking/Configuration/ConditionalReturnOnPrivateMethodTest.php new file mode 100644 index 00000000..6c171058 --- /dev/null +++ b/tests/TypeChecking/Configuration/ConditionalReturnOnPrivateMethodTest.php @@ -0,0 +1,264 @@ + : list) + */ + private function resultPrivate(object $command): array + { + return [new stdClass()]; + } + + /** + * @return ($command is FixtureWithCount ? list : list) + */ + private function resultPrivateInts(object $command): array + { + return [10, 20]; + } + + /** + * @return ($command is FixtureWithCount ? list : list) + */ + protected function resultProtected(object $command): array + { + return [new stdClass()]; + } + + /** + * @return ($command is FixtureWithCount ? list : list) + */ + protected function resultProtectedInts(object $command): array + { + return [10, 20]; + } + + /** + * @return ($command is FixtureWithCount ? list : list) + */ + public function resultPublic(object $command): array + { + return [new stdClass()]; + } + + /** + * @return ($command is FixtureWithCount ? list : list) + */ + public function resultPublicInts(object $command): array + { + return [10, 20]; + } + + /** + * @return ($limit is 1 ? object : list) + */ + private function slicePrivate(int $limit): object|array + { + if ($limit === 1) { + return new stdClass(); + } + + return [new stdClass(), new stdClass()]; + } + + /** + * @return ($limit is 1 ? object : list) + */ + protected function sliceProtected(int $limit): object|array + { + if ($limit === 1) { + return new stdClass(); + } + + return [new stdClass(), new stdClass()]; + } + + /** + * @param mixed $command + * + * @return ($command is FixtureWithCount ? list : list) + */ + private function resultWithMixedParam(mixed $command): array + { + return [new stdClass()]; + } + + public function runPrivateCounted(): array + { + return $this->resultPrivate(new FixtureCounted()); + } + + public function runPrivateUncountedValid(): array + { + return $this->resultPrivateInts(new FixtureUncounted()); + } + + public function runPrivateUncountedInvalid(): array + { + return $this->resultPrivate(new FixtureUncounted()); + } + + public function runPrivateSlice(int $limit): object|array + { + return $this->slicePrivate($limit); + } + + public function runMixedParam(): array + { + return $this->resultWithMixedParam(new FixtureCounted()); + } + + public function runProtectedCounted(): array + { + return $this->resultProtected(new FixtureCounted()); + } + + public function runProtectedUncountedValid(): array + { + return $this->resultProtectedInts(new FixtureUncounted()); + } + + public function runProtectedUncountedInvalid(): array + { + return $this->resultProtected(new FixtureUncounted()); + } + + public function runProtectedSlice(int $limit): object|array + { + return $this->sliceProtected($limit); + } +} + +/** + * Standalone function with conditional return and no @param tag + * + * @return ($command is FixtureWithCount ? list : list) + */ +function testStandaloneConditionalWithoutParamTag(object $command): array +{ + return [new stdClass()]; +} + +describe('Conditional Return Types Across Method Visibilities (private, protected, public)', function () { + describe('Private Methods', function () { + test('evaluates true branch of parameter conditional on private method (User Bug Report)', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + $result = $finder->runPrivateCounted(); + + expect($result)->toBeArray() + ->and($result[0])->toBeInstanceOf(stdClass::class) + ; + }); + + test('evaluates false branch of parameter conditional on private method when condition fails', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->runPrivateUncountedValid())->toBe([10, 20]); + + expect(fn () => $finder->runPrivateUncountedInvalid()) + ->toThrow(TypeError::class, 'must be of type int, stdClass returned') + ; + }); + + test('evaluates literal value condition on private method ($limit is 1 ? object : list)', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->runPrivateSlice(1))->toBeInstanceOf(stdClass::class) + ->and($finder->runPrivateSlice(5))->toBeArray() + ->and(\count($finder->runPrivateSlice(5)))->toBe(2) + ; + }); + + test('evaluates parameter conditional on private method when parameter has @param mixed', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->runMixedParam())->toBeArray() + ->and($finder->runMixedParam()[0])->toBeInstanceOf(stdClass::class) + ; + }); + }); + + describe('Protected Methods', function () { + test('evaluates true branch of parameter conditional on protected method', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + $result = $finder->runProtectedCounted(); + + expect($result)->toBeArray() + ->and($result[0])->toBeInstanceOf(stdClass::class) + ; + }); + + test('evaluates false branch of parameter conditional on protected method when condition fails', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->runProtectedUncountedValid())->toBe([10, 20]); + + expect(fn () => $finder->runProtectedUncountedInvalid()) + ->toThrow(TypeError::class, 'must be of type int, stdClass returned') + ; + }); + + test('evaluates literal value condition on protected method ($limit is 1 ? object : list)', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->runProtectedSlice(1))->toBeInstanceOf(stdClass::class) + ->and($finder->runProtectedSlice(5))->toBeArray() + ->and(\count($finder->runProtectedSlice(5)))->toBe(2) + ; + }); + }); + + describe('Public Methods', function () { + test('evaluates true branch of parameter conditional on public method', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + $result = $finder->resultPublic(new FixtureCounted()); + + expect($result)->toBeArray() + ->and($result[0])->toBeInstanceOf(stdClass::class) + ; + }); + + test('evaluates false branch of parameter conditional on public method when condition fails', function () { + $finder = new ConditionalMethodVisibilityFinder(); + + expect($finder->resultPublicInts(new FixtureUncounted()))->toBe([10, 20]); + + expect(fn () => $finder->resultPublic(new FixtureUncounted())) + ->toThrow(TypeError::class, 'must be of type int, stdClass returned') + ; + }); + }); + + describe('Standalone Functions', function () { + test('evaluates parameter conditional on standalone functions without @param tag', function () { + $result = testStandaloneConditionalWithoutParamTag(new FixtureCounted()); + + expect($result)->toBeArray() + ->and($result[0])->toBeInstanceOf(stdClass::class) + ; + }); + }); +}); diff --git a/tests/TypeChecking/Configuration/RedactValuesConfigTest.php b/tests/TypeChecking/Configuration/RedactValuesConfigTest.php new file mode 100644 index 00000000..7903b256 --- /dev/null +++ b/tests/TypeChecking/Configuration/RedactValuesConfigTest.php @@ -0,0 +1,264 @@ + $percentage + */ +function testRedactStandaloneFunction(string $rawSecret, int $secretCode, int $percentage): string +{ + return "{$rawSecret}:{$secretCode}:{$percentage}"; +} + +/** + * @param class-string<\DateTimeInterface> $class + */ +function testRedactClassStringFunction(string $class): string +{ + return $class; +} + +describe('Global Value Redaction (redact_values => true)', function () { + beforeEach(function () { + putenv('TYPEPHP_REDACT_VALUES='); + unset($_ENV['TYPEPHP_REDACT_VALUES'], $_SERVER['TYPEPHP_REDACT_VALUES']); + Config::reset(); + TypePHP::clearViolations(); + }); + + afterEach(function () { + putenv('TYPEPHP_REDACT_VALUES='); + unset($_ENV['TYPEPHP_REDACT_VALUES'], $_SERVER['TYPEPHP_REDACT_VALUES']); + TypePHP::clearViolations(); + Config::reset(); + }); + + describe('1. Default Behavior (redact_values => false)', function () { + test('shows raw value details in TypeError when redact_values is false (default)', function () { + $rawSecret = 'my_super_secret_password_123'; + + try { + testRedactStandaloneFunction('', -42, 150); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain("empty string ('') given"); + } + + expect($failed)->toBeTrue(); + }); + }); + + describe('2. Global Redaction Enabled (redact_values => true)', function () { + test('redacts raw string parameters without #[SensitiveParameter] annotation', function () { + Config::set(['redact_values' => true]); + + $rawSecret = 'super_confidential_credit_card_41111111'; + + try { + testRedactStandaloneFunction('wrong_token', -42, 50); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('int given') + ->and($e->getMessage())->not()->toContain('-42') + ->and($e->getMessage())->not()->toContain('negative int') + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts raw string values on empty string violations', function () { + Config::set(['redact_values' => true]); + + try { + testRedactStandaloneFunction('', 10, 50); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('string given') + ->and($e->getMessage())->not()->toContain("empty string ('')") + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts integer values in int range violations', function () { + Config::set(['redact_values' => true]); + + try { + testRedactStandaloneFunction('valid_token', 10, 250); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('int given') + ->and($e->getMessage())->not()->toContain('250') + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts class-string values in class-string violations', function () { + Config::set(['redact_values' => true]); + + try { + testRedactClassStringFunction(\stdClass::class); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('string given') + ->and($e->getMessage())->not()->toContain(\stdClass::class . "' given") + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts return value in return type contract violations', function () { + Config::set(['redact_values' => true]); + $model = new RedactValuesModelFixture(); + + try { + $model->getNegativeNumber(); + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('int returned') + ->and($e->getMessage())->not()->toContain('-9999') + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts property values on property assignment violations', function () { + Config::set(['redact_values' => true]); + $model = new RedactValuesModelFixture(); + + try { + $model->secretProperty = ''; + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('string given') + ->and($e->getMessage())->not()->toContain("empty string ('')") + ; + } + + expect($failed)->toBeTrue(); + }); + + test('redacts local variable values on inline @var violations', function () { + Config::set(['redact_values' => true]); + + try { + /** @var positive-int $secretCode */ + $secretCode = -500; + $failed = false; + } catch (TypeError $e) { + $failed = true; + expect($e->getMessage())->toContain('int given') + ->and($e->getMessage())->not()->toContain('-500') + ; + } + + expect($failed)->toBeTrue(); + }); + }); + + describe('3. Audit Report Mode with Global Redaction (on_violation => report)', function () { + test('redacts raw value from JSON violation record in report mode', function () { + Config::set([ + 'on_violation' => 'report', + 'redact_values' => true, + ]); + + testRedactStandaloneFunction('', -999, 150); + + $violations = TypePHP::getViolations(); + expect($violations)->not()->toBeEmpty() + ->and($violations[0]->given)->toBe('string') + ->and($violations[0]->given)->not()->toContain("empty string ('')") + ; + }); + }); + + describe('4. Environment Variable & Composer.json Configuration', function () { + test('respects TYPEPHP_REDACT_VALUES environment variable', function () { + putenv('TYPEPHP_REDACT_VALUES=true'); + + expect(Config::isRedactValuesEnabled())->toBeTrue(); + + putenv('TYPEPHP_REDACT_VALUES=false'); + expect(Config::isRedactValuesEnabled())->toBeFalse(); + }); + + test('reads extra.typephp.redact-values from composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_redact_composer_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/redact-values-config', + 'extra' => [ + 'typephp' => [ + 'redact-values' => true, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isRedactValuesEnabled())->toBeTrue(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + }); +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/CrossNamespaceTraitPropertyTest.php b/tests/TypeChecking/InheritanceAndAttributes/CrossNamespaceTraitPropertyTest.php new file mode 100644 index 00000000..56346daa --- /dev/null +++ b/tests/TypeChecking/InheritanceAndAttributes/CrossNamespaceTraitPropertyTest.php @@ -0,0 +1,28 @@ +setOwner(); + + expect($account->owner)->toBeInstanceOf(CrossNamespaceUser::class); + }); + + test('still enforces type validation when assigning invalid object across namespaces', function () { + $account = new CrossNamespaceAccount(); + + expect(function () use ($account) { + $account->owner = new Car(); + })->toThrow(TypeError::class, CrossNamespaceUser::class); + }); +}); diff --git a/tests/TypeChecking/InheritanceAndAttributes/NestedTraitPropertyResolutionTest.php b/tests/TypeChecking/InheritanceAndAttributes/NestedTraitPropertyResolutionTest.php new file mode 100644 index 00000000..5fd72518 --- /dev/null +++ b/tests/TypeChecking/InheritanceAndAttributes/NestedTraitPropertyResolutionTest.php @@ -0,0 +1,28 @@ +setGroup([$agency]); + + expect($agency->group)->toHaveCount(1) + ->and($agency->group[0])->toBeInstanceOf(NestedEntity::class) + ; + }); + + test('resolves non-array property @var against declaring trait imports in nested traits', function () { + $agency = new NestedAgency(); + + $agency->setSingle($agency); + + expect($agency->singleEntity)->toBe($agency); + }); +}); diff --git a/tests/Unit/AutoBootConfigurationTest.php b/tests/Unit/AutoBootConfigurationTest.php new file mode 100644 index 00000000..83e2725f --- /dev/null +++ b/tests/Unit/AutoBootConfigurationTest.php @@ -0,0 +1,109 @@ + false]); + expect(Config::isAutoBootEnabled())->toBeFalse(); + + TypePHP::boot(); + + expect(StreamWrapper::isRegistered())->toBeTrue(); + }); + + test('isAutoBootEnabled handles boolean strings in composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_autoboot_string_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/autoboot-string', + 'extra' => [ + 'typephp' => [ + 'auto-boot' => 'false', + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isAutoBootEnabled())->toBeFalse(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + + test('isAutoBootEnabled handles true in composer.json', function () { + $tempDir = sys_get_temp_dir() . '/typephp_autoboot_true_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/autoboot-true', + 'extra' => [ + 'typephp' => [ + 'auto-boot' => true, + ], + ], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isAutoBootEnabled())->toBeTrue(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); + + test('bypasses json parsing completely if composer.json does not contain auto-boot keyword', function () { + $tempDir = sys_get_temp_dir() . '/typephp_autoboot_skip_' . uniqid(); + mkdir($tempDir, 0777, true); + + $composerJsonContent = json_encode([ + 'name' => 'test/autoboot-skip', + 'require' => ['php' => '^8.1'], + ]); + file_put_contents($tempDir . '/composer.json', $composerJsonContent); + + try { + Config::reset(); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('projectRoot'); + $prop->setValue(null, $tempDir); + + expect(Config::isAutoBootEnabled())->toBeTrue(); + } finally { + @unlink($tempDir . '/composer.json'); + @rmdir($tempDir); + Config::reset(); + } + }); +}); diff --git a/typephp.php b/typephp.php index 896101cd..09d085c5 100644 --- a/typephp.php +++ b/typephp.php @@ -20,6 +20,42 @@ */ 'enabled' => true, + /* + |-------------------------------------------------------------------------- + | Auto-Boot on Composer Autoload + |-------------------------------------------------------------------------- + | When true (default), TypePHP automatically hooks into the stream wrapper + | as soon as 'vendor/autoload.php' is required. + | + | Set to false to disable auto-booting project-wide. You can then manually + | boot TypePHP where desired (e.g. in 'tests/bootstrap.php') via: + | \TypePHP\TypePHP::boot(); + | + | Can also be configured in root composer.json: + | "extra": { "typephp": { "auto-boot": false } } + */ + 'auto_boot' => true, + + /* + |-------------------------------------------------------------------------- + | Violation Handling Strategy & Audit Reporting + |-------------------------------------------------------------------------- + | Controls what happens when a type contract is violated at runtime: + | + | - 'throw' : (Default / Strict) Immediately throws TypePHP\Exception\TypeError. + | - 'report' : (Audit Mode) Does not throw; records each unique violation + | and dumps a structured JSON audit report to 'report_file' at shutdown. + | - 'warn' : Emits PHP E_USER_WARNING once per unique violation to your logs + | and allows execution to continue normally without crashing. + | + | 'report_file' : Sets the target JSON output path when 'report' mode is active. + | 'fail_on_report' : When true, terminates the process with exit code 1 at shutdown + | if the violation report is non-empty. Useful for CI gates. + */ + 'on_violation' => 'throw', + 'report_file' => null, + 'fail_on_report' => false, + /* |-------------------------------------------------------------------------- | Function Boundary Contracts (@param & @return)