diff --git a/docs/running_psalm/issues/RiskyTruthyFalsyComparison.md b/docs/running_psalm/issues/RiskyTruthyFalsyComparison.md index 8d60969633e..601b870c038 100644 --- a/docs/running_psalm/issues/RiskyTruthyFalsyComparison.md +++ b/docs/running_psalm/issues/RiskyTruthyFalsyComparison.md @@ -1,6 +1,6 @@ # RiskyTruthyFalsyComparison -Emitted when comparing a value with multiple types that can both contain truthy and falsy values. +Emitted when comparing a value with multiple types, where at least one type can be only truthy or falsy and other types can contain both truthy and falsy values. ```php - + tags['variablesfrom'][0]]]> @@ -862,8 +862,6 @@ self]]> mixin_declaring_fqcln]]> - parent_class]]> - parent_class]]> calling_method_id]]> calling_method_id]]> self]]> @@ -1194,11 +1192,6 @@ - - - value]]> - - @@ -1918,9 +1911,6 @@ strings]]> strings]]> strings]]> - value_types['string'] instanceof TNonFalsyString - ? $type->value - : $type->value !== '']]> @@ -2227,7 +2217,6 @@ - diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index ee95eb1b00e..26ecfbadcc2 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -2197,6 +2197,10 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress foreach ($stub_files as $file_path) { $file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path); + // fix mangled phar paths on Windows + if (strpos($file_path, 'phar:\\\\') === 0) { + $file_path = 'phar://'. substr($file_path, 7); + } $codebase->scanner->addFileToDeepScan($file_path); } @@ -2282,6 +2286,10 @@ public function visitStubFiles(Codebase $codebase, ?Progress $progress = null): foreach ($stub_files as $file_path) { $file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path); + // fix mangled phar paths on Windows + if (strpos($file_path, 'phar:\\\\') === 0) { + $file_path = 'phar://' . substr($file_path, 7); + } $codebase->scanner->addFileToDeepScan($file_path); } diff --git a/src/Psalm/Internal/Analyzer/FileAnalyzer.php b/src/Psalm/Internal/Analyzer/FileAnalyzer.php index c7ed9cedc2b..8c17c0e7281 100644 --- a/src/Psalm/Internal/Analyzer/FileAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FileAnalyzer.php @@ -146,8 +146,7 @@ public function analyze( return; } - $event = new BeforeFileAnalysisEvent($this, $this->context, $file_storage, $codebase); - + $event = new BeforeFileAnalysisEvent($this, $this->context, $file_storage, $codebase, $stmts); $codebase->config->eventDispatcher->dispatchBeforeFileAnalysis($event); if ($codebase->alter_code) { diff --git a/src/Psalm/Internal/Analyzer/MethodAnalyzer.php b/src/Psalm/Internal/Analyzer/MethodAnalyzer.php index 1c68bee7a7b..b487a5bc6b0 100644 --- a/src/Psalm/Internal/Analyzer/MethodAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/MethodAnalyzer.php @@ -15,6 +15,7 @@ use Psalm\Issue\InvalidStaticInvocation; use Psalm\Issue\MethodSignatureMustOmitReturnType; use Psalm\Issue\NonStaticSelfCall; +use Psalm\Issue\UndefinedMagicMethod; use Psalm\Issue\UndefinedMethod; use Psalm\IssueBuffer; use Psalm\StatementsSource; @@ -114,8 +115,9 @@ public static function checkStatic( } $original_method_id = $method_id; + $with_pseudo = true; - $method_id = $codebase_methods->getDeclaringMethodId($method_id); + $method_id = $codebase_methods->getDeclaringMethodId($method_id, $with_pseudo); if (!$method_id) { if (InternalCallMapHandler::inCallMap((string) $original_method_id)) { @@ -125,7 +127,7 @@ public static function checkStatic( throw new LogicException('Declaring method for ' . $original_method_id . ' should not be null'); } - $storage = $codebase_methods->getStorage($method_id); + $storage = $codebase_methods->getStorage($method_id, $with_pseudo); if (!$storage->is_static) { if ($self_call) { @@ -170,6 +172,7 @@ public static function checkMethodExists( CodeLocation $code_location, array $suppressed_issues, ?string $calling_method_id = null, + bool $with_pseudo = false, ): ?bool { if ($codebase->methods->methodExists( $method_id, @@ -180,15 +183,31 @@ public static function checkMethodExists( : null, null, $code_location->file_path, + true, + false, + $with_pseudo, )) { return true; } - if (IssueBuffer::accepts( - new UndefinedMethod('Method ' . $method_id . ' does not exist', $code_location, (string) $method_id), - $suppressed_issues, - )) { - return false; + if ($with_pseudo) { + if (IssueBuffer::accepts( + new UndefinedMagicMethod( + 'Magic method ' . $method_id . ' does not exist', + $code_location, + (string) $method_id, + ), + $suppressed_issues, + )) { + return false; + } + } else { + if (IssueBuffer::accepts( + new UndefinedMethod('Method ' . $method_id . ' does not exist', $code_location, (string) $method_id), + $suppressed_issues, + )) { + return false; + } } return null; diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/IfConditionalAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/IfConditionalAnalyzer.php index 8e0e23ec18d..59d0fb92bc1 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/IfConditionalAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/IfConditionalAnalyzer.php @@ -374,16 +374,18 @@ public static function handleParadoxicalCondition( && !($stmt instanceof PhpParser\Node\Expr\BinaryOp\Identical) && !($stmt instanceof PhpParser\Node\Expr\BooleanNot)) { if (count($type->getAtomicTypes()) > 1) { + $has_truthy_or_falsy_exclusive_type = false; $both_types = $type->getBuilder(); foreach ($both_types->getAtomicTypes() as $key => $atomic_type) { if ($atomic_type->isTruthy() || $atomic_type->isFalsy() || $atomic_type instanceof TBool) { $both_types->removeType($key); + $has_truthy_or_falsy_exclusive_type = true; } } - if (count($both_types->getAtomicTypes()) > 0) { + if (count($both_types->getAtomicTypes()) > 0 && $has_truthy_or_falsy_exclusive_type) { $both_types = $both_types->freeze(); IssueBuffer::maybeAdd( new RiskyTruthyFalsyComparison( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php index 4557e1cfebf..9a782307f13 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php @@ -34,7 +34,6 @@ use Psalm\Type\Union; use UnexpectedValueException; -use function array_merge; use function in_array; use function strlen; @@ -173,14 +172,6 @@ public static function analyze( $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event); if ($stmt_left_type && $stmt_left_type->parent_nodes) { - // numeric types can't be tainted html or has_quotes, neither can bool - if ($statements_analyzer->data_flow_graph instanceof TaintFlowGraph - && $stmt_left_type->isSingle() - && ($stmt_left_type->isInt() || $stmt_left_type->isFloat() || $stmt_left_type->isBool()) - ) { - $removed_taints = array_merge($removed_taints, array('html', 'has_quotes')); - } - foreach ($stmt_left_type->parent_nodes as $parent_node) { $statements_analyzer->data_flow_graph->addPath( $parent_node, @@ -193,14 +184,6 @@ public static function analyze( } if ($stmt_right_type && $stmt_right_type->parent_nodes) { - // numeric types can't be tainted html or has_quotes, neither can bool - if ($statements_analyzer->data_flow_graph instanceof TaintFlowGraph - && $stmt_right_type->isSingle() - && ($stmt_right_type->isInt() || $stmt_right_type->isFloat() || $stmt_right_type->isBool()) - ) { - $removed_taints = array_merge($removed_taints, array('html', 'has_quotes')); - } - foreach ($stmt_right_type->parent_nodes as $parent_node) { $statements_analyzer->data_flow_graph->addPath( $parent_node, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BooleanNotAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BooleanNotAnalyzer.php index 1f36642ecab..10f3a9c7f92 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BooleanNotAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BooleanNotAnalyzer.php @@ -48,16 +48,18 @@ public static function analyze( $stmt_type = new TTrue($expr_type->from_docblock); } else { if (count($expr_type->getAtomicTypes()) > 1) { + $has_truthy_or_falsy_exclusive_type = false; $both_types = $expr_type->getBuilder(); foreach ($both_types->getAtomicTypes() as $key => $atomic_type) { if ($atomic_type->isTruthy() || $atomic_type->isFalsy() || $atomic_type instanceof TBool) { $both_types->removeType($key); + $has_truthy_or_falsy_exclusive_type = true; } } - if (count($both_types->getAtomicTypes()) > 0) { + if (count($both_types->getAtomicTypes()) > 0 && $has_truthy_or_falsy_exclusive_type) { $both_types = $both_types->freeze(); IssueBuffer::maybeAdd( new RiskyTruthyFalsyComparison( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php index c1d4a3f6b85..9bb21d65e97 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php @@ -61,7 +61,6 @@ use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Union; -use function array_merge; use function count; use function explode; use function implode; @@ -1528,19 +1527,19 @@ private static function processTaintedness( return; } - $event = new AddRemoveTaintsEvent($expr, $context, $statements_analyzer, $codebase); - - $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event); - $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event); - - // numeric types can't be tainted html or has_quotes, neither can bool + // numeric types can't be tainted, neither can bool if ($statements_analyzer->data_flow_graph instanceof TaintFlowGraph && $input_type->isSingle() && ($input_type->isInt() || $input_type->isFloat() || $input_type->isBool()) ) { - $removed_taints = array_merge($removed_taints, array('html', 'has_quotes')); + return; } + $event = new AddRemoveTaintsEvent($expr, $context, $statements_analyzer, $codebase); + + $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event); + $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event); + if ($function_param->type && $function_param->type->isString() && !$input_type->isString()) { $input_type = CastAnalyzer::castStringAttempt( $statements_analyzer, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodVisibilityAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodVisibilityAnalyzer.php index abfe8c080d1..8e047dedebd 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodVisibilityAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodVisibilityAnalyzer.php @@ -42,6 +42,8 @@ public static function analyze( $fq_classlike_name = $method_id->fq_class_name; $method_name = $method_id->method_name; + $with_pseudo = true; + if ($codebase_methods->visibility_provider->has($fq_classlike_name)) { $method_visible = $codebase_methods->visibility_provider->isMethodVisible( $source, @@ -67,7 +69,7 @@ public static function analyze( } } - $declaring_method_id = $codebase_methods->getDeclaringMethodId($method_id); + $declaring_method_id = $codebase_methods->getDeclaringMethodId($method_id, $with_pseudo); if (!$declaring_method_id) { if ($method_name === '__construct' @@ -111,7 +113,7 @@ public static function analyze( return null; } - $storage = $codebase->methods->getStorage($declaring_method_id); + $storage = $codebase->methods->getStorage($declaring_method_id, $with_pseudo); $visibility = $storage->visibility; if ($appearing_method_name diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php index d770de533cd..20202e189cd 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -34,12 +34,8 @@ use Psalm\Issue\UndefinedClass; use Psalm\Issue\UndefinedMethod; use Psalm\IssueBuffer; -use Psalm\Node\Expr\VirtualArray; use Psalm\Node\Expr\VirtualMethodCall; use Psalm\Node\Expr\VirtualVariable; -use Psalm\Node\Scalar\VirtualString; -use Psalm\Node\VirtualArg; -use Psalm\Node\VirtualArrayItem; use Psalm\Storage\ClassLikeStorage; use Psalm\Storage\MethodStorage; use Psalm\Type; @@ -59,7 +55,6 @@ use Psalm\Type\Union; use function array_filter; -use function array_map; use function array_values; use function assert; use function count; @@ -564,6 +559,49 @@ private static function handleNamedCall( return true; } + $callstatic_id = new MethodIdentifier( + $fq_class_name, + '__callstatic', + ); + + $callstatic_method_exists = $codebase->methods->methodExists($callstatic_id); + + $with_pseudo = $callstatic_method_exists + || $codebase->config->use_phpdoc_method_without_magic_or_parent; + + if ($codebase->methods->getDeclaringMethodId($method_id, $with_pseudo)) { + if ((!$stmt->class instanceof PhpParser\Node\Name + || $stmt->class->getFirst() !== 'parent' + || $statements_analyzer->isStatic()) + && ( + !$context->self + || $statements_analyzer->isStatic() + || !$codebase->classExtends($context->self, $fq_class_name) + ) + ) { + MethodAnalyzer::checkStatic( + $method_id, + ($stmt->class instanceof PhpParser\Node\Name + && strtolower($stmt->class->getFirst()) === 'self') + || $context->self === $fq_class_name, + !$statements_analyzer->isStatic(), + $codebase, + new CodeLocation($statements_analyzer, $stmt), + $statements_analyzer->getSuppressedIssues(), + $is_dynamic_this_method, + ); + + if ($is_dynamic_this_method) { + return self::forwardCallToInstanceMethod( + $statements_analyzer, + $stmt, + $stmt_name, + $context, + ); + } + } + } + if (!$naive_method_exists || !MethodAnalyzer::isMethodVisible( $method_id, @@ -571,28 +609,9 @@ private static function handleNamedCall( $statements_analyzer->getSource(), ) || $fake_method_exists - || ($found_method_and_class_storage - && ($config->use_phpdoc_method_without_magic_or_parent || $class_storage->parent_class)) + || $found_method_and_class_storage ) { - $callstatic_id = new MethodIdentifier( - $fq_class_name, - '__callstatic', - ); - - if ($codebase->methods->methodExists( - $callstatic_id, - $context->calling_method_id, - $codebase->collect_locations - ? new CodeLocation($statements_analyzer, $stmt_name) - : null, - !$context->collect_initializations - && !$context->collect_mutations - ? $statements_analyzer - : null, - $statements_analyzer->getFilePath(), - true, - $context->insideUse(), - )) { + if ($callstatic_method_exists) { $callstatic_declaring_id = $codebase->methods->getDeclaringMethodId($callstatic_id); assert($callstatic_declaring_id !== null); $callstatic_pure = false; @@ -693,39 +712,7 @@ private static function handleNamedCall( return false; } } - - $array_values = array_map( - static fn(PhpParser\Node\Arg $arg): PhpParser\Node\ArrayItem => new VirtualArrayItem( - $arg->value, - null, - false, - $arg->getAttributes(), - ), - $args, - ); - - $args = [ - new VirtualArg( - new VirtualString((string) $method_id, $stmt_name->getAttributes()), - false, - false, - $stmt_name->getAttributes(), - ), - new VirtualArg( - new VirtualArray($array_values, $stmt->getAttributes()), - false, - false, - $stmt->getAttributes(), - ), - ]; - - $method_id = new MethodIdentifier( - $fq_class_name, - '__callstatic', - ); - } elseif ($found_method_and_class_storage - && ($config->use_phpdoc_method_without_magic_or_parent || $class_storage->parent_class) - ) { + } elseif ($found_method_and_class_storage && ($naive_method_exists || $with_pseudo)) { [$pseudo_method_storage, $defining_class_storage] = $found_method_and_class_storage; if (self::checkPseudoMethod( @@ -804,13 +791,18 @@ private static function handleNamedCall( } } - $does_method_exist = MethodAnalyzer::checkMethodExists( - $codebase, - $method_id, - new CodeLocation($statements_analyzer, $stmt), - $statements_analyzer->getSuppressedIssues(), - $context->calling_method_id, - ); + if (!$callstatic_method_exists || $class_storage->hasSealedMethods($config)) { + $does_method_exist = MethodAnalyzer::checkMethodExists( + $codebase, + $method_id, + new CodeLocation($statements_analyzer, $stmt), + $statements_analyzer->getSuppressedIssues(), + $context->calling_method_id, + $with_pseudo, + ); + } else { + $does_method_exist = null; + } if (!$does_method_exist) { if (ArgumentsAnalyzer::analyze( @@ -872,37 +864,6 @@ private static function handleNamedCall( return false; } - if ((!$stmt->class instanceof PhpParser\Node\Name - || $stmt->class->getFirst() !== 'parent' - || $statements_analyzer->isStatic()) - && ( - !$context->self - || $statements_analyzer->isStatic() - || !$codebase->classExtends($context->self, $fq_class_name) - ) - ) { - MethodAnalyzer::checkStatic( - $method_id, - ($stmt->class instanceof PhpParser\Node\Name - && strtolower($stmt->class->getFirst()) === 'self') - || $context->self === $fq_class_name, - !$statements_analyzer->isStatic(), - $codebase, - new CodeLocation($statements_analyzer, $stmt), - $statements_analyzer->getSuppressedIssues(), - $is_dynamic_this_method, - ); - - if ($is_dynamic_this_method) { - return self::forwardCallToInstanceMethod( - $statements_analyzer, - $stmt, - $stmt_name, - $context, - ); - } - } - $has_existing_method = true; ExistingAtomicStaticCallAnalyzer::analyze( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php index e2f2544f0ca..65f24af9f03 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php @@ -142,9 +142,14 @@ public static function analyze( } } - $type = new Union([new TBool()], [ - 'parent_nodes' => $maybe_type->parent_nodes ?? [], - ]); + if ($statements_analyzer->data_flow_graph instanceof VariableUseGraph + ) { + $type = new Union([new TBool()], [ + 'parent_nodes' => $maybe_type->parent_nodes ?? [], + ]); + } else { + $type = Type::getBool(); + } $statements_analyzer->node_data->setType($stmt, $type); @@ -317,7 +322,11 @@ public static function castIntAttempt( $atomic_types = $stmt_type->getAtomicTypes(); - $parent_nodes = $stmt_type->parent_nodes; + $parent_nodes = []; + + if ($statements_analyzer->data_flow_graph instanceof VariableUseGraph) { + $parent_nodes = $stmt_type->parent_nodes; + } while ($atomic_types) { $atomic_type = array_pop($atomic_types); @@ -499,7 +508,11 @@ public static function castFloatAttempt( $atomic_types = $stmt_type->getAtomicTypes(); - $parent_nodes = $stmt_type->parent_nodes; + $parent_nodes = []; + + if ($statements_analyzer->data_flow_graph instanceof VariableUseGraph) { + $parent_nodes = $stmt_type->parent_nodes; + } while ($atomic_types) { $atomic_type = array_pop($atomic_types); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/EmptyAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/EmptyAnalyzer.php index 1fbd8f8c86f..eb91ff841c4 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/EmptyAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/EmptyAnalyzer.php @@ -67,16 +67,18 @@ public static function analyze( $stmt_type = new TTrue($expr_type->from_docblock); } else { if (count($expr_type->getAtomicTypes()) > 1) { + $has_truthy_or_falsy_exclusive_type = false; $both_types = $expr_type->getBuilder(); foreach ($both_types->getAtomicTypes() as $key => $atomic_type) { if ($atomic_type->isTruthy() || $atomic_type->isFalsy() || $atomic_type instanceof TBool) { $both_types->removeType($key); + $has_truthy_or_falsy_exclusive_type = true; } } - if (count($both_types->getAtomicTypes()) > 0) { + if (count($both_types->getAtomicTypes()) > 0 && $has_truthy_or_falsy_exclusive_type) { $both_types = $both_types->freeze(); IssueBuffer::maybeAdd( new RiskyTruthyFalsyComparison( diff --git a/src/Psalm/Internal/Codebase/Methods.php b/src/Psalm/Internal/Codebase/Methods.php index 75db5faca69..82f57aa381c 100644 --- a/src/Psalm/Internal/Codebase/Methods.php +++ b/src/Psalm/Internal/Codebase/Methods.php @@ -93,6 +93,7 @@ public function methodExists( ?string $source_file_path = null, bool $use_method_existence_provider = true, bool $is_used = false, + bool $with_pseudo = false, ): bool { $fq_class_name = $method_id->fq_class_name; $method_name = $method_id->method_name; @@ -140,9 +141,11 @@ public function methodExists( $calling_class_name = explode('::', $calling_method_id)[0]; } - if (isset($class_storage->declaring_method_ids[$method_name])) { - $declaring_method_id = $class_storage->declaring_method_ids[$method_name]; - + $declaring_method_id = $class_storage->declaring_method_ids[$method_name] ?? null; + if ($declaring_method_id === null && $with_pseudo) { + $declaring_method_id = $class_storage->declaring_pseudo_method_ids[$method_name] ?? null; + } + if ($declaring_method_id !== null) { if ($calling_method_id === strtolower((string) $declaring_method_id)) { return true; } @@ -358,7 +361,7 @@ public function getMethodParams( } } - $declaring_method_id = $this->getDeclaringMethodId($method_id); + $declaring_method_id = $this->getDeclaringMethodId($method_id, true); $callmap_id = $declaring_method_id ?? $method_id; @@ -414,7 +417,7 @@ public function getMethodParams( } if ($declaring_method_id) { - $storage = $this->getStorage($declaring_method_id); + $storage = $this->getStorage($declaring_method_id, true); $params = $storage->params; @@ -1009,6 +1012,7 @@ public function setAppearingMethodId( /** @psalm-mutation-free */ public function getDeclaringMethodId( MethodIdentifier $method_id, + bool $with_pseudo = false, ): ?MethodIdentifier { $fq_class_name = $this->classlikes->getUnAliasedName($method_id->fq_class_name); @@ -1026,6 +1030,10 @@ public function getDeclaringMethodId( return reset($class_storage->overridden_method_ids[$method_name]); } + if ($with_pseudo && isset($class_storage->declaring_pseudo_method_ids[$method_name])) { + return $class_storage->declaring_pseudo_method_ids[$method_name]; + } + return null; } @@ -1082,7 +1090,7 @@ public function getCasedMethodId(MethodIdentifier $original_method_id): string public function getUserMethodStorage(MethodIdentifier $method_id): ?MethodStorage { - $declaring_method_id = $this->getDeclaringMethodId($method_id); + $declaring_method_id = $this->getDeclaringMethodId($method_id, true); if (!$declaring_method_id) { if (InternalCallMapHandler::inCallMap((string) $method_id)) { @@ -1092,7 +1100,7 @@ public function getUserMethodStorage(MethodIdentifier $method_id): ?MethodStorag throw new UnexpectedValueException('$storage should not be null for ' . $method_id); } - $storage = $this->getStorage($declaring_method_id); + $storage = $this->getStorage($declaring_method_id, true); if (!$storage->location) { return null; @@ -1133,7 +1141,7 @@ public function getClassLikeStorageForMethod(MethodIdentifier $method_id): Class } /** @psalm-mutation-free */ - public function getStorage(MethodIdentifier $method_id): MethodStorage + public function getStorage(MethodIdentifier $method_id, bool $with_pseudo = false): MethodStorage { try { $class_storage = $this->classlike_storage_provider->get($method_id->fq_class_name); @@ -1142,12 +1150,14 @@ public function getStorage(MethodIdentifier $method_id): MethodStorage } $method_name = $method_id->method_name; - $method_storage = $class_storage->methods[$method_name] - ?? $class_storage->pseudo_methods[$method_name] - ?? $class_storage->pseudo_static_methods[$method_name] - ?? null; + $method_storage = $class_storage->methods[$method_name] ?? null; + if ($method_storage === null && $with_pseudo) { + $method_storage = $class_storage->pseudo_methods[$method_name] + ?? $class_storage->pseudo_static_methods[$method_name] + ?? null; + } - if (! $method_storage) { + if ($method_storage === null) { throw new UnexpectedValueException( '$storage should not be null for ' . $method_id, ); diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index 64f1bce052c..3504b2ec1ca 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -16,6 +16,7 @@ use Psalm\Internal\Scanner\ParsedDocblock; use Psalm\Issue\InvalidDocblock; use Psalm\IssueBuffer; +use Psalm\Type\TaintKindGroup; use function array_keys; use function array_shift; @@ -268,10 +269,11 @@ public static function parse( $taint_type = substr($taint_type, 5); if ($taint_type === 'tainted') { - $taint_type = 'input'; + $taint_type = TaintKindGroup::GROUP_INPUT; } if ($taint_type === 'misc') { + // @todo `text` is semantically not defined in `TaintKind`, maybe drop it $taint_type = 'text'; } @@ -309,10 +311,11 @@ public static function parse( if ($param_parts[0]) { if ($param_parts[0] === 'tainted') { - $param_parts[0] = 'input'; + $param_parts[0] = TaintKindGroup::GROUP_INPUT; } if ($param_parts[0] === 'misc') { + // @todo `text` is semantically not defined in `TaintKind`, maybe drop it $param_parts[0] = 'text'; } diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php index cf8feb8e687..b1311e82029 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php @@ -52,6 +52,9 @@ use function array_filter; use function array_merge; +use function array_search; +use function array_splice; +use function array_unique; use function array_values; use function count; use function explode; @@ -356,16 +359,20 @@ public static function addDocblockInfo( } } - foreach ($docblock_info->taint_source_types as $taint_source_type) { - if ($taint_source_type === 'input') { - $storage->taint_source_types = array_merge( - $storage->taint_source_types, - TaintKindGroup::ALL_INPUT, - ); - } else { - $storage->taint_source_types[] = $taint_source_type; - } + $docblock_info->taint_source_types = array_values(array_unique($docblock_info->taint_source_types)); + // expand 'input' group to all items, e.g. `['other', 'input']` -> `['other', 'html', 'sql', 'shell', ...]` + $inputIndex = array_search(TaintKindGroup::GROUP_INPUT, $docblock_info->taint_source_types, true); + if ($inputIndex !== false) { + array_splice( + $docblock_info->taint_source_types, + $inputIndex, + 1, + TaintKindGroup::ALL_INPUT, + ); } + // merge taints from doc block to storage, enforce uniqueness and having consecutive index keys + $storage->taint_source_types = array_merge($storage->taint_source_types, $docblock_info->taint_source_types); + $storage->taint_source_types = array_values(array_unique($storage->taint_source_types)); $storage->added_taints = $docblock_info->added_taints; diff --git a/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php b/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php index ec9822a2114..b0a291f2b3f 100644 --- a/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php +++ b/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php @@ -9,6 +9,7 @@ use Psalm\Plugin\EventHandler\AddTaintsInterface; use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent; use Psalm\Plugin\EventHandler\RemoveTaintsInterface; +use Psalm\Type\TaintKind; use function count; use function strtolower; @@ -49,24 +50,24 @@ public static function addTaints(AddRemoveTaintsEvent $event): array if ($second_arg === null) { if ($statements_analyzer->getCodebase()->analysis_php_version_id >= 8_01_00) { - return ['html', 'has_quotes']; + return [TaintKind::INPUT_HTML, TaintKind::INPUT_HAS_QUOTES]; } - return ['html']; + return [TaintKind::INPUT_HTML]; } $second_arg_value = $statements_analyzer->node_data->getType($second_arg); if (!$second_arg_value || !$second_arg_value->isSingleIntLiteral()) { - return ['html']; + return [TaintKind::INPUT_HTML]; } $second_arg_value = $second_arg_value->getSingleIntLiteral()->value; if (($second_arg_value & ENT_QUOTES) === ENT_QUOTES) { - return ['html', 'has_quotes']; + return [TaintKind::INPUT_HTML, TaintKind::INPUT_HAS_QUOTES]; } - return ['html']; + return [TaintKind::INPUT_HTML]; } return []; @@ -101,24 +102,24 @@ public static function removeTaints(AddRemoveTaintsEvent $event): array if ($second_arg === null) { if ($statements_analyzer->getCodebase()->analysis_php_version_id >= 8_01_00) { - return ['html', 'has_quotes']; + return [TaintKind::INPUT_HTML, TaintKind::INPUT_HAS_QUOTES]; } - return ['html']; + return [TaintKind::INPUT_HTML]; } $second_arg_value = $statements_analyzer->node_data->getType($second_arg); if (!$second_arg_value || !$second_arg_value->isSingleIntLiteral()) { - return ['html']; + return [TaintKind::INPUT_HTML]; } $second_arg_value = $second_arg_value->getSingleIntLiteral()->value; if (($second_arg_value & ENT_QUOTES) === ENT_QUOTES) { - return ['html', 'has_quotes']; + return [TaintKind::INPUT_HTML, TaintKind::INPUT_HAS_QUOTES]; } - return ['html']; + return [TaintKind::INPUT_HTML]; } return []; diff --git a/src/Psalm/Internal/Type/TypeCombiner.php b/src/Psalm/Internal/Type/TypeCombiner.php index ecda4b05ed3..2abaedef7b7 100644 --- a/src/Psalm/Internal/Type/TypeCombiner.php +++ b/src/Psalm/Internal/Type/TypeCombiner.php @@ -63,6 +63,7 @@ use function array_values; use function assert; use function count; +use function get_class; use function is_int; use function is_numeric; use function min; @@ -1042,12 +1043,19 @@ private static function scrapeStringProperties( && strtolower($type->value) === $type->value ) { // do nothing + } elseif (isset($combination->value_types['string']) + && $combination->value_types['string'] instanceof TNonFalsyString + && $type->value + ) { + // do nothing + } elseif (isset($combination->value_types['string']) + && $combination->value_types['string'] instanceof TNonFalsyString + && $type->value === '0' + ) { + $combination->value_types['string'] = new TNonEmptyString(); } elseif (isset($combination->value_types['string']) && $combination->value_types['string'] instanceof TNonEmptyString - && ($combination->value_types['string'] instanceof TNonFalsyString - ? $type->value - : $type->value !== '' - ) + && $type->value !== '' ) { // do nothing } else { @@ -1098,18 +1106,53 @@ private static function scrapeStringProperties( } else { $combination->value_types['string'] = $type; } + } elseif ($type instanceof TNonFalsyString) { + $has_empty_string = false; + $has_falsy_string = false; + + foreach ($combination->strings as $string_type) { + if ($string_type->value === '') { + $has_empty_string = true; + $has_falsy_string = true; + break; + } + + if ($string_type->value === '0') { + $has_falsy_string = true; + } + } + + if ($has_empty_string) { + $combination->value_types['string'] = new TString(); + } elseif ($has_falsy_string) { + $combination->value_types['string'] = new TNonEmptyString(); + } else { + $combination->value_types['string'] = $type; + } } elseif ($type instanceof TNonEmptyString) { $has_empty_string = false; foreach ($combination->strings as $string_type) { - if (!$string_type->value) { + if ($string_type->value === '') { $has_empty_string = true; break; } } + $has_non_lowercase_string = false; + if ($type instanceof TNonEmptyLowercaseString) { + foreach ($combination->strings as $string_type) { + if (strtolower($string_type->value) !== $string_type->value) { + $has_non_lowercase_string = true; + break; + } + } + } + if ($has_empty_string) { $combination->value_types['string'] = new TString(); + } elseif ($has_non_lowercase_string && get_class($type) !== TNonEmptyString::class) { + $combination->value_types['string'] = new TNonEmptyString(); } else { $combination->value_types['string'] = $type; } diff --git a/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php b/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php index 3a2cd512fc7..ed9ed33e5e6 100644 --- a/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php +++ b/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php @@ -4,6 +4,7 @@ namespace Psalm\Plugin\EventHandler\Event; +use PhpParser\Node\Stmt; use Psalm\Codebase; use Psalm\Context; use Psalm\StatementsSource; @@ -14,6 +15,7 @@ final class BeforeFileAnalysisEvent /** * Called before a file has been checked * + * @param list $stmts * @internal */ public function __construct( @@ -21,6 +23,7 @@ public function __construct( private readonly Context $file_context, private readonly FileStorage $file_storage, private readonly Codebase $codebase, + private array $stmts, ) { } @@ -43,4 +46,12 @@ public function getCodebase(): Codebase { return $this->codebase; } + + /** + * @return list + */ + public function getStmts(): array + { + return $this->stmts; + } } diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index acb3f535c83..8d2393d0dc7 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -890,33 +890,37 @@ private static function intersectAtomicTypes( } if (null === $intersection_atomic) { - if (AtomicTypeComparator::isContainedBy( - $codebase, - $type_2_atomic, - $type_1_atomic, - $allow_interface_equality, - $allow_float_int_equality, - )) { - $intersection_atomic = $type_2_atomic; - $wider_type = $type_1_atomic; - $intersection_performed = true; - } elseif (AtomicTypeComparator::isContainedBy( - $codebase, - $type_1_atomic, - $type_2_atomic, - $allow_interface_equality, - $allow_float_int_equality, - )) { - $intersection_atomic = $type_1_atomic; - $wider_type = $type_2_atomic; - $intersection_performed = true; - } + try { + if (AtomicTypeComparator::isContainedBy( + $codebase, + $type_2_atomic, + $type_1_atomic, + $allow_interface_equality, + $allow_float_int_equality, + )) { + $intersection_atomic = $type_2_atomic; + $wider_type = $type_1_atomic; + $intersection_performed = true; + } elseif (AtomicTypeComparator::isContainedBy( + $codebase, + $type_1_atomic, + $type_2_atomic, + $allow_interface_equality, + $allow_float_int_equality, + )) { + $intersection_atomic = $type_1_atomic; + $wider_type = $type_2_atomic; + $intersection_performed = true; + } - if ($intersection_atomic - && !self::hasIntersection($type_1_atomic) - && !self::hasIntersection($type_2_atomic) - ) { - return $intersection_atomic; + if ($intersection_atomic + && !self::hasIntersection($type_1_atomic) + && !self::hasIntersection($type_2_atomic) + ) { + return $intersection_atomic; + } + } catch (InvalidArgumentException $e) { + // Ignore non-existing classes during initial scan } } diff --git a/src/Psalm/Type/TaintKindGroup.php b/src/Psalm/Type/TaintKindGroup.php index cbe04e8ee21..2049012cfb1 100644 --- a/src/Psalm/Type/TaintKindGroup.php +++ b/src/Psalm/Type/TaintKindGroup.php @@ -9,6 +9,8 @@ */ final class TaintKindGroup { + public const GROUP_INPUT = 'input'; + public const ALL_INPUT = [ TaintKind::INPUT_HTML, TaintKind::INPUT_HAS_QUOTES, diff --git a/stubs/CoreGenericAttributes.phpstub b/stubs/CoreGenericAttributes.phpstub index 92abe9542f8..0871e05448c 100644 --- a/stubs/CoreGenericAttributes.phpstub +++ b/stubs/CoreGenericAttributes.phpstub @@ -1,17 +1,20 @@ return */ function str_split(string $string, int $length = 1) {} + + /** + * @psalm-immutable + * @template TValue + * + * @since 8.2.0 + */ + final class SensitiveParameterValue + { + /** @param TValue $value */ + public function __construct(private readonly mixed $value) {} + + /** @return array */ + public function __debugInfo(): array {} + + /** @return TValue */ + public function getValue(): mixed {} + } } diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php index 6757b43088f..518f7569428 100644 --- a/tests/Config/ConfigTest.php +++ b/tests/Config/ConfigTest.php @@ -160,6 +160,9 @@ public function testIgnoreMissingProjectDirectory(): void $this->assertFalse($config->isInProjectDirs((string) realpath('examples/TemplateScanner.php'))); } + /** + * @requires OS ^(?!WIN) + */ public function testIgnoreSymlinkedProjectDirectory(): void { @unlink(dirname(__DIR__, 1) . '/fixtures/symlinktest/ignored/b'); diff --git a/tests/MagicMethodAnnotationTest.php b/tests/MagicMethodAnnotationTest.php index d246a38a7ce..e74b38bd515 100644 --- a/tests/MagicMethodAnnotationTest.php +++ b/tests/MagicMethodAnnotationTest.php @@ -8,12 +8,14 @@ use Psalm\Context; use Psalm\Exception\CodeException; use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait; +use Psalm\Tests\Traits\InvalidCodeAnalysisWithIssuesTestTrait; use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait; use const DIRECTORY_SEPARATOR; class MagicMethodAnnotationTest extends TestCase { + use InvalidCodeAnalysisWithIssuesTestTrait; use InvalidCodeAnalysisTestTrait; use ValidCodeAnalysisTestTrait; @@ -48,6 +50,35 @@ class Child {} $this->analyzeFile('somefile.php', new Context()); } + public function testPhpDocMethodWhenUndefinedWithStatic(): void + { + Config::getInstance()->use_phpdoc_method_without_magic_or_parent = true; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testPhpDocMethodWhenTemplated(): void { Config::getInstance()->use_phpdoc_method_without_magic_or_parent = true; @@ -101,6 +132,76 @@ class Child {} $this->analyzeFile('somefile.php', $context); } + public function testAnnotationWithoutCallConfigWithStatic(): void + { + $this->expectExceptionMessage('UndefinedMethod'); + $this->expectException(CodeException::class); + Config::getInstance()->use_phpdoc_method_without_magic_or_parent = false; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', $context); + } + + public function testAnnotationWithoutCallConfigWithExtends(): void + { + $this->expectExceptionMessage('UndefinedMethod'); + $this->expectException(CodeException::class); + Config::getInstance()->use_phpdoc_method_without_magic_or_parent = false; + + $this->addFile( + 'somefile.php', + 'getString();', + ); + + $context = new Context(); + + $this->analyzeFile('somefile.php', $context); + } + + public function testAnnotationWithoutCallConfigWithExtendsWithStatic(): void + { + $this->expectExceptionMessage('UndefinedMethod'); + $this->expectException(CodeException::class); + Config::getInstance()->use_phpdoc_method_without_magic_or_parent = false; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', $context); + } + public function testOverrideParentClassRetunType(): void { Config::getInstance()->use_phpdoc_method_without_magic_or_parent = true; @@ -195,6 +296,48 @@ class Child extends ParentClass {} '$e' => 'callable():string', ], ], + 'validSimpleAnnotationsWithStatic' => [ + 'code' => ' [ + '$a' => 'string', + '$b' => 'mixed', + '$c' => 'bool', + '$d' => 'array', + '$e' => 'callable():string', + '$f' => 'Child', + ], + ], 'validAnnotationWithDefault' => [ 'code' => ' [ + // This is compatible with "magicMethodInheritanceWithoutCall_WithReturnAndManyArgs" + 'code' => <<<'PHP' + [ + '$a===' => 'mixed', + ], + ], + 'magicMethodInheritanceWithoutCall_WithReturnAndManyArgs' => [ + 'code' => <<<'PHP' + bar(123, "whatever"); + PHP, + 'assertions' => [ + '$a===' => 'mixed', + ], + ], 'callUsingParent' => [ 'code' => 'foo();', 'error_message' => 'UndefinedMagicMethod', ], + 'inheritSealedMethodsWithStatic' => [ + 'code' => ' 'UndefinedMagicMethod', + ], 'lonelyMethod' => [ 'code' => ' 'ImplementedParamTypeMismatch', ], + 'staticInvocationWithMagicMethodFoo' => [ + 'code' => ' 'InvalidStaticInvocation', + ], + 'nonStaticSelfCallWithMagicMethodFoo' => [ + 'code' => ' 'NonStaticSelfCall', + ], + 'staticInvocationWithInstanceMethodFoo' => [ + 'code' => ' 'InvalidStaticInvocation', + ], + 'nonStaticSelfCallWithInstanceMethodFoo' => [ + 'code' => ' 'NonStaticSelfCall', + ], + 'suppressUndefinedMethodWithObjectCall_WithNotExistsFunc' => [ + 'code' => <<<'PHP' + bar(function_does_not_exist(123)); + PHP, + 'error_message' => 'UndefinedFunction', + ], + 'suppressUndefinedMethodWithStaticCall_WithNotExistsFunc' => [ + 'code' => <<<'PHP' + 'UndefinedFunction', + ], ]; } @@ -1279,6 +1559,29 @@ class B extends A {} $this->analyzeFile('somefile.php', new Context()); } + public function testSealAllMethodsWithoutFooWithStatic(): void + { + Config::getInstance()->seal_all_methods = true; + + $this->addFile( + 'somefile.php', + 'expectException(CodeException::class); + $this->expectExceptionMessage($error_message); + $this->analyzeFile('somefile.php', new Context()); + } + public function testNoSealAllMethods(): void { Config::getInstance()->seal_all_methods = true; @@ -1304,6 +1607,30 @@ class B extends A {} $this->analyzeFile('somefile.php', new Context()); } + public function testNoSealAllMethodsWithStatic(): void + { + Config::getInstance()->seal_all_methods = true; + + $this->addFile( + 'somefile.php', + 'expectException(CodeException::class); + $this->expectExceptionMessage($error_message); + $this->analyzeFile('somefile.php', new Context()); + } + public function testSealAllMethodsWithFoo(): void { Config::getInstance()->seal_all_methods = true; @@ -1326,6 +1653,27 @@ class B extends A {} $this->analyzeFile('somefile.php', new Context()); } + public function testSealAllMethodsWithFooWithStatic(): void + { + Config::getInstance()->seal_all_methods = true; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testSealAllMethodsWithFooInSubclass(): void { Config::getInstance()->seal_all_methods = true; @@ -1349,6 +1697,28 @@ public function foo(): void {} $this->analyzeFile('somefile.php', new Context()); } + public function testSealAllMethodsWithFooInSubclassWithStatic(): void + { + Config::getInstance()->seal_all_methods = true; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testSealAllMethodsWithFooAnnotated(): void { Config::getInstance()->seal_all_methods = true; @@ -1371,6 +1741,27 @@ class B extends A {} $this->analyzeFile('somefile.php', new Context()); } + public function testSealAllMethodsWithFooAnnotatedWithStatic(): void + { + Config::getInstance()->seal_all_methods = true; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testSealAllMethodsSetToFalse(): void { Config::getInstance()->seal_all_methods = false; @@ -1392,6 +1783,26 @@ class B extends A {} $this->analyzeFile('somefile.php', new Context()); } + public function testSealAllMethodsSetToFalseWithStatic(): void + { + Config::getInstance()->seal_all_methods = false; + + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testIntersectionTypeWhenMagicMethodDoesNotExistButIsProvidedBySecondType(): void { $this->addFile( diff --git a/tests/MagicPropertyTest.php b/tests/MagicPropertyTest.php index abb03aed1a0..5b0f7a0734b 100644 --- a/tests/MagicPropertyTest.php +++ b/tests/MagicPropertyTest.php @@ -8,12 +8,14 @@ use Psalm\Context; use Psalm\Exception\CodeException; use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait; +use Psalm\Tests\Traits\InvalidCodeAnalysisWithIssuesTestTrait; use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait; use const DIRECTORY_SEPARATOR; class MagicPropertyTest extends TestCase { + use InvalidCodeAnalysisWithIssuesTestTrait; use InvalidCodeAnalysisTestTrait; use ValidCodeAnalysisTestTrait; diff --git a/tests/MethodCallTest.php b/tests/MethodCallTest.php index 66360a66028..b8719abb685 100644 --- a/tests/MethodCallTest.php +++ b/tests/MethodCallTest.php @@ -6,12 +6,14 @@ use Psalm\Context; use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait; +use Psalm\Tests\Traits\InvalidCodeAnalysisWithIssuesTestTrait; use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait; use const DIRECTORY_SEPARATOR; class MethodCallTest extends TestCase { + use InvalidCodeAnalysisWithIssuesTestTrait; use InvalidCodeAnalysisTestTrait; use ValidCodeAnalysisTestTrait; diff --git a/tests/MethodSignatureTest.php b/tests/MethodSignatureTest.php index d1fab3db768..2bb229acf8f 100644 --- a/tests/MethodSignatureTest.php +++ b/tests/MethodSignatureTest.php @@ -7,12 +7,14 @@ use Psalm\Context; use Psalm\Exception\CodeException; use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait; +use Psalm\Tests\Traits\InvalidCodeAnalysisWithIssuesTestTrait; use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait; use const DIRECTORY_SEPARATOR; class MethodSignatureTest extends TestCase { + use InvalidCodeAnalysisWithIssuesTestTrait; use ValidCodeAnalysisTestTrait; use InvalidCodeAnalysisTestTrait; diff --git a/tests/MixinAnnotationTest.php b/tests/MixinAnnotationTest.php index 807fc4a57a5..b9863d36f6b 100644 --- a/tests/MixinAnnotationTest.php +++ b/tests/MixinAnnotationTest.php @@ -5,10 +5,12 @@ namespace Psalm\Tests; use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait; +use Psalm\Tests\Traits\InvalidCodeAnalysisWithIssuesTestTrait; use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait; class MixinAnnotationTest extends TestCase { + use InvalidCodeAnalysisWithIssuesTestTrait; use ValidCodeAnalysisTestTrait; use InvalidCodeAnalysisTestTrait; diff --git a/tests/NativeIntersectionsTest.php b/tests/NativeIntersectionsTest.php index ade439a5f11..94f65bfee2c 100644 --- a/tests/NativeIntersectionsTest.php +++ b/tests/NativeIntersectionsTest.php @@ -53,6 +53,64 @@ function test(A&B $in): void { 'ignored_issues' => [], 'php_version' => '8.1', ], + 'nativeTypeIntersectionAsClassProperty' => [ + 'code' => 'intersection = new C(); + } + } + ', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nativeTypeIntersectionAsClassPropertyUsingProcessedInterfaces' => [ + 'code' => 'other = new AB(); + } + } + ', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nativeTypeIntersectionAsClassPropertyUsingUnprocessedInterfaces' => [ + 'code' => 'other = new StringableJson(); + } + } + ', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; } @@ -138,6 +196,22 @@ function foo (A&B $test): A&B { 'ignored_issues' => [], 'php_version' => '8.0', ], + 'nativeTypeIntersectionAsClassPropertyUsingUnknownInterfaces' => [ + 'code' => 'other = new \Example\Unknown\AB(); + } + } + ', + // @todo decide whether a fall-back should be implemented, that allows to by-pass this failure (opt-in config) + // `UndefinedClass - src/somefile.php:3:33 - Class, interface or enum named Example\Unknown\B does not exist` + 'error_message' => 'UndefinedClass', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; } } diff --git a/tests/OverrideTest.php b/tests/OverrideTest.php index 2d188c30a21..b2169132914 100644 --- a/tests/OverrideTest.php +++ b/tests/OverrideTest.php @@ -70,6 +70,23 @@ public function f(): void; 'ignored_issues' => [], 'php_version' => '8.3', ], + 'canBeUsedOnPureMethods' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], ]; } diff --git a/tests/TaintTest.php b/tests/TaintTest.php index a5e1cf28f0e..03226e222b7 100644 --- a/tests/TaintTest.php +++ b/tests/TaintTest.php @@ -179,6 +179,23 @@ public function deleteUser(PDO $pdo, string $userId) : void { } }', ], + 'untaintedInputAfterIntCast' => [ + 'code' => 'getUserId(); + } + + public function deleteUser(PDO $pdo) : void { + $userId = $this->getAppendedUserId(); + $pdo->exec("delete from users where user_id = " . $userId); + } + }', + ], 'specializedCoreFunctionCall' => [ 'code' => ' [ + 'code' => ' [ 'code' => ' 'TaintedSql', ], - 'taintedInputAfterIntCast' => [ - 'code' => 'getUserId(); - } - - public function deleteUser(PDO $pdo) : void { - $userId = $this->getAppendedUserId(); - $pdo->exec("delete from users where user_id = " . $userId); - } - }', - 'error_message' => 'TaintedSql', - ], - 'TaintForIntTypeCastUsingAnnotatedSink' => [ - 'code' => ' 'TaintedSql', - ], 'taintedInputFromReturnTypeWithBranch' => [ 'code' => 'getName($withDataSet); } + public static function assertHasIssue(string $expected, string $message = ''): void + { + $issue_messages = []; + $res = false; + $issues = IssueBuffer::getIssuesData(); + foreach ($issues as $file_issues) { + foreach ($file_issues as $issue) { + $full_issue_message = $issue->type . ' - ' . $issue->file_name . ':' . $issue->line_from . ':' . $issue->column_from . ' - ' . $issue->message; + $issue_messages[] = $full_issue_message; + if (preg_match('/\b' . preg_quote($expected, '/') . '\b/', $full_issue_message)) { + $res = true; + } + } + } + if (!$message) { + $message = "Failed asserting that issue with \"$expected\" was emitted."; + if (count($issue_messages)) { + $message .= "\n" . 'Other issues reported:' . "\n - " . implode("\n - ", $issue_messages); + } else { + $message .= ' No issues reported.'; + } + } + self::assertTrue($res, $message); + } + public static function assertArrayKeysAreStrings(array $array, string $message = ''): void { $validKeys = array_filter($array, 'is_string', ARRAY_FILTER_USE_KEY); diff --git a/tests/Traits/InvalidCodeAnalysisWithIssuesTestTrait.php b/tests/Traits/InvalidCodeAnalysisWithIssuesTestTrait.php new file mode 100644 index 00000000000..4965cf25460 --- /dev/null +++ b/tests/Traits/InvalidCodeAnalysisWithIssuesTestTrait.php @@ -0,0 +1,116 @@ +config->throw_exception = true; // or false + * ``` + * + * When `throw_exception` is set to `true`, code execution stops once + * the first issue is emitted, thus it may mask any problems after + * that point. + * + * When `throw_exception` is set to `false`, the code will continue + * to be executed and we can uncover some additional bugs. + * + * This is trait allows testing for the second case, when the value of + * "throw_exception" will be "false". + * + * @psalm-type DeprecatedDataProviderArrayNotation = array{ + * code: string, + * error_message: string, + * ignored_issues?: list, + * php_version?: string + * } + * @psalm-type NamedArgumentsDataProviderArrayNotation = array{ + * code: string, + * error_message: string, + * error_levels?: list, + * php_version?: string + * } + */ +trait InvalidCodeAnalysisWithIssuesTestTrait +{ + /** + * @return iterable< + * string, + * DeprecatedDataProviderArrayNotation|NamedArgumentsDataProviderArrayNotation + * > + */ + abstract public function providerInvalidCodeParse(): iterable; + + /** + * @dataProvider providerInvalidCodeParse + * @small + * @param list $error_levels + */ + public function testInvalidCodeWithIssues( + string $code, + string $error_message, + array $error_levels = [], + string $php_version = '7.4', + ): void { + $test_name = $this->getTestName(); + if (strpos($test_name, 'PHP80-') !== false) { + if (version_compare(PHP_VERSION, '8.0.0', '<')) { + $this->markTestSkipped('Test case requires PHP 8.0.'); + } + } elseif (strpos($test_name, 'SKIPPED-') !== false) { + $this->markTestSkipped('Skipped due to a bug.'); + } + + // sanity check - do we have a PHP tag? + if (strpos($code, 'fail('Test case must have a setCustomErrorLevel($issue_name, $error_level); + } + + $this->project_analyzer->setPhpVersion($php_version, 'tests'); + + $file_path = self::$src_dir_path . 'somefile.php'; + + $codebase = $this->project_analyzer->getCodebase(); + $codebase->enterServerMode(); + $codebase->config->visitPreloadedStubFiles($codebase); + + $codebase->config->throw_exception = false; + + $this->addFile($file_path, $code); + $this->analyzeFile($file_path, new Context()); + + $this->assertHasIssue($error_message); + } +} diff --git a/tests/TypeCombinationTest.php b/tests/TypeCombinationTest.php index bb1447e26ff..a30e9e55308 100644 --- a/tests/TypeCombinationTest.php +++ b/tests/TypeCombinationTest.php @@ -129,6 +129,40 @@ function takesLiteralString($arg) {} '$x===' => 'non-falsy-string', ], ], + 'loopNonFalsyWithZeroShouldBeNonEmpty' => [ + 'code' => ' [ + '$x===' => 'list', + ], + ], + 'loopNonLowercaseLiteralWithNonEmptyLowercaseShouldBeNonEmptyAndNotLowercase' => [ + 'code' => ' [ + '$x===' => 'list', + ], + ], ]; } @@ -902,7 +936,7 @@ public function providerTestValidTypeCombination(): array ], ], 'nonFalsyStringAndFalsyLiteral' => [ - 'string', + 'non-empty-string', [ 'non-falsy-string', '"0"', diff --git a/tests/TypeReconciliation/ConditionalTest.php b/tests/TypeReconciliation/ConditionalTest.php index 6de140f62a5..e253efadafd 100644 --- a/tests/TypeReconciliation/ConditionalTest.php +++ b/tests/TypeReconciliation/ConditionalTest.php @@ -43,28 +43,28 @@ function foo($a): void { 'nonStrictConditionTruthyFalsyNoOverlap' => [ 'code' => ' [ 'code' => ' 'null|stdClass', ], ], + 'nonStrictConditionWithoutExclusiveTruthyFalsyFuncCallNegated' => [ + 'code' => ' [], + 'ignored_issues' => ['InvalidReturnType'], + ], ]; } @@ -3539,61 +3558,61 @@ public function fluent(): self 'nonStrictConditionTruthyFalsy' => [ 'code' => ' 'RiskyTruthyFalsyComparison', ], 'nonStrictConditionTruthyFalsyNegated' => [ 'code' => ' 'RiskyTruthyFalsyComparison', ], 'nonStrictConditionTruthyFalsyFuncCall' => [ 'code' => ' 'RiskyTruthyFalsyComparison', ], 'nonStrictConditionTruthyFalsyFuncCallNegated' => [ 'code' => ' 'RiskyTruthyFalsyComparison', ], 'redundantConditionForNonEmptyString' => [