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