From 0c4dad2360107d7c6394ad1a25ac6d12f22eaf9f Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 22:12:52 -0500 Subject: [PATCH 1/9] Add `@phpstan-ignore-var` to ignore `@var` in the same PHPDoc --- src/Parser/RichParser.php | 8 ++- src/PhpDoc/PhpDocNodeResolver.php | 4 ++ src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 1 + .../Analyser/nsrt/phpstan-ignore-var.php | 71 +++++++++++++++++++ tests/PHPStan/Parser/RichParserTest.php | 33 +++++++++ .../Rules/PhpDoc/data/invalid-phpstan-doc.php | 9 +++ 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php diff --git a/src/Parser/RichParser.php b/src/Parser/RichParser.php index 6ea1fc17a3c..3f544dcb70c 100644 --- a/src/Parser/RichParser.php +++ b/src/Parser/RichParser.php @@ -22,6 +22,7 @@ use function count; use function implode; use function in_array; +use function preg_match; use function preg_match_all; use function sprintf; use function str_contains; @@ -47,6 +48,9 @@ final class RichParser implements Parser private const PHPDOC_DOCTRINE_TAG_REGEX = '(@[a-z_\\\\][a-z0-9_\:\\\\]*[a-z_][a-z0-9_]*)'; + // @phpstan-ignore-var is a PHPDoc tag of its own, not an ignore comment + private const IGNORE_COMMENT_REGEX = '~@phpstan-ignore(?!-var\b)~'; + /** * @param ExtensionsCollection $nodeVisitors */ @@ -219,10 +223,10 @@ private function getLinesToIgnore(array $tokens): array } } - $ignorePos = strpos($text, '@phpstan-ignore'); - if ($ignorePos === false) { + if (preg_match(self::IGNORE_COMMENT_REGEX, $text, $ignoreMatch, PREG_OFFSET_CAPTURE) !== 1) { continue; } + $ignorePos = $ignoreMatch[0][1]; $ignoreLine = substr_count(substr($text, 0, $ignorePos), "\n") - 1; diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 004444161d0..989d53ff7fd 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -68,6 +68,10 @@ public function __construct( */ public function resolveVarTags(PhpDocNode $phpDocNode, NameScope $nameScope): array { + if (count($phpDocNode->getTagsByName('@phpstan-ignore-var')) > 0) { + return []; + } + $resolved = []; $resolvedByTag = []; foreach (['@var', '@phan-var', '@psalm-var', '@phpstan-var'] as $tagName) { diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index e7f58e41bbc..317c8b01432 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -68,6 +68,7 @@ final class InvalidPHPStanDocTagRule implements MultipleNodeTypesRule '@phpstan-pure-unless-parameter-passed', '@phpstan-all-methods-pure', '@phpstan-all-methods-impure', + '@phpstan-ignore-var', ]; public function __construct( diff --git a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php new file mode 100644 index 00000000000..0688d50b373 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php @@ -0,0 +1,71 @@ + */ +function getTimes(int $n): iterable +{ + while ($n--) { + yield new \DateTime(); + } +} + +function doFoo(): void +{ + /** + * @var \DateTime[] $times + * @phpstan-ignore-var + */ + $times = getTimes(3); + assertType('iterable<(int|string), DateTime>', $times); + + /** + * @var list<\DateTime> + * @phpstan-ignore-var + */ + $timesWithoutVariableName = getTimes(3); + assertType('iterable<(int|string), DateTime>', $timesWithoutVariableName); + + /** + * @phpstan-var list<\DateTime> $timesFromPhpStanVar + * @phpstan-ignore-var + */ + $timesFromPhpStanVar = getTimes(3); + assertType('iterable<(int|string), DateTime>', $timesFromPhpStanVar); + + /** @var \DateTime[] $timesFromVar */ + $timesFromVar = getTimes(3); + assertType('array', $timesFromVar); +} + +/** + * @param array $items + */ +function doBar(array $items): void +{ + /** + * @var string $item + * @phpstan-ignore-var + */ + foreach ($items as $item) { + assertType('mixed', $item); + } +} + +class Foo +{ + + /** + * @var list + * @phpstan-ignore-var + */ + public array $ids = []; + +} + +function doBaz(Foo $foo): void +{ + assertType('array', $foo->ids); +} diff --git a/tests/PHPStan/Parser/RichParserTest.php b/tests/PHPStan/Parser/RichParserTest.php index af6cebbad5e..787b4a973a5 100644 --- a/tests/PHPStan/Parser/RichParserTest.php +++ b/tests/PHPStan/Parser/RichParserTest.php @@ -222,6 +222,31 @@ public static function dataLinesToIgnore(): iterable ], ]; + yield [ + ' [['name' => 'test', 'comment' => null]], + ], + ]; + yield [ ' ["Unexpected T_OTHER '-variable' after @phpstan-ignore, expected identifier"], + ], + ]; + yield [ ' Date: Thu, 29 Oct 2026 22:54:05 -0500 Subject: [PATCH 2/9] Keep checking inline `@var` tags that `@phpstan-ignore-var` ignores --- src/Analyser/StatementsHandler.php | 20 +++++-- src/Analyser/VarAnnotationProcessor.php | 3 + src/PhpDoc/PhpDocNodeResolver.php | 9 +-- src/PhpDoc/ResolvedPhpDocBlock.php | 15 +++++ .../Analyser/nsrt/phpstan-ignore-var.php | 14 ++--- .../VarTagChangedExpressionTypeRuleTest.php | 14 +++++ .../WrongVariableNameInVarTagRuleTest.php | 18 ++++++ .../Rules/PhpDoc/data/phpstan-ignore-var.php | 60 +++++++++++++++++++ turbo-ext/src/ResolvedPhpDocBlockAccess.cpp | 1 + turbo-ext/src/StatementsHandler.cpp | 14 ++++- turbo-ext/src/VarAnnotationProcessor.cpp | 3 + turbo-ext/src/generated/StatementsHandler.h | 14 +++-- turbo-ext/src/support.h | 1 + 13 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php diff --git a/src/Analyser/StatementsHandler.php b/src/Analyser/StatementsHandler.php index e8db16423f1..431962280b9 100644 --- a/src/Analyser/StatementsHandler.php +++ b/src/Analyser/StatementsHandler.php @@ -1920,6 +1920,7 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M { $function = $scope->getFunction(); $variableLessTags = []; + $isVariableLessTagIgnored = false; foreach ($stmt->getComments() as $comment) { if (!$comment instanceof Doc) { @@ -1944,9 +1945,11 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M $assignedVariable = $stmt->expr->var->name; } + $areVarTagsIgnored = $resolvedPhpDoc->areVarTagsIgnored(); foreach ($resolvedPhpDoc->getVarTags() as $name => $varTag) { if (is_int($name)) { $variableLessTags[] = $varTag; + $isVariableLessTagIgnored = $areVarTagsIgnored; continue; } @@ -1972,6 +1975,9 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M if (!$originalType->equals($varTag->getType())) { $nodeScopeResolver->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $variableNode), $scope, $storage); } + if ($areVarTagsIgnored) { + continue; + } $templateArgumentFrame = $nodeScopeResolver->observingTemplateArgumentFrame($scope); if ($templateArgumentFrame !== null) { $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($varTag->getType(), $originalType)); @@ -1988,7 +1994,7 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M } } - if (count($variableLessTags) === 1 && $defaultExpr !== null) { + if (count($variableLessTags) === 1 && !$isVariableLessTagIgnored && $defaultExpr !== null) { // only the scope effect here: the changed-type node is emitted by the // statement handler AFTER it walked the expression (emitVarTagChangedNode), // so the rule prices the expression from its stored result rather than @@ -2002,9 +2008,10 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M /** * The single variable-less @var tag on the statement's doc comment, or null * when there is none or more than one - the shape processStmtVarAnnotation() - * applies to $defaultExpr and emitVarTagChangedNode() reports on. + * applies to $defaultExpr and emitVarTagChangedNode() reports on. $isIgnored + * tells whether its PHPDoc has @phpstan-ignore-var. */ - private function findSingleVariableLessVarTag(MutatingScope $scope, Node\Stmt $stmt): ?VarTag + private function findSingleVariableLessVarTag(MutatingScope $scope, Node\Stmt $stmt, bool &$isIgnored): ?VarTag { $function = $scope->getFunction(); $variableLessTags = []; @@ -2026,6 +2033,7 @@ private function findSingleVariableLessVarTag(MutatingScope $scope, Node\Stmt $s } $variableLessTags[] = $varTag; + $isIgnored = $resolvedPhpDoc->areVarTagsIgnored(); } } @@ -2041,12 +2049,16 @@ private function findSingleVariableLessVarTag(MutatingScope $scope, Node\Stmt $s */ public function emitVarTagChangedNode(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $defaultExpr, callable $nodeCallback): TemplateArgumentConstraints { - $varTag = $this->findSingleVariableLessVarTag($scope, $stmt); + $isIgnored = false; + $varTag = $this->findSingleVariableLessVarTag($scope, $stmt, $isIgnored); if ($varTag === null) { return TemplateArgumentConstraints::createEmpty(); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); + if ($isIgnored) { + return TemplateArgumentConstraints::createEmpty(); + } $defaultExprResult = $storage->findExpressionResult($defaultExpr); if ($defaultExprResult === null || $nodeScopeResolver->observingTemplateArgumentFrame($defaultExprResult->getScope()) === null) { return TemplateArgumentConstraints::createEmpty(); diff --git a/src/Analyser/VarAnnotationProcessor.php b/src/Analyser/VarAnnotationProcessor.php index 56c985e27ec..91510b8b9eb 100644 --- a/src/Analyser/VarAnnotationProcessor.php +++ b/src/Analyser/VarAnnotationProcessor.php @@ -41,6 +41,9 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames, $function !== null ? $function->getName() : null, $comment->getText(), ); + if ($resolvedPhpDoc->areVarTagsIgnored()) { + continue; + } foreach ($resolvedPhpDoc->getVarTags() as $key => $varTag) { $varTags[$key] = $varTag; } diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 989d53ff7fd..6cb30297a5f 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -68,10 +68,6 @@ public function __construct( */ public function resolveVarTags(PhpDocNode $phpDocNode, NameScope $nameScope): array { - if (count($phpDocNode->getTagsByName('@phpstan-ignore-var')) > 0) { - return []; - } - $resolved = []; $resolvedByTag = []; foreach (['@var', '@phan-var', '@psalm-var', '@phpstan-var'] as $tagName) { @@ -719,6 +715,11 @@ public function resolveAllMethodsImpure(PhpDocNode $phpDocNode): bool return count($phpDocNode->getTagsByName('@phpstan-all-methods-impure')) > 0; } + public function resolveAreVarTagsIgnored(PhpDocNode $phpDocNode): bool + { + return count($phpDocNode->getTagsByName('@phpstan-ignore-var')) > 0; + } + public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool { foreach (['@readonly', '@phan-read-only', '@psalm-readonly', '@phpstan-readonly', '@phpstan-readonly-allow-private-mutation', '@psalm-readonly-allow-private-mutation'] as $tagName) { diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index e1026770cef..77fd6c9ae97 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -162,6 +162,8 @@ final class ResolvedPhpDocBlock private ?bool $acceptsNamedArguments = null; + private ?bool $areVarTagsIgnored = null; + private function __construct() { } @@ -255,6 +257,7 @@ public static function createEmpty(): self $self->isAllowedPrivateMutation = false; $self->hasConsistentConstructor = false; $self->acceptsNamedArguments = true; + $self->areVarTagsIgnored = false; return $self; } @@ -313,6 +316,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); $result->hasConsistentConstructor = $this->hasConsistentConstructor(); $result->acceptsNamedArguments = $acceptsNamedArguments; + $result->areVarTagsIgnored = $this->areVarTagsIgnored(); return $result; } @@ -483,6 +487,17 @@ public function getVarTags(): array return $this->varTags; } + /** + * Whether the PHPDoc has @phpstan-ignore-var. Its inline @var tags are then still + * returned by getVarTags() and checked by rules, but do not change any inferred type. + */ + public function areVarTagsIgnored(): bool + { + return $this->areVarTagsIgnored ??= $this->phpDocNodeResolver->resolveAreVarTagsIgnored( + $this->phpDocNode, + ); + } + /** * @return array */ diff --git a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php index 0688d50b373..efbaf52cc8a 100644 --- a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php +++ b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php @@ -54,18 +54,12 @@ function doBar(array $items): void } } -class Foo +function doBaz(int $i): void { - /** - * @var list + * @var int<0, max> $i * @phpstan-ignore-var */ - public array $ids = []; - -} - -function doBaz(Foo $foo): void -{ - assertType('array', $foo->ids); + echo $i; + assertType('int', $i); } diff --git a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php index 465b2bb5d9d..88dc64607b9 100644 --- a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php @@ -49,6 +49,20 @@ public function testRule(): void ]); } + public function testPhpStanIgnoreVar(): void + { + $this->analyse([__DIR__ . '/data/phpstan-ignore-var.php'], [ + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 48, + ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 57, + ], + ]); + } + public function testAssignOfDifferentVariable(): void { $this->analyse([__DIR__ . '/data/wrong-var-native-type.php'], [ diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index aa3c3843f0e..fd63e29cae3 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -169,6 +169,24 @@ public function testAboveDeclare(): void $this->analyse([__DIR__ . '/data/var-above-declare.php'], []); } + public function testPhpStanIgnoreVar(): void + { + $this->analyse([__DIR__ . '/data/phpstan-ignore-var.php'], [ + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 26, + ], + [ + 'Variable $wrongName in PHPDoc tag @var does not match assigned variable $j.', + 32, + ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 38, + ], + ]); + } + public function testBug3515(): void { $this->analyse([__DIR__ . '/data/bug-3515.php'], []); diff --git a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php new file mode 100644 index 00000000000..1b8bcb7c5ce --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php @@ -0,0 +1,60 @@ + */ + public function getTimes(): iterable + { + return []; + } + + public function doFoo(int $i): void + { + /** + * @var \DateTime[] $times + * @phpstan-ignore-var + */ + $times = $this->getTimes(); + + /** + * @var string $s + * @phpstan-ignore-var + */ + $s = $i; + + /** + * @var int $wrongName + * @phpstan-ignore-var + */ + $j = $i; + + /** + * @var string $item + * @phpstan-ignore-var + */ + foreach ([$i] as $item) { + } + } + + public function doBar(int $i): void + { + /** + * @var string $i + * @phpstan-ignore-var + */ + echo $i; + } + + public function doBaz(int $i): int + { + /** + * @var string + * @phpstan-ignore-var + */ + return $i; + } + +} diff --git a/turbo-ext/src/ResolvedPhpDocBlockAccess.cpp b/turbo-ext/src/ResolvedPhpDocBlockAccess.cpp index ebd87bc163b..f2f2d8d80e4 100644 --- a/turbo-ext/src/ResolvedPhpDocBlockAccess.cpp +++ b/turbo-ext/src/ResolvedPhpDocBlockAccess.cpp @@ -80,6 +80,7 @@ const MemberInfo pt_rpd_members[PT_RPD_MEMBER_COUNT] = { /* PT_RPD_IS_READ_ONLY */ {"isReadOnly", PT_LC("isreadonly"), "isReadOnly", PT_RPD_BOOL_NULL}, /* PT_RPD_IS_IMMUTABLE */ {"isImmutable", PT_LC("isimmutable"), "isImmutable", PT_RPD_BOOL_NULL}, /* PT_RPD_IS_ALLOWED_PRIVATE_MUTATION */ {"isAllowedPrivateMutation", PT_LC("isallowedprivatemutation"), "isAllowedPrivateMutation", PT_RPD_BOOL_NULL}, + /* PT_RPD_ARE_VAR_TAGS_IGNORED */ {"areVarTagsIgnored", PT_LC("arevartagsignored"), "areVarTagsIgnored", PT_RPD_BOOL_NULL}, /* PT_RPD_HAS_PHP_DOC_STRING */ {"phpDocString", PT_LC("hasphpdocstring"), "hasPhpDocString", PT_RPD_HAS_PHP_DOC_STRING}, /* PT_RPD_GET_PHP_DOC_STRING */ {"phpDocString", PT_LC("getphpdocstring"), "getPhpDocString", PT_RPD_PLAIN}, /* PT_RPD_GET_FILENAME */ {"filename", PT_LC("getfilename"), "getFilename", PT_RPD_PLAIN}, diff --git a/turbo-ext/src/StatementsHandler.cpp b/turbo-ext/src/StatementsHandler.cpp index 656fbaefae6..3b569bfc642 100644 --- a/turbo-ext/src/StatementsHandler.cpp +++ b/turbo-ext/src/StatementsHandler.cpp @@ -1071,6 +1071,7 @@ class StatementsHandler if (EXPECTED(!comments.ref().isArray() || zend_hash_num_elements(Z_ARRVAL_P(comments.raw())) == 0)) return scope; zv::Arr variableLessTags = zv::Arr::empty(); + bool isVariableLessTagIgnored = false; for (auto entry : zv::ArrRef(comments.raw())) { zval *comment = entry.value().deref().raw(); bool isDoc; @@ -1083,6 +1084,8 @@ class StatementsHandler zend_string *assignedVariable = NULL; if (UNEXPECTED(!assignedVariableOf(stmt, assignedVariable))) return zv::Val(); + bool areVarTagsIgnored; + if (UNEXPECTED(!pt_resolved_php_doc_block_bool(resolvedPhpDoc.raw(), PT_RPD_ARE_VAR_TAGS_IGNORED, areVarTagsIgnored))) return zv::Val(); zv::Val varTags = resolvedPhpDocGetVarTags(resolvedPhpDoc.raw()); if (UNEXPECTED(varTags.isUndef())) return zv::Val(); if (UNEXPECTED(!varTags.ref().isArray())) { @@ -1094,6 +1097,7 @@ class StatementsHandler zval *varTag = tagEntry.value().deref().raw(); if (!tagEntry.hasStringKey()) { variableLessTags.push(zv::Ref(varTag)); + isVariableLessTagIgnored = areVarTagsIgnored; continue; } zend_string *name = tagEntry.stringKey(); @@ -1143,6 +1147,7 @@ class StatementsHandler if (UNEXPECTED(changedNode.isUndef())) return zv::Val(); if (UNEXPECTED(!pt_node_scope_resolver_call_node_callback(nodeScopeResolver, nodeCallback, changedNode.raw(), scope.raw(), storage))) return zv::Val(); } + if (areVarTagsIgnored) continue; zv::Val templateArgumentFrame = pt_node_scope_resolver_observing_template_argument_frame(nodeScopeResolver, scope.raw()); if (UNEXPECTED(templateArgumentFrame.isUndef())) return zv::Val(); if (!templateArgumentFrame.isNull()) { @@ -1169,7 +1174,7 @@ class StatementsHandler } } - if (zend_hash_num_elements(variableLessTags.table()) == 1 && defaultExpr != NULL && Z_TYPE_P(defaultExpr) != IS_NULL) { + if (zend_hash_num_elements(variableLessTags.table()) == 1 && !isVariableLessTagIgnored && defaultExpr != NULL && Z_TYPE_P(defaultExpr) != IS_NULL) { zval *tag = zend_hash_index_find(variableLessTags.table(), 0); zv::Val type = tagGetType(tag); if (UNEXPECTED(type.isUndef())) return zv::Val(); @@ -1184,7 +1189,8 @@ class StatementsHandler /* Mirrors emitVarTagChangedNode(). */ zv::Val emitVarTagChangedNode(zval *nodeScopeResolver, zval *scope, zval *storage, zval *stmt, zval *defaultExpr, zval *nodeCallback) { - zv::Val varTag = findSingleVariableLessVarTag(scope, stmt); + bool isIgnored = false; + zv::Val varTag = findSingleVariableLessVarTag(scope, stmt, isIgnored); if (UNEXPECTED(varTag.isUndef())) return zv::Val(); if (varTag.isNull()) return templateArgumentConstraintsCreateEmpty(); @@ -1192,6 +1198,7 @@ class StatementsHandler zv::Val changedNode = pt_type_new(PT_CLASS_VAR_TAG_CHANGED_EXPRESSION_TYPE_NODE, 2, nodeArgv); if (UNEXPECTED(changedNode.isUndef())) return zv::Val(); if (UNEXPECTED(!pt_node_scope_resolver_call_node_callback(nodeScopeResolver, nodeCallback, changedNode.raw(), scope, storage))) return zv::Val(); + if (isIgnored) return templateArgumentConstraintsCreateEmpty(); zv::Val defaultExprResult = pt_expression_result_storage_find(storage, defaultExpr); if (UNEXPECTED(defaultExprResult.isUndef())) return zv::Val(); if (defaultExprResult.isNull()) return templateArgumentConstraintsCreateEmpty(); @@ -3428,7 +3435,7 @@ class StatementsHandler } /* the private findSingleVariableLessVarTag() */ - zv::Val findSingleVariableLessVarTag(zval *scope, zval *stmt) + zv::Val findSingleVariableLessVarTag(zval *scope, zval *stmt, bool &isIgnored) { zv::Val function = pt_mutating_scope_get_function(Z_OBJ_P(scope)); if (UNEXPECTED(function.isUndef())) return zv::Val(); @@ -3449,6 +3456,7 @@ class StatementsHandler for (auto tagEntry : zv::ArrRef(varTags.raw())) { if (tagEntry.hasStringKey()) continue; variableLessTags.push(tagEntry.value().deref()); + if (UNEXPECTED(!pt_resolved_php_doc_block_bool(resolvedPhpDoc.raw(), PT_RPD_ARE_VAR_TAGS_IGNORED, isIgnored))) return zv::Val(); } } } diff --git a/turbo-ext/src/VarAnnotationProcessor.cpp b/turbo-ext/src/VarAnnotationProcessor.cpp index 6ff7594b11c..de3cbe33ccd 100644 --- a/turbo-ext/src/VarAnnotationProcessor.cpp +++ b/turbo-ext/src/VarAnnotationProcessor.cpp @@ -170,6 +170,9 @@ class VarAnnotationProcessor ZVAL_COPY_VALUE(&getResolvedPhpDocArgs[4], text.raw()); zv::Val resolvedPhpDoc = callOn(pt_vap_get_resolved_php_doc_site, fileTypeMapper, PT_LC("getresolvedphpdoc"), "getResolvedPhpDoc", 5, getResolvedPhpDocArgs); if (UNEXPECTED(resolvedPhpDoc.isUndef())) return zv::Val(); + bool areVarTagsIgnored; + if (UNEXPECTED(!pt_resolved_php_doc_block_bool(resolvedPhpDoc.raw(), PT_RPD_ARE_VAR_TAGS_IGNORED, areVarTagsIgnored))) return zv::Val(); + if (areVarTagsIgnored) continue; zv::Val docVarTags = pt_resolved_php_doc_block_call(resolvedPhpDoc.raw(), PT_RPD_GET_VAR_TAGS); if (UNEXPECTED(docVarTags.isUndef())) return zv::Val(); if (Z_TYPE_P(docVarTags.raw()) != IS_ARRAY) continue; diff --git a/turbo-ext/src/generated/StatementsHandler.h b/turbo-ext/src/generated/StatementsHandler.h index 08f4e1b1eed..3c63c4f2c57 100644 --- a/turbo-ext/src/generated/StatementsHandler.h +++ b/turbo-ext/src/generated/StatementsHandler.h @@ -166,9 +166,10 @@ inline constexpr char strings[] = "defaultExpr\0" /* 2563 */ "PhpParser\\Node\\Expr\0" /* 2575 */ "processStmtVarAnnotation\0" /* 2595 */ - "PHPStan\\PhpDoc\\Tag\\VarTag\0" /* 2620 */ - "findSingleVariableLessVarTag\0" /* 2646 */ - "emitVarTagChangedNode"; /* 2675 */ + "isIgnored\0" /* 2620 */ + "PHPStan\\PhpDoc\\Tag\\VarTag\0" /* 2630 */ + "findSingleVariableLessVarTag\0" /* 2656 */ + "emitVarTagChangedNode"; /* 2685 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 15), /* __construct $fileTypeMapper */ reg::packed(43, 0, 68), /* __construct $templateArgumentObserver */ @@ -386,7 +387,8 @@ inline constexpr reg::PackedArg args[] = { reg::packed(531, 0, 422), /* processStmtVarAnnotation return */ reg::packed(416, 0, 422), /* findSingleVariableLessVarTag $scope */ reg::packed(1238, 0, 1243), /* findSingleVariableLessVarTag $stmt */ - reg::packed(531, MAY_BE_NULL, 2620), /* findSingleVariableLessVarTag return */ + reg::packed(2620, MAY_BE_BOOL, reg::NoString, true, false), /* findSingleVariableLessVarTag $isIgnored */ + reg::packed(531, MAY_BE_NULL, 2630), /* findSingleVariableLessVarTag return */ reg::packed(357, 0, 375), /* emitVarTagChangedNode $nodeScopeResolver */ reg::packed(416, 0, 422), /* emitVarTagChangedNode $scope */ reg::packed(624, 0, 477), /* emitVarTagChangedNode $storage */ @@ -438,8 +440,8 @@ inline constexpr sigtab::Sig collectMentionedVariables = { { 2480 /* collectMent inline constexpr sigtab::Sig getVarTagDependencies = { { 2506 /* getVarTagDependencies */, 2, 201, 2, 203, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig getOverridingThrowPoints = { { 2538 /* getOverridingThrowPoints */, 2, 204, 2, 206, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig processStmtVarAnnotation = { { 2595 /* processStmtVarAnnotation */, 6, 207, 6, 213, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig findSingleVariableLessVarTag = { { 2646 /* findSingleVariableLessVarTag */, 2, 214, 2, 216, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig emitVarTagChangedNode = { { 2675 /* emitVarTagChangedNode */, 6, 217, 6, 223, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig findSingleVariableLessVarTag = { { 2656 /* findSingleVariableLessVarTag */, 3, 214, 3, 217, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig emitVarTagChangedNode = { { 2685 /* emitVarTagChangedNode */, 6, 218, 6, 224, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::StatementsHandler diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 6c0e26f9c3a..50c775d93a9 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -4432,6 +4432,7 @@ enum pt_resolved_php_doc_member PT_RPD_IS_READ_ONLY, PT_RPD_IS_IMMUTABLE, PT_RPD_IS_ALLOWED_PRIVATE_MUTATION, + PT_RPD_ARE_VAR_TAGS_IGNORED, PT_RPD_HAS_PHP_DOC_STRING, PT_RPD_GET_PHP_DOC_STRING, PT_RPD_GET_FILENAME, From cf9bba8cae3051b66d2d1a9a4cc50981674164d8 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 22:54:05 -0500 Subject: [PATCH 3/9] Report `@phpstan-ignore-var` above declarations as having no effect --- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 90 +++++++++++++++---- .../PhpDoc/InvalidPHPStanDocTagRuleTest.php | 47 ++++++++++ .../invalid-phpstan-tag-property-hooks.php | 7 ++ .../data/misplaced-phpstan-ignore-var.php | 70 +++++++++++++++ 4 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index 317c8b01432..35246526a28 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -8,9 +8,12 @@ use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\DependencyInjection\ValidatesStubFiles; use PHPStan\Node\VirtualNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Lexer\Lexer; use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\MultipleNodeTypesRule; use PHPStan\Rules\RuleErrorBuilder; use function in_array; @@ -107,31 +110,82 @@ public function processNode(Node $node, Scope $scope): array } } + $errors = []; $docComment = $node->getDocComment(); - if ($docComment === null) { - return []; - } - $phpDocString = $docComment->getText(); - $tokens = new TokenIterator($this->phpDocLexer->tokenize($phpDocString)); - $phpDocNode = $this->phpDocParser->parse($tokens); + if ($docComment !== null) { + $declaration = self::describeDeclaration($node); + foreach ($this->parsePhpDoc($docComment->getText())->getTags() as $phpDocTag) { + if ($phpDocTag->name === '@phpstan-ignore-var' && $declaration !== null) { + $errors[] = self::createMisplacedIgnoreVarError($node, $phpDocTag, $declaration); + continue; + } - $errors = []; - foreach ($phpDocNode->getTags() as $phpDocTag) { - if (!str_starts_with($phpDocTag->name, '@phpstan-') - || in_array($phpDocTag->name, self::POSSIBLE_PHPSTAN_TAGS, true) - ) { - continue; + if (!str_starts_with($phpDocTag->name, '@phpstan-') + || in_array($phpDocTag->name, self::POSSIBLE_PHPSTAN_TAGS, true) + ) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Unknown PHPDoc tag: %s', + $phpDocTag->name, + )) + ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) + ->identifier('phpDoc.phpstanTag')->build(); } + } + + if ($node instanceof Node\Stmt\ClassMethod) { + foreach ($node->params as $param) { + $paramDocComment = $param->getDocComment(); + if (!$param->isPromoted() || $paramDocComment === null) { + continue; + } - $errors[] = RuleErrorBuilder::message(sprintf( - 'Unknown PHPDoc tag: %s', - $phpDocTag->name, - )) - ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) - ->identifier('phpDoc.phpstanTag')->build(); + foreach ($this->parsePhpDoc($paramDocComment->getText())->getTagsByName('@phpstan-ignore-var') as $phpDocTag) { + $errors[] = self::createMisplacedIgnoreVarError($param, $phpDocTag, 'a promoted property'); + } + } } return $errors; } + private function parsePhpDoc(string $phpDocString): PhpDocNode + { + $tokens = new TokenIterator($this->phpDocLexer->tokenize($phpDocString)); + + return $this->phpDocParser->parse($tokens); + } + + /** + * Null for statements with inline @var tags, the only place @phpstan-ignore-var has an effect. + */ + private static function describeDeclaration(Node $node): ?string + { + return match (true) { + $node instanceof Node\Stmt\Property => 'a property', + $node instanceof Node\PropertyHook => 'a property hook', + $node instanceof Node\Stmt\ClassConst => 'a class constant', + $node instanceof Node\Stmt\Const_ => 'a constant', + $node instanceof Node\Stmt\Function_ => 'a function', + $node instanceof Node\Stmt\ClassMethod => 'a method', + $node instanceof Node\Stmt\Interface_ => 'an interface', + $node instanceof Node\Stmt\Trait_ => 'a trait', + $node instanceof Node\Stmt\Enum_ => 'an enum', + $node instanceof Node\Stmt\ClassLike => 'a class', + default => null, + }; + } + + private static function createMisplacedIgnoreVarError(Node $node, PhpDocTagNode $phpDocTag, string $declaration): IdentifierRuleError + { + return RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @phpstan-ignore-var above %s has no effect.', + $declaration, + )) + ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) + ->identifier('varTag.misplaced')->build(); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php index d3bab774130..d138a567cca 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php @@ -64,6 +64,49 @@ public function testAssignOperator(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testMisplacedIgnoreVar(): void + { + $this->analyse([__DIR__ . '/data/misplaced-phpstan-ignore-var.php'], [ + [ + 'PHPDoc tag @phpstan-ignore-var above a constant has no effect.', + 7, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a function has no effect.', + 12, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a class has no effect.', + 24, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a property has no effect.', + 31, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a class constant has no effect.', + 37, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a method has no effect.', + 42, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a promoted property has no effect.', + 47, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an interface has no effect.', + 57, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a trait has no effect.', + 65, + ], + ]); + } + #[RequiresPhp('>= 8.4.0')] public function testPropertyHooks(): void { @@ -72,6 +115,10 @@ public function testPropertyHooks(): void 'Unknown PHPDoc tag: @phpstan-what', 9, ], + [ + 'PHPDoc tag @phpstan-ignore-var above a property hook has no effect.', + 16, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php index 1221fe7b438..c86bd5f31b8 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php @@ -12,4 +12,11 @@ class Foo } } + public int $j { + /** @phpstan-ignore-var */ + get { + + } + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php new file mode 100644 index 00000000000..f1e1257c60d --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php @@ -0,0 +1,70 @@ += 8.0 + +namespace MisplacedPhpStanIgnoreVar; + +/** + * @var int + * @phpstan-ignore-var + */ +const FOO = 1; + +/** + * @phpstan-ignore-var + */ +function doFoo(): void +{ + /** + * @var int $i + * @phpstan-ignore-var + */ + $i = 1; +} + +/** + * @phpstan-ignore-var + */ +class Foo +{ + + /** + * @var list + * @phpstan-ignore-var + */ + public array $ids = []; + + /** + * @var non-empty-string + * @phpstan-ignore-var + */ + public const NAME = 'foo'; + + /** + * @phpstan-ignore-var + */ + public function __construct( + /** + * @var list + * @phpstan-ignore-var + */ + public array $promoted, + ) + { + } + +} + +/** + * @phpstan-ignore-var + */ +interface Bar +{ + +} + +/** + * @phpstan-ignore-var + */ +trait Baz +{ + +} From 470ab8fcda78c6917957c023a5a0ca1f5f8103be Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 23:06:12 -0500 Subject: [PATCH 4/9] Do not use `match` in `InvalidPHPStanDocTagRule`, the PHP 7.4 downgrade keeps it --- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index 35246526a28..a1352ee225b 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -163,19 +163,38 @@ private function parsePhpDoc(string $phpDocString): PhpDocNode */ private static function describeDeclaration(Node $node): ?string { - return match (true) { - $node instanceof Node\Stmt\Property => 'a property', - $node instanceof Node\PropertyHook => 'a property hook', - $node instanceof Node\Stmt\ClassConst => 'a class constant', - $node instanceof Node\Stmt\Const_ => 'a constant', - $node instanceof Node\Stmt\Function_ => 'a function', - $node instanceof Node\Stmt\ClassMethod => 'a method', - $node instanceof Node\Stmt\Interface_ => 'an interface', - $node instanceof Node\Stmt\Trait_ => 'a trait', - $node instanceof Node\Stmt\Enum_ => 'an enum', - $node instanceof Node\Stmt\ClassLike => 'a class', - default => null, - }; + if ($node instanceof Node\Stmt\Property) { + return 'a property'; + } + if ($node instanceof Node\PropertyHook) { + return 'a property hook'; + } + if ($node instanceof Node\Stmt\ClassConst) { + return 'a class constant'; + } + if ($node instanceof Node\Stmt\Const_) { + return 'a constant'; + } + if ($node instanceof Node\Stmt\Function_) { + return 'a function'; + } + if ($node instanceof Node\Stmt\ClassMethod) { + return 'a method'; + } + if ($node instanceof Node\Stmt\Interface_) { + return 'an interface'; + } + if ($node instanceof Node\Stmt\Trait_) { + return 'a trait'; + } + if ($node instanceof Node\Stmt\Enum_) { + return 'an enum'; + } + if ($node instanceof Node\Stmt\ClassLike) { + return 'a class'; + } + + return null; } private static function createMisplacedIgnoreVarError(Node $node, PhpDocTagNode $phpDocTag, string $declaration): IdentifierRuleError From 7b88a8a3ebc85474228426bd5bb7e4f629769a7c Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 23:06:12 -0500 Subject: [PATCH 5/9] Report `@phpstan-ignore-var` without a `@var` tag as having no effect --- .../PhpDoc/WrongVariableNameInVarTagRule.php | 21 +++++++++- .../WrongVariableNameInVarTagRuleTest.php | 16 ++++++++ .../Rules/PhpDoc/data/phpstan-ignore-var.php | 41 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index fbd3f1c7bb2..f732f80bd40 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -73,6 +73,7 @@ public function processNode(Node $node, Scope $scope): array } $varTags = []; + $errors = []; $function = $scope->getFunction(); foreach ($node->getComments() as $comment) { if (!$comment instanceof Doc) { @@ -85,15 +86,31 @@ public function processNode(Node $node, Scope $scope): array $function !== null ? $function->getName() : null, $comment->getText(), ); - foreach ($resolvedPhpDoc->getVarTags() as $key => $varTag) { + $docVarTags = $resolvedPhpDoc->getVarTags(); + // above declarations InvalidPHPStanDocTagRule reports the tag as misplaced + if (count($docVarTags) === 0 && $resolvedPhpDoc->areVarTagsIgnored() && !$node instanceof VirtualNode) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.') + ->identifier('varTag.misplaced') + ->build(); + } + foreach ($docVarTags as $key => $varTag) { $varTags[$key] = $varTag; } } if (count($varTags) === 0) { - return []; + return $errors; } + return array_merge($errors, $this->processVarTags($node, $scope, $varTags)); + } + + /** + * @param VarTag[] $varTags + * @return list + */ + private function processVarTags(Node\Stmt $node, Scope $scope, array $varTags): array + { if ($node instanceof Node\Stmt\Foreach_) { return $this->processForeach($scope, $node->expr, $node->keyVar, $node->valueVar, $varTags); } diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index fd63e29cae3..2344bf9c32f 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -184,6 +184,22 @@ public function testPhpStanIgnoreVar(): void 'PHPDoc tag @var with type string is not subtype of native type int.', 38, ], + [ + 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', + 73, + ], + [ + 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', + 78, + ], + [ + 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', + 89, + ], + [ + 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', + 98, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php index 1b8bcb7c5ce..7ecd363367a 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php @@ -58,3 +58,44 @@ public function doBaz(int $i): int } } + +class WithoutVarTag +{ + + /** + * @phpstan-ignore-var + */ + public function doFoo(int $i): void + { + /** + * @phpstan-ignore-var + */ + $j = $i; + + /** + * @phpstan-ignore-var + */ + $this->doBar(); + + /** + * @phpstan-var int $k + * @phpstan-ignore-var + */ + $k = $i; + + /** + * @phpstan-ignore-var + */ + foreach ([$i] as $item) { + } + } + + public function doBar(): int + { + /** + * @phpstan-ignore-var + */ + return 1; + } + +} From ba5db074dbfec67012b6b294f3033af7a90f8b76 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 23:42:05 -0500 Subject: [PATCH 6/9] Infer `static` variables whose `@var` tag `@phpstan-ignore-var` ignores --- .../Generics/StaticVariableInference.php | 9 ++++++++- .../Analyser/nsrt/phpstan-ignore-var.php | 11 ++++++++++ turbo-ext/src/StaticVariableInference.cpp | 20 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Generics/StaticVariableInference.php b/src/Analyser/Generics/StaticVariableInference.php index d7cb9ac1d57..d0aa9d76657 100644 --- a/src/Analyser/Generics/StaticVariableInference.php +++ b/src/Analyser/Generics/StaticVariableInference.php @@ -389,8 +389,15 @@ private static function collectRootNames(Expr $expr, array &$names): void private static function hasVarTag(Node\Stmt\Static_ $stmt): bool { $docComment = $stmt->getDocComment(); + if ($docComment === null) { + return false; + } + + $text = $docComment->getText(); - return $docComment !== null && preg_match('~@(?:phpstan-|psalm-)?var\s~', $docComment->getText()) === 1; + // a @var tag ignored by @phpstan-ignore-var does not type the variable, so it is inferred + return preg_match('~@(?:phpstan-|psalm-)?var\s~', $text) === 1 + && preg_match('~@phpstan-ignore-var\b~', $text) !== 1; } } diff --git a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php index efbaf52cc8a..c829f50390c 100644 --- a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php +++ b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php @@ -63,3 +63,14 @@ function doBaz(int $i): void echo $i; assertType('int', $i); } + +function doStatic(int $i): void +{ + /** + * @var array $cache + * @phpstan-ignore-var + */ + static $cache = []; + assertType('array', $cache); + $cache[$i] = 'x'; +} diff --git a/turbo-ext/src/StaticVariableInference.cpp b/turbo-ext/src/StaticVariableInference.cpp index 7f7d38f6c4a..50a859b3c71 100644 --- a/turbo-ext/src/StaticVariableInference.cpp +++ b/turbo-ext/src/StaticVariableInference.cpp @@ -137,12 +137,30 @@ bool declaresVar(zend_string *text) return false; } +/* preg_match('~@phpstan-ignore-var\b~', $text) === 1 */ +bool ignoresVar(zend_string *text) +{ + const char *s = ZSTR_VAL(text); + size_t n = ZSTR_LEN(text); + const char tag[] = "@phpstan-ignore-var"; + const size_t len = sizeof(tag) - 1; + for (size_t i = 0; i + len <= n; i++) { + if (s[i] != '@' || memcmp(s + i, tag, len) != 0) continue; + // \b after the word character 'r': the end of the text or a non-word character + if (i + len == n) return true; + char c = s[i + len]; + bool isWord = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + if (!isWord) return true; + } + return false; +} + /* Mirrors the private hasVarTag(); false = pending exception */ [[nodiscard]] bool hasVarTag(zval *stmt, bool &out) { zv::Val text = pt_node_doc_comment_text(stmt); if (UNEXPECTED(text.isUndef())) return false; - out = text.ref().isString() && declaresVar(Z_STR_P(text.raw())); + out = text.ref().isString() && declaresVar(Z_STR_P(text.raw())) && !ignoresVar(Z_STR_P(text.raw())); return true; } From d9239ecad1964c955efaedb6b475793e1979e920 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Thu, 29 Oct 2026 23:42:05 -0500 Subject: [PATCH 7/9] Check ignored `@var` tags once per statement and tighten the `@phpstan-ignore-var` errors --- src/Analyser/StatementsHandler.php | 9 +- src/PhpDoc/PhpDocNodeResolver.php | 5 +- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 6 ++ .../PhpDoc/WrongVariableNameInVarTagRule.php | 86 +++++++++++++++++-- .../PhpDoc/InvalidPHPStanDocTagRuleTest.php | 15 ++++ .../VarTagChangedExpressionTypeRuleTest.php | 4 - .../WrongVariableNameInVarTagRuleTest.php | 28 ++++++ .../misplaced-phpstan-ignore-var-enum.php | 13 +++ .../data/misplaced-phpstan-ignore-var.php | 11 +++ .../Rules/PhpDoc/data/phpstan-ignore-var.php | 38 ++++++++ turbo-ext/src/StatementsHandler.cpp | 4 +- 11 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var-enum.php diff --git a/src/Analyser/StatementsHandler.php b/src/Analyser/StatementsHandler.php index 431962280b9..c3719e58a00 100644 --- a/src/Analyser/StatementsHandler.php +++ b/src/Analyser/StatementsHandler.php @@ -1953,6 +1953,12 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M continue; } + // WrongVariableNameInVarTagRule checks these: with their type not assigned, the + // second run of this method for an assignment would report them again + if ($areVarTagsIgnored) { + continue; + } + if ($name === $assignedVariable) { continue; } @@ -1975,9 +1981,6 @@ public function processStmtVarAnnotation(NodeScopeResolver $nodeScopeResolver, M if (!$originalType->equals($varTag->getType())) { $nodeScopeResolver->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $variableNode), $scope, $storage); } - if ($areVarTagsIgnored) { - continue; - } $templateArgumentFrame = $nodeScopeResolver->observingTemplateArgumentFrame($scope); if ($templateArgumentFrame !== null) { $scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($varTag->getType(), $originalType)); diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 6cb30297a5f..a4026e602c7 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -55,6 +55,9 @@ final class PhpDocNodeResolver { + /** The names of the @var tag, the last one taking precedence. */ + public const VAR_TAG_NAMES = ['@var', '@phan-var', '@psalm-var', '@phpstan-var']; + public function __construct( private TypeNodeResolver $typeNodeResolver, private ConstExprNodeResolver $constExprNodeResolver, @@ -70,7 +73,7 @@ public function resolveVarTags(PhpDocNode $phpDocNode, NameScope $nameScope): ar { $resolved = []; $resolvedByTag = []; - foreach (['@var', '@phan-var', '@psalm-var', '@phpstan-var'] as $tagName) { + foreach (self::VAR_TAG_NAMES as $tagName) { $tagResolved = []; foreach ($phpDocNode->getVarTagValues($tagName) as $tagValue) { $type = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index a1352ee225b..aeaacb2e8fb 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -193,6 +193,12 @@ private static function describeDeclaration(Node $node): ?string if ($node instanceof Node\Stmt\ClassLike) { return 'a class'; } + if ($node instanceof Node\Stmt\EnumCase) { + return 'an enum case'; + } + if ($node instanceof Node\Stmt\TraitUse) { + return 'a trait use'; + } return null; } diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index f732f80bd40..3e9eebe3bd3 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -12,6 +12,8 @@ use PHPStan\Node\InClassNode; use PHPStan\Node\InFunctionNode; use PHPStan\Node\VirtualNode; +use PHPStan\PhpDoc\PhpDocNodeResolver; +use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\Tag\VarTag; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; @@ -73,8 +75,12 @@ public function processNode(Node $node, Scope $scope): array } $varTags = []; + $ignoredVarTags = []; $errors = []; $function = $scope->getFunction(); + // inline @var tags apply in functions and outside classes; above declarations, enum cases + // and trait uses InvalidPHPStanDocTagRule reports @phpstan-ignore-var as misplaced + $canIgnoreVarTags = !$node instanceof VirtualNode && !($scope->isInClass() && $function === null); foreach ($node->getComments() as $comment) { if (!$comment instanceof Doc) { continue; @@ -87,17 +93,28 @@ public function processNode(Node $node, Scope $scope): array $comment->getText(), ); $docVarTags = $resolvedPhpDoc->getVarTags(); - // above declarations InvalidPHPStanDocTagRule reports the tag as misplaced - if (count($docVarTags) === 0 && $resolvedPhpDoc->areVarTagsIgnored() && !$node instanceof VirtualNode) { - $errors[] = RuleErrorBuilder::message('PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.') - ->identifier('varTag.misplaced') - ->build(); + if ($canIgnoreVarTags && $resolvedPhpDoc->areVarTagsIgnored()) { + if (!self::hasVarTag($resolvedPhpDoc)) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.') + ->identifier('varTag.misplaced') + ->build(); + } + foreach ($docVarTags as $key => $varTag) { + if (!is_string($key)) { + continue; + } + $ignoredVarTags[$key] = $varTag; + } } foreach ($docVarTags as $key => $varTag) { $varTags[$key] = $varTag; } } + foreach ($this->processIgnoredVarTags($node, $scope, $ignoredVarTags) as $error) { + $errors[] = $error; + } + if (count($varTags) === 0) { return $errors; } @@ -105,6 +122,65 @@ public function processNode(Node $node, Scope $scope): array return array_merge($errors, $this->processVarTags($node, $scope, $varTags)); } + /** + * Whether the PHPDoc has a @var tag of any prefix, including one with an invalid value. + */ + private static function hasVarTag(ResolvedPhpDocBlock $resolvedPhpDoc): bool + { + foreach ($resolvedPhpDoc->getPhpDocNodes() as $phpDocNode) { + foreach (PhpDocNodeResolver::VAR_TAG_NAMES as $tagName) { + if (count($phpDocNode->getTagsByName($tagName)) > 0) { + return true; + } + } + } + + return false; + } + + /** + * StatementsHandler::processStmtVarAnnotation() reports @var tags naming an existing variable + * through VarTagChangedExpressionTypeNode, but skips them in a PHPDoc with @phpstan-ignore-var: + * it runs twice for an assignment and, with their type not assigned, would report them twice. + * The same check runs here instead, once per statement. + * + * @param array $varTags + * @return list + */ + private function processIgnoredVarTags(Node\Stmt $node, Scope $scope, array $varTags): array + { + // mirrored from StatementsHandler::processStmtVarAnnotation(), which is not called for foreach + if ($node instanceof Node\Stmt\Foreach_) { + return []; + } + + $assignedVariable = null; + if ( + $node instanceof Node\Stmt\Expression + && ($node->expr instanceof Expr\Assign || $node->expr instanceof Expr\AssignRef) + && $node->expr->var instanceof Expr\Variable + && is_string($node->expr->var->name) + ) { + $assignedVariable = $node->expr->var->name; + } + + $errors = []; + foreach ($varTags as $name => $varTag) { + if ($name === $assignedVariable || $scope->hasVariableType($name)->no()) { + continue; + } + if ($scope->getVariableType($name)->equals($varTag->getType())) { + continue; + } + + foreach ($this->varTagTypeRuleHelper->checkExprType($scope, new Expr\Variable($name), $varTag->getType()) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + /** * @param VarTag[] $varTags * @return list diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php index d138a567cca..5ae8d645699 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php @@ -104,6 +104,21 @@ public function testMisplacedIgnoreVar(): void 'PHPDoc tag @phpstan-ignore-var above a trait has no effect.', 65, ], + [ + 'PHPDoc tag @phpstan-ignore-var above a trait use has no effect.', + 77, + ], + ]); + } + + #[RequiresPhp('>= 8.1.0')] + public function testMisplacedIgnoreVarInEnum(): void + { + $this->analyse([__DIR__ . '/data/misplaced-phpstan-ignore-var-enum.php'], [ + [ + 'PHPDoc tag @phpstan-ignore-var above an enum case has no effect.', + 9, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php index 88dc64607b9..3145f13bdad 100644 --- a/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/VarTagChangedExpressionTypeRuleTest.php @@ -52,10 +52,6 @@ public function testRule(): void public function testPhpStanIgnoreVar(): void { $this->analyse([__DIR__ . '/data/phpstan-ignore-var.php'], [ - [ - 'PHPDoc tag @var with type string is not subtype of native type int.', - 48, - ], [ 'PHPDoc tag @var with type string is not subtype of native type int.', 57, diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 2344bf9c32f..6b659097efe 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -184,6 +184,10 @@ public function testPhpStanIgnoreVar(): void 'PHPDoc tag @var with type string is not subtype of native type int.', 38, ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 48, + ], [ 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', 73, @@ -200,9 +204,33 @@ public function testPhpStanIgnoreVar(): void 'PHPDoc tag @phpstan-ignore-var without a @var tag has no effect.', 98, ], + [ + 'PHPDoc tag @var with type string is not subtype of native type 1.', + 121, + ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 127, + ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 136, + ], ]); } + #[RequiresPhp('>= 8.0.0')] + public function testMisplacedPhpStanIgnoreVar(): void + { + $this->analyse([__DIR__ . '/data/misplaced-phpstan-ignore-var.php'], []); + } + + #[RequiresPhp('>= 8.1.0')] + public function testMisplacedPhpStanIgnoreVarInEnum(): void + { + $this->analyse([__DIR__ . '/data/misplaced-phpstan-ignore-var-enum.php'], []); + } + public function testBug3515(): void { $this->analyse([__DIR__ . '/data/bug-3515.php'], []); diff --git a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var-enum.php b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var-enum.php new file mode 100644 index 00000000000..efe9ffa6bc2 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var-enum.php @@ -0,0 +1,13 @@ += 8.1 + +namespace MisplacedPhpStanIgnoreVarEnum; + +enum Suit +{ + + /** + * @phpstan-ignore-var + */ + case Hearts; + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php index f1e1257c60d..82e13882783 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php @@ -68,3 +68,14 @@ trait Baz { } + +class UsesTrait +{ + + /** + * @var int $x + * @phpstan-ignore-var + */ + use Baz; + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php index 7ecd363367a..02173d13bac 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php @@ -98,4 +98,42 @@ public function doBar(): int return 1; } + public function doBaz(object $o): void + { + /** + * @var arraydoFoo(); + } + +} + +class ReportedOnce +{ + + public function doFoo(int $other): void + { + /** + * @var string $a + * @phpstan-ignore-var + */ + [$a, $b] = [1, 2]; + + /** + * @var string $other + * @phpstan-ignore-var + */ + $x = 1; + } + + public function doBar(int $i): int + { + /** + * @var string $i + * @phpstan-ignore-var + */ + return $i; + } + } diff --git a/turbo-ext/src/StatementsHandler.cpp b/turbo-ext/src/StatementsHandler.cpp index 3b569bfc642..7983114c76f 100644 --- a/turbo-ext/src/StatementsHandler.cpp +++ b/turbo-ext/src/StatementsHandler.cpp @@ -1100,6 +1100,9 @@ class StatementsHandler isVariableLessTagIgnored = areVarTagsIgnored; continue; } + // WrongVariableNameInVarTagRule checks these: with their type not assigned, the + // second run of this method for an assignment would report them again + if (areVarTagsIgnored) continue; zend_string *name = tagEntry.stringKey(); if (assignedVariable != NULL && zend_string_equals(name, assignedVariable)) continue; @@ -1147,7 +1150,6 @@ class StatementsHandler if (UNEXPECTED(changedNode.isUndef())) return zv::Val(); if (UNEXPECTED(!pt_node_scope_resolver_call_node_callback(nodeScopeResolver, nodeCallback, changedNode.raw(), scope.raw(), storage))) return zv::Val(); } - if (areVarTagsIgnored) continue; zv::Val templateArgumentFrame = pt_node_scope_resolver_observing_template_argument_frame(nodeScopeResolver, scope.raw()); if (UNEXPECTED(templateArgumentFrame.isUndef())) return zv::Val(); if (!templateArgumentFrame.isNull()) { From 612020eaa4465b3fb27385de69cc0af3eab74e70 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Fri, 30 Oct 2026 00:15:50 -0500 Subject: [PATCH 8/9] Support one-line `@phpstan-ignore-var` and tighten its checks - recognise the tag on the line of a `@var` tag, as in `/** @var Foo $x @phpstan-ignore-var */` - do not check ignored `@var` tags above `global` and `static` statements against an earlier variable - report the tag above parameters, closures and arrow functions and at the end of a class body --- src/PhpDoc/PhpDocNodeResolver.php | 26 ++++- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 109 +++++++++++++----- .../PhpDoc/WrongVariableNameInVarTagRule.php | 10 +- .../Analyser/nsrt/phpstan-ignore-var.php | 7 ++ .../PhpDoc/InvalidPHPStanDocTagRuleTest.php | 24 ++++ .../WrongVariableNameInVarTagRuleTest.php | 4 + .../data/misplaced-phpstan-ignore-var.php | 27 +++++ .../Rules/PhpDoc/data/phpstan-ignore-var.php | 31 +++++ 8 files changed, 203 insertions(+), 35 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index a4026e602c7..4b647cbf422 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -30,7 +30,9 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNullNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\Reflection\PassedByReference; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Type\Generic\TemplateTypeFactory; @@ -48,6 +50,7 @@ use function count; use function in_array; use function method_exists; +use function preg_match; use function str_starts_with; use function substr; @@ -720,7 +723,28 @@ public function resolveAllMethodsImpure(PhpDocNode $phpDocNode): bool public function resolveAreVarTagsIgnored(PhpDocNode $phpDocNode): bool { - return count($phpDocNode->getTagsByName('@phpstan-ignore-var')) > 0; + if (count($phpDocNode->getTagsByName('@phpstan-ignore-var')) > 0) { + return true; + } + + foreach ($phpDocNode->getTags() as $phpDocTag) { + if (self::isIgnoreVarInVarTagDescription($phpDocTag)) { + return true; + } + } + + return false; + } + + /** + * phpdoc-parser reads @phpstan-ignore-var written on the line of a @var tag, as in + * a one-line PHPDoc, as the description of that tag. + */ + public static function isIgnoreVarInVarTagDescription(PhpDocTagNode $phpDocTag): bool + { + return in_array($phpDocTag->name, self::VAR_TAG_NAMES, true) + && $phpDocTag->value instanceof VarTagValueNode + && preg_match('~@phpstan-ignore-var\b~', $phpDocTag->value->description) === 1; } public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index aeaacb2e8fb..809ff8e9f7f 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -2,12 +2,14 @@ namespace PHPStan\Rules\PhpDoc; +use PhpParser\Comment\Doc; use PhpParser\Node; use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\DependencyInjection\ValidatesStubFiles; use PHPStan\Node\VirtualNode; +use PHPStan\PhpDoc\PhpDocNodeResolver; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -16,8 +18,10 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\MultipleNodeTypesRule; use PHPStan\Rules\RuleErrorBuilder; +use function array_merge; use function in_array; use function sprintf; +use function str_contains; use function str_starts_with; /** @@ -83,7 +87,7 @@ public function __construct( public function getNodeTypes(): array { - return [Node\Stmt::class, Node\PropertyHook::class]; + return [Node\Stmt::class, Node\PropertyHook::class, Node\Expr\Closure::class, Node\Expr\ArrowFunction::class]; } public function getNodeType(): string @@ -97,6 +101,13 @@ public function processNode(Node $node, Scope $scope): array if ($node instanceof VirtualNode) { return []; } + // closure PHPDocs are only checked for @phpstan-ignore-var, which has no effect there + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + return array_merge( + $this->processMisplacedIgnoreVar($node, $node->getDocComment(), $node instanceof Node\Expr\Closure ? 'above a closure' : 'above an arrow function'), + $this->processParameters($node), + ); + } if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { return []; } @@ -113,10 +124,10 @@ public function processNode(Node $node, Scope $scope): array $errors = []; $docComment = $node->getDocComment(); if ($docComment !== null) { - $declaration = self::describeDeclaration($node); + $placement = self::describeMisplacement($node, $scope); foreach ($this->parsePhpDoc($docComment->getText())->getTags() as $phpDocTag) { - if ($phpDocTag->name === '@phpstan-ignore-var' && $declaration !== null) { - $errors[] = self::createMisplacedIgnoreVarError($node, $phpDocTag, $declaration); + if ($placement !== null && self::isIgnoreVarTag($phpDocTag)) { + $errors[] = self::createMisplacedIgnoreVarError($node, $phpDocTag, $placement); continue; } @@ -135,16 +146,9 @@ public function processNode(Node $node, Scope $scope): array } } - if ($node instanceof Node\Stmt\ClassMethod) { - foreach ($node->params as $param) { - $paramDocComment = $param->getDocComment(); - if (!$param->isPromoted() || $paramDocComment === null) { - continue; - } - - foreach ($this->parsePhpDoc($paramDocComment->getText())->getTagsByName('@phpstan-ignore-var') as $phpDocTag) { - $errors[] = self::createMisplacedIgnoreVarError($param, $phpDocTag, 'a promoted property'); - } + if ($node instanceof Node\FunctionLike) { + foreach ($this->processParameters($node) as $error) { + $errors[] = $error; } } @@ -158,56 +162,101 @@ private function parsePhpDoc(string $phpDocString): PhpDocNode return $this->phpDocParser->parse($tokens); } + /** + * @return list + */ + private function processParameters(Node\FunctionLike $node): array + { + $errors = []; + foreach ($node->getParams() as $param) { + $placement = $param->isPromoted() ? 'above a promoted property' : 'above a parameter'; + foreach ($this->processMisplacedIgnoreVar($param, $param->getDocComment(), $placement) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function processMisplacedIgnoreVar(Node $node, ?Doc $docComment, string $placement): array + { + if ($docComment === null || !str_contains($docComment->getText(), '@phpstan-ignore-var')) { + return []; + } + + $errors = []; + foreach ($this->parsePhpDoc($docComment->getText())->getTags() as $phpDocTag) { + if (!self::isIgnoreVarTag($phpDocTag)) { + continue; + } + + $errors[] = self::createMisplacedIgnoreVarError($node, $phpDocTag, $placement); + } + + return $errors; + } + + private static function isIgnoreVarTag(PhpDocTagNode $phpDocTag): bool + { + return $phpDocTag->name === '@phpstan-ignore-var' || PhpDocNodeResolver::isIgnoreVarInVarTagDescription($phpDocTag); + } + /** * Null for statements with inline @var tags, the only place @phpstan-ignore-var has an effect. */ - private static function describeDeclaration(Node $node): ?string + private static function describeMisplacement(Node $node, Scope $scope): ?string { if ($node instanceof Node\Stmt\Property) { - return 'a property'; + return 'above a property'; } if ($node instanceof Node\PropertyHook) { - return 'a property hook'; + return 'above a property hook'; } if ($node instanceof Node\Stmt\ClassConst) { - return 'a class constant'; + return 'above a class constant'; } if ($node instanceof Node\Stmt\Const_) { - return 'a constant'; + return 'above a constant'; } if ($node instanceof Node\Stmt\Function_) { - return 'a function'; + return 'above a function'; } if ($node instanceof Node\Stmt\ClassMethod) { - return 'a method'; + return 'above a method'; } if ($node instanceof Node\Stmt\Interface_) { - return 'an interface'; + return 'above an interface'; } if ($node instanceof Node\Stmt\Trait_) { - return 'a trait'; + return 'above a trait'; } if ($node instanceof Node\Stmt\Enum_) { - return 'an enum'; + return 'above an enum'; } if ($node instanceof Node\Stmt\ClassLike) { - return 'a class'; + return 'above a class'; } if ($node instanceof Node\Stmt\EnumCase) { - return 'an enum case'; + return 'above an enum case'; } if ($node instanceof Node\Stmt\TraitUse) { - return 'a trait use'; + return 'above a trait use'; + } + if ($node instanceof Node\Stmt\Nop && $scope->isInClass() && $scope->getFunction() === null) { + return 'at the end of a class body'; } return null; } - private static function createMisplacedIgnoreVarError(Node $node, PhpDocTagNode $phpDocTag, string $declaration): IdentifierRuleError + private static function createMisplacedIgnoreVarError(Node $node, PhpDocTagNode $phpDocTag, string $placement): IdentifierRuleError { return RuleErrorBuilder::message(sprintf( - 'PHPDoc tag @phpstan-ignore-var above %s has no effect.', - $declaration, + 'PHPDoc tag @phpstan-ignore-var %s has no effect.', + $placement, )) ->line(PhpDocLineHelper::detectLine($node, $phpDocTag)) ->identifier('varTag.misplaced')->build(); diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index 3e9eebe3bd3..c27c26bcba7 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -78,8 +78,9 @@ public function processNode(Node $node, Scope $scope): array $ignoredVarTags = []; $errors = []; $function = $scope->getFunction(); - // inline @var tags apply in functions and outside classes; above declarations, enum cases - // and trait uses InvalidPHPStanDocTagRule reports @phpstan-ignore-var as misplaced + // inline @var tags apply in functions and outside classes; elsewhere (above declarations, + // enum cases and trait uses, at the end of a class body) InvalidPHPStanDocTagRule reports + // the @phpstan-ignore-var tag as misplaced $canIgnoreVarTags = !$node instanceof VirtualNode && !($scope->isInClass() && $function === null); foreach ($node->getComments() as $comment) { if (!$comment instanceof Doc) { @@ -149,8 +150,9 @@ private static function hasVarTag(ResolvedPhpDocBlock $resolvedPhpDoc): bool */ private function processIgnoredVarTags(Node\Stmt $node, Scope $scope, array $varTags): array { - // mirrored from StatementsHandler::processStmtVarAnnotation(), which is not called for foreach - if ($node instanceof Node\Stmt\Foreach_) { + // mirrored from NodeScopeResolver::processStmtNode(), which does not call + // StatementsHandler::processStmtVarAnnotation() for these + if ($node instanceof Node\Stmt\Foreach_ || $node instanceof Node\Stmt\Static_ || $node instanceof Node\Stmt\Global_) { return []; } diff --git a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php index c829f50390c..24a65a51285 100644 --- a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php +++ b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php @@ -74,3 +74,10 @@ function doStatic(int $i): void assertType('array', $cache); $cache[$i] = 'x'; } + +function doOneLine(): void +{ + /** @var list<\DateTime> $times @phpstan-ignore-var */ + $times = getTimes(3); + assertType('iterable<(int|string), DateTime>', $times); +} diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php index 5ae8d645699..9cea957e911 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php @@ -108,6 +108,30 @@ public function testMisplacedIgnoreVar(): void 'PHPDoc tag @phpstan-ignore-var above a trait use has no effect.', 77, ], + [ + 'PHPDoc tag @phpstan-ignore-var above a property has no effect.', + 86, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a parameter has no effect.', + 92, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a closure has no effect.', + 97, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a parameter has no effect.', + 98, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an arrow function has no effect.', + 102, + ], + [ + 'PHPDoc tag @phpstan-ignore-var at the end of a class body has no effect.', + 106, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 6b659097efe..48a48abe21c 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -216,6 +216,10 @@ public function testPhpStanIgnoreVar(): void 'PHPDoc tag @var with type string is not subtype of native type int.', 136, ], + [ + 'PHPDoc tag @var with type string is not subtype of native type int.', + 167, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php index 82e13882783..b4bd0cd3d1e 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php @@ -79,3 +79,30 @@ class UsesTrait use Baz; } + +class OneLineAndParameters +{ + + /** @var list @phpstan-ignore-var */ + public array $oneLine = []; + + public function doFoo( + /** + * @var int + * @phpstan-ignore-var + */ + $notPromoted, + ): void + { + $f = /** @phpstan-ignore-var */ function ( + /** @phpstan-ignore-var */ + $x, + ): void { + }; + $g = /** @phpstan-ignore-var */ fn () => 1; + } + + /** + * @phpstan-ignore-var + */ +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php index 02173d13bac..4cfe6433071 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php @@ -137,3 +137,34 @@ public function doBar(int $i): int } } + +class GlobalAndStatic +{ + + public function doFoo(): void + { + $x = 1; + /** + * @var string $x + * @phpstan-ignore-var + */ + global $x; + } + + public function doBar(): void + { + $n = 'a'; + /** + * @var int $n + * @phpstan-ignore-var + */ + static $n = 0; + } + + public function doBaz(int $i): void + { + /** @var string $s @phpstan-ignore-var */ + $s = $i; + } + +} From 0bfca1d3a4b8656e60d956a64a716fdfe7d76356 Mon Sep 17 00:00:00 2001 From: Jack Worman Date: Fri, 30 Oct 2026 07:51:57 -0500 Subject: [PATCH 9/9] Report misplaced `@phpstan-ignore-var` in more places and anchor the one-line form - report the tag above a closure or arrow function passed as an argument or written as an array item, whose PHPDoc php-parser attaches to the argument or the item - report it above `elseif`, `else` and `catch`, where no `@var` applies, instead of checking the ignored tags there - recognise it in a `@var` description only at the start, so a description that mentions it keeps the `@var` applied - cover an ignored `@var` over a variable that may not be defined --- src/PhpDoc/PhpDocNodeResolver.php | 5 ++- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 28 ++++++++++++- .../PhpDoc/WrongVariableNameInVarTagRule.php | 12 ++++-- .../Analyser/nsrt/phpstan-ignore-var.php | 11 +++++ .../PhpDoc/InvalidPHPStanDocTagRuleTest.php | 24 +++++++++++ .../WrongVariableNameInVarTagRuleTest.php | 4 ++ .../data/misplaced-phpstan-ignore-var.php | 42 +++++++++++++++++++ .../Rules/PhpDoc/data/phpstan-ignore-var.php | 18 ++++++++ 8 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 4b647cbf422..5da797fe3a8 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -738,13 +738,14 @@ public function resolveAreVarTagsIgnored(PhpDocNode $phpDocNode): bool /** * phpdoc-parser reads @phpstan-ignore-var written on the line of a @var tag, as in - * a one-line PHPDoc, as the description of that tag. + * a one-line PHPDoc, as the description of that tag. It counts only at the start of + * the description, so a description that mentions the tag keeps the @var applied. */ public static function isIgnoreVarInVarTagDescription(PhpDocTagNode $phpDocTag): bool { return in_array($phpDocTag->name, self::VAR_TAG_NAMES, true) && $phpDocTag->value instanceof VarTagValueNode - && preg_match('~@phpstan-ignore-var\b~', $phpDocTag->value->description) === 1; + && preg_match('~^@phpstan-ignore-var\b~', $phpDocTag->value->description) === 1; } public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index 809ff8e9f7f..2595b7e85f3 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -87,7 +87,7 @@ public function __construct( public function getNodeTypes(): array { - return [Node\Stmt::class, Node\PropertyHook::class, Node\Expr\Closure::class, Node\Expr\ArrowFunction::class]; + return [Node\Stmt::class, Node\PropertyHook::class, Node\Expr\Closure::class, Node\Expr\ArrowFunction::class, Node\Arg::class, Node\ArrayItem::class]; } public function getNodeType(): string @@ -104,10 +104,19 @@ public function processNode(Node $node, Scope $scope): array // closure PHPDocs are only checked for @phpstan-ignore-var, which has no effect there if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { return array_merge( - $this->processMisplacedIgnoreVar($node, $node->getDocComment(), $node instanceof Node\Expr\Closure ? 'above a closure' : 'above an arrow function'), + $this->processMisplacedIgnoreVar($node, $node->getDocComment(), self::describeClosure($node)), $this->processParameters($node), ); } + // php-parser gives the PHPDoc of a closure passed as an argument or written as an + // array item to the argument or the item + if ($node instanceof Node\Arg || $node instanceof Node\ArrayItem) { + if (!$node->value instanceof Node\Expr\Closure && !$node->value instanceof Node\Expr\ArrowFunction) { + return []; + } + + return $this->processMisplacedIgnoreVar($node, $node->getDocComment(), self::describeClosure($node->value)); + } if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { return []; } @@ -204,6 +213,11 @@ private static function isIgnoreVarTag(PhpDocTagNode $phpDocTag): bool return $phpDocTag->name === '@phpstan-ignore-var' || PhpDocNodeResolver::isIgnoreVarInVarTagDescription($phpDocTag); } + private static function describeClosure(Node\Expr\Closure|Node\Expr\ArrowFunction $node): string + { + return $node instanceof Node\Expr\Closure ? 'above a closure' : 'above an arrow function'; + } + /** * Null for statements with inline @var tags, the only place @phpstan-ignore-var has an effect. */ @@ -245,6 +259,16 @@ private static function describeMisplacement(Node $node, Scope $scope): ?string if ($node instanceof Node\Stmt\TraitUse) { return 'above a trait use'; } + // NodeScopeResolver processes only the statements inside these, so no @var above them applies + if ($node instanceof Node\Stmt\ElseIf_) { + return 'above an elseif branch'; + } + if ($node instanceof Node\Stmt\Else_) { + return 'above an else branch'; + } + if ($node instanceof Node\Stmt\Catch_) { + return 'above a catch block'; + } if ($node instanceof Node\Stmt\Nop && $scope->isInClass() && $scope->getFunction() === null) { return 'at the end of a class body'; } diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index c27c26bcba7..6039b12977d 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -78,10 +78,14 @@ public function processNode(Node $node, Scope $scope): array $ignoredVarTags = []; $errors = []; $function = $scope->getFunction(); - // inline @var tags apply in functions and outside classes; elsewhere (above declarations, - // enum cases and trait uses, at the end of a class body) InvalidPHPStanDocTagRule reports - // the @phpstan-ignore-var tag as misplaced - $canIgnoreVarTags = !$node instanceof VirtualNode && !($scope->isInClass() && $function === null); + // inline @var tags apply to statements in functions and outside classes; elsewhere (above + // declarations, enum cases, trait uses, elseif, else and catch, at the end of a class body) + // InvalidPHPStanDocTagRule reports the @phpstan-ignore-var tag as misplaced + $canIgnoreVarTags = !$node instanceof VirtualNode + && !$node instanceof Node\Stmt\ElseIf_ + && !$node instanceof Node\Stmt\Else_ + && !$node instanceof Node\Stmt\Catch_ + && !($scope->isInClass() && $function === null); foreach ($node->getComments() as $comment) { if (!$comment instanceof Doc) { continue; diff --git a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php index 24a65a51285..02fb15986f9 100644 --- a/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php +++ b/tests/PHPStan/Analyser/nsrt/phpstan-ignore-var.php @@ -81,3 +81,14 @@ function doOneLine(): void $times = getTimes(3); assertType('iterable<(int|string), DateTime>', $times); } + +function doDescription(): void +{ + /** @var list<\DateTime> $times @phpstan-ignore-var because the IDE needs it */ + $times = getTimes(3); + assertType('iterable<(int|string), DateTime>', $times); + + /** @var list $ids unlike a tag followed by @phpstan-ignore-var, this one applies */ + $ids = [1, 2]; + assertType('list', $ids); +} diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php index 9cea957e911..5cfcdb5ca4d 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php @@ -132,6 +132,30 @@ public function testMisplacedIgnoreVar(): void 'PHPDoc tag @phpstan-ignore-var at the end of a class body has no effect.', 106, ], + [ + 'PHPDoc tag @phpstan-ignore-var above a closure has no effect.', + 112, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an arrow function has no effect.', + 115, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an arrow function has no effect.', + 117, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an elseif branch has no effect.', + 129, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above an else branch has no effect.', + 135, + ], + [ + 'PHPDoc tag @phpstan-ignore-var above a catch block has no effect.', + 146, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 48a48abe21c..c8133c6a45e 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -220,6 +220,10 @@ public function testPhpStanIgnoreVar(): void 'PHPDoc tag @var with type string is not subtype of native type int.', 167, ], + [ + 'PHPDoc tag @var with type string is not subtype of native type 1.', + 185, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php index b4bd0cd3d1e..4485be7d4ba 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/misplaced-phpstan-ignore-var.php @@ -106,3 +106,45 @@ public function doFoo( * @phpstan-ignore-var */ } + +function closuresAsArgumentsAndArrayItems(array $a): void +{ + array_map(/** @phpstan-ignore-var */ function (int $x): int { + return $x; + }, $a); + usort($a, /** @phpstan-ignore-var */ static fn (int $x, int $y): int => $x <=> $y); + $callbacks = [ + /** @phpstan-ignore-var */ + fn (): int => 1, + ]; +} + +function elseIfElseAndCatch(int $x, bool $a, bool $b): void +{ + if ($a) { + echo 1; + } + /** + * @var string $x + * @phpstan-ignore-var + */ + elseif ($b) { + echo 2; + } + /** + * @phpstan-ignore-var + */ + else { + echo 3; + } + + try { + echo 4; + } + /** + * @var string $x + * @phpstan-ignore-var + */ + catch (\Exception $e) { + } +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php index 4cfe6433071..5d47753ca23 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php +++ b/tests/PHPStan/Rules/PhpDoc/data/phpstan-ignore-var.php @@ -168,3 +168,21 @@ public function doBaz(int $i): void } } + +class MaybeDefined +{ + + public function doFoo(bool $b): void + { + if ($b) { + $m = 1; + } + + /** + * @var string $m + * @phpstan-ignore-var + */ + echo 'x'; + } + +}