diff --git a/config.xsd b/config.xsd
index ff3dd6a15dd..3fe1e510b43 100644
--- a/config.xsd
+++ b/config.xsd
@@ -193,6 +193,7 @@
+
diff --git a/docs/annotating_code/supported_annotations.md b/docs/annotating_code/supported_annotations.md
index e1066f04de7..996b88e9c66 100644
--- a/docs/annotating_code/supported_annotations.md
+++ b/docs/annotating_code/supported_annotations.md
@@ -448,6 +448,31 @@ $username = $_GET['username']; // prints something like "test.php:4 $username: m
*Note*: it throws [special low-level issue](../running_psalm/issues/Trace.md), so you have to set errorLevel to 1, override it in config or invoke Psalm with `--show-info=true`.
+### `@psalm-check-type`
+
+You can use this annotation to ensure the inferred type matches what you expect.
+
+```php
+getDocComment()) {
$statements_analyzer->parseStatementDocblock($docblock, $stmt, $context);
@@ -387,6 +393,13 @@ private static function analyzeStatement(
}
}
+ foreach ($statements_analyzer->parsed_docblock->tags['psalm-check-type'] ?? [] as $inexact_check) {
+ $checked_types[] = [$inexact_check, false];
+ }
+ foreach ($statements_analyzer->parsed_docblock->tags['psalm-check-type-exact'] ?? [] as $exact_check) {
+ $checked_types[] = [$exact_check, true];
+ }
+
if (isset($statements_analyzer->parsed_docblock->tags['psalm-ignore-variable-method'])) {
$context->ignore_variable_method = $ignore_variable_method = true;
}
@@ -660,6 +673,66 @@ private static function analyzeStatement(
}
}
+ foreach ($checked_types as [$check_type_line, $is_exact]) {
+ /** @var string|null $check_type_string (incorrectly inferred) */
+ [$checked_var, $check_type_string] = array_map('trim', explode('=', $check_type_line));
+
+ if ($check_type_string === null) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ "Invalid format for @psalm-check-type" . ($is_exact ? "-exact" : ""),
+ new CodeLocation($statements_analyzer->source, $stmt),
+ ),
+ $statements_analyzer->getSuppressedIssues(),
+ );
+ } else {
+ $checked_var_id = $checked_var;
+ $possibly_undefined = strrpos($checked_var_id, "?") === strlen($checked_var_id) - 1;
+ if ($possibly_undefined) {
+ $checked_var_id = substr($checked_var_id, 0, strlen($checked_var_id) - 1);
+ }
+
+ if (!isset($context->vars_in_scope[$checked_var_id])) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ "Attempt to check undefined variable $checked_var_id",
+ new CodeLocation($statements_analyzer->source, $stmt),
+ ),
+ $statements_analyzer->getSuppressedIssues(),
+ );
+ } else {
+ try {
+ $checked_type = $context->vars_in_scope[$checked_var_id];
+ $check_type = Type::parseString($check_type_string);
+ $check_type->possibly_undefined = $possibly_undefined;
+
+ if ($check_type->possibly_undefined !== $checked_type->possibly_undefined
+ || !UnionTypeComparator::isContainedBy($codebase, $checked_type, $check_type)
+ || ($is_exact && !UnionTypeComparator::isContainedBy($codebase, $check_type, $checked_type))
+ ) {
+ $check_var = $checked_var_id . ($checked_type->possibly_undefined ? "?" : "");
+ IssueBuffer::maybeAdd(
+ new CheckType(
+ "Checked variable $checked_var = {$check_type->getId()} does not match "
+ . "$check_var = {$checked_type->getId()}",
+ new CodeLocation($statements_analyzer->source, $stmt),
+ ),
+ $statements_analyzer->getSuppressedIssues(),
+ );
+ }
+ } catch (TypeParseTreeException $e) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ $e->getMessage(),
+ new CodeLocation($statements_analyzer->source, $stmt),
+ ),
+ $statements_analyzer->getSuppressedIssues(),
+ );
+ }
+ }
+ }
+ }
+
return null;
}
diff --git a/src/Psalm/Issue/CheckType.php b/src/Psalm/Issue/CheckType.php
new file mode 100644
index 00000000000..a101ad1f256
--- /dev/null
+++ b/src/Psalm/Issue/CheckType.php
@@ -0,0 +1,9 @@
+,ignored_issues?:list,php_version?:string}>
+ */
+ public function providerValidCodeParse(): iterable
+ {
+ yield 'allowSubtype' => [
+ 'code' => ',php_version?:string}>
+ */
+ public function providerInvalidCodeParse(): iterable
+ {
+ yield 'checkType' => [
+ 'code' => ' 'CheckType',
+ ];
+ yield 'checkTypeExact' => [
+ 'code' => ' 'CheckType',
+ ];
+ yield 'checkMultipleTypesFirstCorrect' => [
+ 'code' => ' 'CheckType',
+ ];
+ yield 'possiblyUnset' => [
+ 'code' => ' 'Checked variable $foo = 1 does not match $foo? = 1',
+ ];
+ yield 'notPossiblyUnset' => [
+ 'code' => ' 'Checked variable $foo? = 1 does not match $foo = 1',
+ ];
+ }
+}