diff --git a/.gitattributes b/.gitattributes
index 615bf05f..aae3fd19 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,12 +2,10 @@
*.stub linguist-language=PHP
*.neon linguist-language=YAML
-.github export-ignore
-tests export-ignore
-tmp export-ignore
-.gitattributes export-ignore
-.gitignore export-ignore
-Makefile export-ignore
-phpcs.xml export-ignore
-phpstan.neon export-ignore
-phpunit.xml export-ignore
+/.* export-ignore
+/tests export-ignore
+/tmp export-ignore
+/Makefile export-ignore
+/phpstan.neon export-ignore
+/phpstan-baseline.neon export-ignore
+/phpunit.xml export-ignore
diff --git a/.github/renovate.json b/.github/renovate.json
index b775cc18..d3f5961e 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -10,11 +10,6 @@
"enabled": true,
"groupName": "root-composer"
},
- {
- "matchPaths": ["build-cs/**"],
- "enabled": true,
- "groupName": "build-cs"
- },
{
"matchPaths": [".github/**"],
"enabled": true,
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 82332bb6..bc327e8c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,7 +6,7 @@ on:
pull_request:
push:
branches:
- - "1.1.x"
+ - "1.3.x"
jobs:
lint:
@@ -21,6 +21,7 @@ jobs:
- "7.4"
- "8.0"
- "8.1"
+ - "8.2"
steps:
- name: "Checkout"
@@ -45,7 +46,7 @@ jobs:
- name: "Lint"
run: "make lint"
- coding-standards:
+ coding-standard:
name: "Coding Standard"
runs-on: "ubuntu-latest"
@@ -54,11 +55,17 @@ jobs:
- name: "Checkout"
uses: actions/checkout@v3
+ - name: "Checkout build-cs"
+ uses: actions/checkout@v3
+ with:
+ repository: "phpstan/build-cs"
+ path: "build-cs"
+
- name: "Install PHP"
uses: "shivammathur/setup-php@v2"
with:
coverage: "none"
- php-version: "8.0"
+ php-version: "8.2"
- name: "Validate Composer"
run: "composer validate"
@@ -66,6 +73,10 @@ jobs:
- name: "Install dependencies"
run: "composer install --no-interaction --no-progress"
+ - name: "Install build-cs dependencies"
+ working-directory: "build-cs"
+ run: "composer install --no-interaction --no-progress"
+
- name: "Lint"
run: "make lint"
@@ -85,6 +96,7 @@ jobs:
- "7.4"
- "8.0"
- "8.1"
+ - "8.2"
dependencies:
- "lowest"
- "highest"
@@ -127,6 +139,7 @@ jobs:
- "7.4"
- "8.0"
- "8.1"
+ - "8.2"
dependencies:
- "lowest"
- "highest"
diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml
new file mode 100644
index 00000000..8452d986
--- /dev/null
+++ b/.github/workflows/create-tag.yml
@@ -0,0 +1,53 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+name: "Create tag"
+
+on:
+ # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Next version'
+ required: true
+ default: 'patch'
+ type: choice
+ options:
+ - patch
+ - minor
+
+jobs:
+ create-tag:
+ name: "Create tag"
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.PHPSTAN_BOT_TOKEN }}
+
+ - name: 'Get Previous tag'
+ id: previoustag
+ uses: "WyriHaximus/github-action-get-previous-tag@v1"
+ env:
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+
+ - name: 'Get next versions'
+ id: semvers
+ uses: "WyriHaximus/github-action-next-semvers@v1"
+ with:
+ version: ${{ steps.previoustag.outputs.tag }}
+
+ - name: "Create new minor tag"
+ uses: rickstaa/action-create-tag@v1
+ if: inputs.version == 'minor'
+ with:
+ tag: ${{ steps.semvers.outputs.minor }}
+ message: ${{ steps.semvers.outputs.minor }}
+
+ - name: "Create new patch tag"
+ uses: rickstaa/action-create-tag@v1
+ if: inputs.version == 'patch'
+ with:
+ tag: ${{ steps.semvers.outputs.patch }}
+ message: ${{ steps.semvers.outputs.patch }}
diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml
index a05d4173..4c7990df 100644
--- a/.github/workflows/lock-closed-issues.yml
+++ b/.github/workflows/lock-closed-issues.yml
@@ -8,7 +8,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v3
+ - uses: dessant/lock-threads@v4
with:
github-token: ${{ github.token }}
issue-inactive-days: '31'
diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml
new file mode 100644
index 00000000..6a1c8156
--- /dev/null
+++ b/.github/workflows/release-toot.yml
@@ -0,0 +1,21 @@
+name: Toot release
+
+# More triggers
+# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release
+on:
+ release:
+ types: [published]
+
+jobs:
+ toot:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: cbrgm/mastodon-github-action@v1
+ if: ${{ !github.event.repository.private }}
+ with:
+ # GitHub event payload
+ # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release
+ message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan"
+ env:
+ MASTODON_URL: https://phpc.social
+ MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5fed0458..92b72547 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,7 @@ jobs:
- name: Generate changelog
id: changelog
- uses: metcalfc/changelog-generator@v3.0.0
+ uses: metcalfc/changelog-generator@v4.1.0
with:
myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 2db21315..7de9f3c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/tests/tmp
+/build-cs
/vendor
/composer.lock
.phpunit.result.cache
diff --git a/Makefile b/Makefile
index fe917d3b..ecd8cfb2 100644
--- a/Makefile
+++ b/Makefile
@@ -10,14 +10,24 @@ lint:
php vendor/bin/parallel-lint --colors \
src tests
+.PHONY: cs-install
+cs-install:
+ git clone https://github.com/phpstan/build-cs.git || true
+ git -C build-cs fetch origin && git -C build-cs reset --hard origin/main
+ composer install --working-dir build-cs
+
.PHONY: cs
cs:
- composer install --working-dir build-cs && php build-cs/vendor/bin/phpcs
+ php build-cs/vendor/bin/phpcs --standard=build-cs/phpcs.xml src tests
.PHONY: cs-fix
cs-fix:
- php build-cs/vendor/bin/phpcbf
+ php build-cs/vendor/bin/phpcbf --standard=build-cs/phpcs.xml src tests
.PHONY: phpstan
phpstan:
php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests
+
+.PHONY: phpstan-generate-baseline
+phpstan-generate-baseline:
+ php vendor/bin/phpstan analyse -l 8 -c phpstan.neon src tests -b phpstan-baseline.neon
diff --git a/build-cs/.gitignore b/build-cs/.gitignore
deleted file mode 100644
index 61ead866..00000000
--- a/build-cs/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/vendor
diff --git a/build-cs/composer.json b/build-cs/composer.json
deleted file mode 100644
index e3079710..00000000
--- a/build-cs/composer.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "require-dev": {
- "consistence-community/coding-standard": "^3.10",
- "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
- "slevomat/coding-standard": "^7.0"
- },
- "config": {
- "allow-plugins": {
- "dealerdirect/phpcodesniffer-composer-installer": true
- }
- }
-}
diff --git a/build-cs/composer.lock b/build-cs/composer.lock
deleted file mode 100644
index 5a10fffd..00000000
--- a/build-cs/composer.lock
+++ /dev/null
@@ -1,322 +0,0 @@
-{
- "_readme": [
- "This file locks the dependencies of your project to a known state",
- "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
- "This file is @generated automatically"
- ],
- "content-hash": "4485bbedba7bcc71ace5f69dbb9b6c47",
- "packages": [],
- "packages-dev": [
- {
- "name": "consistence-community/coding-standard",
- "version": "3.11.1",
- "source": {
- "type": "git",
- "url": "https://github.com/consistence-community/coding-standard.git",
- "reference": "4632fead8c9ee8f50044fcbce9f66c797b34c0df"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/consistence-community/coding-standard/zipball/4632fead8c9ee8f50044fcbce9f66c797b34c0df",
- "reference": "4632fead8c9ee8f50044fcbce9f66c797b34c0df",
- "shasum": ""
- },
- "require": {
- "php": ">=7.4",
- "slevomat/coding-standard": "~7.0",
- "squizlabs/php_codesniffer": "~3.6.0"
- },
- "replace": {
- "consistence/coding-standard": "3.10.*"
- },
- "require-dev": {
- "phing/phing": "2.16.4",
- "php-parallel-lint/php-parallel-lint": "1.3.0",
- "phpunit/phpunit": "9.5.4"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Consistence\\": [
- "Consistence"
- ]
- },
- "classmap": [
- "Consistence"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "VaĊĦek Purchart",
- "email": "me@vasekpurchart.cz",
- "homepage": "http://vasekpurchart.cz/"
- }
- ],
- "description": "Consistence - Coding Standard - PHP Code Sniffer rules",
- "keywords": [
- "Coding Standard",
- "PHPCodeSniffer",
- "codesniffer",
- "coding",
- "cs",
- "phpcs",
- "ruleset",
- "sniffer",
- "standard"
- ],
- "support": {
- "issues": "https://github.com/consistence-community/coding-standard/issues",
- "source": "https://github.com/consistence-community/coding-standard/tree/3.11.1"
- },
- "time": "2021-05-03T18:13:22+00:00"
- },
- {
- "name": "dealerdirect/phpcodesniffer-composer-installer",
- "version": "v0.7.2",
- "source": {
- "type": "git",
- "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git",
- "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db",
- "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db",
- "shasum": ""
- },
- "require": {
- "composer-plugin-api": "^1.0 || ^2.0",
- "php": ">=5.3",
- "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0"
- },
- "require-dev": {
- "composer/composer": "*",
- "php-parallel-lint/php-parallel-lint": "^1.3.1",
- "phpcompatibility/php-compatibility": "^9.0"
- },
- "type": "composer-plugin",
- "extra": {
- "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
- },
- "autoload": {
- "psr-4": {
- "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Franck Nijhof",
- "email": "franck.nijhof@dealerdirect.com",
- "homepage": "http://www.frenck.nl/",
- "role": "Developer / IT Manager"
- },
- {
- "name": "Contributors",
- "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors"
- }
- ],
- "description": "PHP_CodeSniffer Standards Composer Installer Plugin",
- "homepage": "http://www.dealerdirect.com/",
- "keywords": [
- "PHPCodeSniffer",
- "PHP_CodeSniffer",
- "code quality",
- "codesniffer",
- "composer",
- "installer",
- "phpcbf",
- "phpcs",
- "plugin",
- "qa",
- "quality",
- "standard",
- "standards",
- "style guide",
- "stylecheck",
- "tests"
- ],
- "support": {
- "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues",
- "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer"
- },
- "time": "2022-02-04T12:51:07+00:00"
- },
- {
- "name": "phpstan/phpdoc-parser",
- "version": "1.4.2",
- "source": {
- "type": "git",
- "url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d",
- "reference": "4cb3021a4e10ffe3d5f94a4c34cf4b3f6de2fa3d",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "require-dev": {
- "php-parallel-lint/php-parallel-lint": "^1.2",
- "phpstan/extension-installer": "^1.0",
- "phpstan/phpstan": "^1.5",
- "phpstan/phpstan-strict-rules": "^1.0",
- "phpunit/phpunit": "^9.5",
- "symfony/process": "^5.2"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "PHPStan\\PhpDocParser\\": [
- "src/"
- ]
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "PHPDoc parser with support for nullable, intersection and generic types",
- "support": {
- "issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/1.4.2"
- },
- "time": "2022-03-30T13:33:37+00:00"
- },
- {
- "name": "slevomat/coding-standard",
- "version": "7.1",
- "source": {
- "type": "git",
- "url": "https://github.com/slevomat/coding-standard.git",
- "reference": "b521bd358b5f7a7d69e9637fd139e036d8adeb6f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/b521bd358b5f7a7d69e9637fd139e036d8adeb6f",
- "reference": "b521bd358b5f7a7d69e9637fd139e036d8adeb6f",
- "shasum": ""
- },
- "require": {
- "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7",
- "php": "^7.2 || ^8.0",
- "phpstan/phpdoc-parser": "^1.4.1",
- "squizlabs/php_codesniffer": "^3.6.2"
- },
- "require-dev": {
- "phing/phing": "2.17.2",
- "php-parallel-lint/php-parallel-lint": "1.3.2",
- "phpstan/phpstan": "1.4.10|1.5.2",
- "phpstan/phpstan-deprecation-rules": "1.0.0",
- "phpstan/phpstan-phpunit": "1.0.0|1.1.0",
- "phpstan/phpstan-strict-rules": "1.1.0",
- "phpunit/phpunit": "7.5.20|8.5.21|9.5.19"
- },
- "type": "phpcodesniffer-standard",
- "extra": {
- "branch-alias": {
- "dev-master": "7.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "SlevomatCodingStandard\\": "SlevomatCodingStandard"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.",
- "support": {
- "issues": "https://github.com/slevomat/coding-standard/issues",
- "source": "https://github.com/slevomat/coding-standard/tree/7.1"
- },
- "funding": [
- {
- "url": "https://github.com/kukulich",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard",
- "type": "tidelift"
- }
- ],
- "time": "2022-03-29T12:44:16+00:00"
- },
- {
- "name": "squizlabs/php_codesniffer",
- "version": "3.6.2",
- "source": {
- "type": "git",
- "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
- "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/5e4e71592f69da17871dba6e80dd51bce74a351a",
- "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a",
- "shasum": ""
- },
- "require": {
- "ext-simplexml": "*",
- "ext-tokenizer": "*",
- "ext-xmlwriter": "*",
- "php": ">=5.4.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
- },
- "bin": [
- "bin/phpcs",
- "bin/phpcbf"
- ],
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Greg Sherwood",
- "role": "lead"
- }
- ],
- "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
- "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
- "keywords": [
- "phpcs",
- "standards"
- ],
- "support": {
- "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
- "source": "https://github.com/squizlabs/PHP_CodeSniffer",
- "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
- },
- "time": "2021-12-12T21:44:58+00:00"
- }
- ],
- "aliases": [],
- "minimum-stability": "stable",
- "stability-flags": [],
- "prefer-stable": false,
- "prefer-lowest": false,
- "platform": [],
- "platform-dev": [],
- "plugin-api-version": "2.3.0"
-}
diff --git a/composer.json b/composer.json
index 2f67ed5a..ea9f5cdf 100644
--- a/composer.json
+++ b/composer.json
@@ -7,7 +7,7 @@
],
"require": {
"php": "^7.2 || ^8.0",
- "phpstan/phpstan": "^1.5.0"
+ "phpstan/phpstan": "^1.10"
},
"conflict": {
"phpunit/phpunit": "<7.0"
@@ -15,7 +15,7 @@
"require-dev": {
"nikic/php-parser": "^4.13.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
- "phpstan/phpstan-strict-rules": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.5.1",
"phpunit/phpunit": "^9.5"
},
"config": {
diff --git a/extension.neon b/extension.neon
index 93d3a9ed..cea2b155 100644
--- a/extension.neon
+++ b/extension.neon
@@ -51,6 +51,15 @@ services:
class: PHPStan\Type\PHPUnit\MockObjectDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
+ -
+ class: PHPStan\Rules\PHPUnit\CoversHelper
+ -
+ class: PHPStan\Rules\PHPUnit\AnnotationHelper
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderHelper
+ factory: @PHPStan\Rules\PHPUnit\DataProviderHelperFactory::create()
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderHelperFactory
conditionalTags:
PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension:
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index 95032a6e..00000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,111 +0,0 @@
-
-
-
-
-
-
-
-
-
- src
- tests
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 10
-
-
-
-
-
- 10
-
-
-
-
-
-
-
- 10
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- tests/*/data
-
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 00000000..53fb96b8
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,16 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
+
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeStaticMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php
+
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
diff --git a/phpstan.neon b/phpstan.neon
index d71fc5ab..2b8fa1ae 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -3,24 +3,8 @@ includes:
- rules.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- phar://phpstan.phar/conf/bleedingEdge.neon
+ - phpstan-baseline.neon
parameters:
excludePaths:
- tests/*/data/*
-
-services:
- scopeIsInClass:
- class: PHPStan\Internal\ScopeIsInClassTypeSpecifyingExtension
- arguments:
- isInMethodName: isInClass
- removeNullMethodName: getClassReflection
- tags:
- - phpstan.typeSpecifier.methodTypeSpecifyingExtension
-
- scopeIsInTrait:
- class: PHPStan\Internal\ScopeIsInClassTypeSpecifyingExtension
- arguments:
- isInMethodName: isInTrait
- removeNullMethodName: getTraitReflection
- tags:
- - phpstan.typeSpecifier.methodTypeSpecifyingExtension
diff --git a/rules.neon b/rules.neon
index 5be69279..8dc7056b 100644
--- a/rules.neon
+++ b/rules.neon
@@ -4,3 +4,26 @@ rules:
- PHPStan\Rules\PHPUnit\AssertSameWithCountRule
- PHPStan\Rules\PHPUnit\MockMethodCallRule
- PHPStan\Rules\PHPUnit\ShouldCallParentMethodsRule
+
+services:
+ - class: PHPStan\Rules\PHPUnit\ClassCoversExistsRule
+ - class: PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule
+ arguments:
+ checkFunctionNameCase: %checkFunctionNameCase%
+ deprecationRulesInstalled: %deprecationRulesInstalled%
+ - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule
+ - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule
+
+conditionalTags:
+ PHPStan\Rules\PHPUnit\ClassCoversExistsRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
+ PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
+ PHPStan\Rules\PHPUnit\DataProviderDeclarationRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
+ PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
+ PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
diff --git a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php
index 4d463799..2d70b380 100644
--- a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php
+++ b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php
@@ -11,8 +11,8 @@
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
-use PHPStan\Type\TypeWithClassName;
use function array_key_exists;
+use function count;
class MockObjectTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
{
@@ -44,11 +44,12 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
$types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope);
foreach ($types as $type) {
- if (!$type instanceof TypeWithClassName) {
+ $classNames = $type->getObjectClassNames();
+ if (count($classNames) !== 1) {
continue;
}
- if (array_key_exists($type->getClassName(), $mockClassNames)) {
+ if (array_key_exists($classNames[0], $mockClassNames)) {
$resultType = TypeCombinator::intersect(...$types);
if ($resultType instanceof NeverType) {
continue;
diff --git a/src/Rules/PHPUnit/AnnotationHelper.php b/src/Rules/PHPUnit/AnnotationHelper.php
new file mode 100644
index 00000000..f5529a8f
--- /dev/null
+++ b/src/Rules/PHPUnit/AnnotationHelper.php
@@ -0,0 +1,66 @@
+getText());
+ if ($docCommentLines === false) {
+ return [];
+ }
+
+ foreach ($docCommentLines as $docCommentLine) {
+ // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid
+ $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches);
+ if ($annotation === false) {
+ continue; // Line without annotation
+ }
+
+ if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) {
+ continue;
+ }
+
+ if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') {
+ continue;
+ }
+
+ $errors[] = RuleErrorBuilder::message(
+ 'Annotation "' . $matches['annotation'] . '" is invalid, "@' . $matches['property'] . '" should be followed by a space and a value.'
+ )->build();
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertRuleHelper.php b/src/Rules/PHPUnit/AssertRuleHelper.php
index 96735379..3ad79c00 100644
--- a/src/Rules/PHPUnit/AssertRuleHelper.php
+++ b/src/Rules/PHPUnit/AssertRuleHelper.php
@@ -11,9 +11,11 @@
class AssertRuleHelper
{
+ /**
+ * @phpstan-assert-if-true Node\Expr\MethodCall|Node\Expr\StaticCall $node
+ */
public static function isMethodOrStaticCallOnAssert(Node $node, Scope $scope): bool
{
- $testCaseType = new ObjectType('PHPUnit\Framework\Assert');
if ($node instanceof Node\Expr\MethodCall) {
$calledOnType = $scope->getType($node->var);
} elseif ($node instanceof Node\Expr\StaticCall) {
@@ -42,11 +44,9 @@ public static function isMethodOrStaticCallOnAssert(Node $node, Scope $scope): b
return false;
}
- if (!$testCaseType->isSuperTypeOf($calledOnType)->yes()) {
- return false;
- }
+ $testCaseType = new ObjectType('PHPUnit\Framework\Assert');
- return true;
+ return $testCaseType->isSuperTypeOf($calledOnType)->yes();
}
}
diff --git a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php
index d24d4904..b2860676 100644
--- a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php
+++ b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php
@@ -4,13 +4,11 @@
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
-use PhpParser\Node\Expr\MethodCall;
-use PhpParser\Node\Expr\StaticCall;
use PhpParser\NodeAbstract;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
use function count;
-use function strtolower;
/**
* @implements Rule
@@ -29,13 +27,10 @@ public function processNode(Node $node, Scope $scope): array
return [];
}
- /** @var MethodCall|StaticCall $node */
- $node = $node;
-
if (count($node->getArgs()) < 2) {
return [];
}
- if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') {
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
return [];
}
@@ -46,13 +41,13 @@ public function processNode(Node $node, Scope $scope): array
if ($expectedArgumentValue->name->toLowerString() === 'true') {
return [
- 'You should use assertTrue() instead of assertSame() when expecting "true"',
+ RuleErrorBuilder::message('You should use assertTrue() instead of assertSame() when expecting "true"')->build(),
];
}
if ($expectedArgumentValue->name->toLowerString() === 'false') {
return [
- 'You should use assertFalse() instead of assertSame() when expecting "false"',
+ RuleErrorBuilder::message('You should use assertFalse() instead of assertSame() when expecting "false"')->build(),
];
}
diff --git a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php
index 672f3496..cf6fc76e 100644
--- a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php
+++ b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php
@@ -4,13 +4,11 @@
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
-use PhpParser\Node\Expr\MethodCall;
-use PhpParser\Node\Expr\StaticCall;
use PhpParser\NodeAbstract;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
use function count;
-use function strtolower;
/**
* @implements Rule
@@ -29,13 +27,10 @@ public function processNode(Node $node, Scope $scope): array
return [];
}
- /** @var MethodCall|StaticCall $node */
- $node = $node;
-
if (count($node->getArgs()) < 2) {
return [];
}
- if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') {
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
return [];
}
@@ -46,7 +41,7 @@ public function processNode(Node $node, Scope $scope): array
if ($expectedArgumentValue->name->toLowerString() === 'null') {
return [
- 'You should use assertNull() instead of assertSame(null, $actual).',
+ RuleErrorBuilder::message('You should use assertNull() instead of assertSame(null, $actual).')->build(),
];
}
diff --git a/src/Rules/PHPUnit/AssertSameWithCountRule.php b/src/Rules/PHPUnit/AssertSameWithCountRule.php
index 1f1a3ab2..209614ae 100644
--- a/src/Rules/PHPUnit/AssertSameWithCountRule.php
+++ b/src/Rules/PHPUnit/AssertSameWithCountRule.php
@@ -4,14 +4,12 @@
use Countable;
use PhpParser\Node;
-use PhpParser\Node\Expr\MethodCall;
-use PhpParser\Node\Expr\StaticCall;
use PhpParser\NodeAbstract;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use function count;
-use function strtolower;
/**
* @implements Rule
@@ -30,13 +28,10 @@ public function processNode(Node $node, Scope $scope): array
return [];
}
- /** @var MethodCall|StaticCall $node */
- $node = $node;
-
if (count($node->getArgs()) < 2) {
return [];
}
- if (!$node->name instanceof Node\Identifier || strtolower($node->name->name) !== 'assertsame') {
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
return [];
}
@@ -45,24 +40,24 @@ public function processNode(Node $node, Scope $scope): array
if (
$right instanceof Node\Expr\FuncCall
&& $right->name instanceof Node\Name
- && strtolower($right->name->toString()) === 'count'
+ && $right->name->toLowerString() === 'count'
) {
return [
- 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).',
+ RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).')->build(),
];
}
if (
$right instanceof Node\Expr\MethodCall
&& $right->name instanceof Node\Identifier
- && strtolower($right->name->toString()) === 'count'
+ && $right->name->toLowerString() === 'count'
&& count($right->getArgs()) === 0
) {
$type = $scope->getType($right->var);
if ((new ObjectType(Countable::class))->isSuperTypeOf($type)->yes()) {
return [
- 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).',
+ RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).')->build(),
];
}
}
diff --git a/src/Rules/PHPUnit/ClassCoversExistsRule.php b/src/Rules/PHPUnit/ClassCoversExistsRule.php
new file mode 100644
index 00000000..a5a0cb70
--- /dev/null
+++ b/src/Rules/PHPUnit/ClassCoversExistsRule.php
@@ -0,0 +1,93 @@
+
+ */
+class ClassCoversExistsRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ * @var CoversHelper
+ */
+ private $coversHelper;
+
+ /**
+ * Reflection provider.
+ *
+ * @var ReflectionProvider
+ */
+ private $reflectionProvider;
+
+ public function __construct(
+ CoversHelper $coversHelper,
+ ReflectionProvider $reflectionProvider
+ )
+ {
+ $this->reflectionProvider = $reflectionProvider;
+ $this->coversHelper = $coversHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $node->getClassReflection();
+
+ if (!$classReflection->isSubclassOf(TestCase::class)) {
+ return [];
+ }
+
+ $errors = [];
+ $classPhpDoc = $classReflection->getResolvedPhpDoc();
+ [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
+
+ if (count($classCoversDefaultClasses) >= 2) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass is defined multiple times.'
+ ))->build();
+
+ return $errors;
+ }
+
+ $coversDefaultClass = array_shift($classCoversDefaultClasses);
+
+ if ($coversDefaultClass !== null) {
+ $className = (string) $coversDefaultClass->value;
+ if (!$this->reflectionProvider->hasClass($className)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass references an invalid class %s.',
+ $className
+ ))->build();
+ }
+ }
+
+ foreach ($classCovers as $covers) {
+ $errors = array_merge(
+ $errors,
+ $this->coversHelper->processCovers($node, $covers, null)
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php
new file mode 100644
index 00000000..69693dee
--- /dev/null
+++ b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php
@@ -0,0 +1,117 @@
+
+ */
+class ClassMethodCoversExistsRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ * @var CoversHelper
+ */
+ private $coversHelper;
+
+ /**
+ * The file type mapper.
+ *
+ * @var FileTypeMapper
+ */
+ private $fileTypeMapper;
+
+ public function __construct(
+ CoversHelper $coversHelper,
+ FileTypeMapper $fileTypeMapper
+ )
+ {
+ $this->coversHelper = $coversHelper;
+ $this->fileTypeMapper = $fileTypeMapper;
+ }
+
+ public function getNodeType(): string
+ {
+ return Node\Stmt\ClassMethod::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+
+ if ($classReflection === null) {
+ return [];
+ }
+
+ if (!$classReflection->isSubclassOf(TestCase::class)) {
+ return [];
+ }
+
+ $classPhpDoc = $classReflection->getResolvedPhpDoc();
+ [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
+
+ $classCoversStrings = array_map(static function (PhpDocTagNode $covers): string {
+ return (string) $covers->value;
+ }, $classCovers);
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ $coversDefaultClass = count($classCoversDefaultClasses) === 1
+ ? array_shift($classCoversDefaultClasses)
+ : null;
+
+ $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ $classReflection->getName(),
+ $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
+ $node->name->toString(),
+ $docComment->getText()
+ );
+
+ [$methodCovers, $methodCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($methodPhpDoc);
+
+ $errors = [];
+
+ if (count($methodCoversDefaultClasses) > 0) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass defined on class method %s.',
+ $node->name
+ ))->build();
+ }
+
+ foreach ($methodCovers as $covers) {
+ if (in_array((string) $covers->value, $classCoversStrings, true)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ 'Class already @covers %s so the method @covers is redundant.',
+ $covers->value
+ ))->build();
+ }
+
+ $errors = array_merge(
+ $errors,
+ $this->coversHelper->processCovers($node, $covers, $coversDefaultClass)
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/CoversHelper.php b/src/Rules/PHPUnit/CoversHelper.php
new file mode 100644
index 00000000..f17a2063
--- /dev/null
+++ b/src/Rules/PHPUnit/CoversHelper.php
@@ -0,0 +1,131 @@
+reflectionProvider = $reflectionProvider;
+ }
+
+ /**
+ * Gathers @covers and @coversDefaultClass annotations from phpdocs.
+ *
+ * @return array{PhpDocTagNode[], PhpDocTagNode[]}
+ */
+ public function getCoverAnnotations(?ResolvedPhpDocBlock $phpDoc): array
+ {
+ if ($phpDoc === null) {
+ return [[], []];
+ }
+
+ $phpDocNodes = $phpDoc->getPhpDocNodes();
+
+ $covers = [];
+ $coversDefaultClasses = [];
+
+ foreach ($phpDocNodes as $docNode) {
+ $covers = array_merge(
+ $covers,
+ $docNode->getTagsByName('@covers')
+ );
+
+ $coversDefaultClasses = array_merge(
+ $coversDefaultClasses,
+ $docNode->getTagsByName('@coversDefaultClass')
+ );
+ }
+
+ return [$covers, $coversDefaultClasses];
+ }
+
+ /**
+ * @return RuleError[] errors
+ */
+ public function processCovers(
+ Node $node,
+ PhpDocTagNode $phpDocTag,
+ ?PhpDocTagNode $coversDefaultClass
+ ): array
+ {
+ $errors = [];
+ $covers = (string) $phpDocTag->value;
+
+ if ($covers === '') {
+ $errors[] = RuleErrorBuilder::message('@covers value does not specify anything.')->build();
+
+ return $errors;
+ }
+
+ $isMethod = strpos($covers, '::') !== false;
+ $fullName = $covers;
+
+ if ($isMethod) {
+ [$className, $method] = explode('::', $covers);
+ } else {
+ $className = $covers;
+ }
+
+ if ($className === '' && $node instanceof Node\Stmt\ClassMethod && $coversDefaultClass !== null) {
+ $className = (string) $coversDefaultClass->value;
+ $fullName = $className . $covers;
+ }
+
+ if ($this->reflectionProvider->hasClass($className)) {
+ $class = $this->reflectionProvider->getClass($className);
+
+ if ($class->isInterface()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an interface.',
+ $fullName
+ ))->build();
+ }
+
+ if (isset($method) && $method !== '' && !$class->hasMethod($method)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an invalid method.',
+ $fullName
+ ))->build();
+ }
+ } elseif (isset($method) && $this->reflectionProvider->hasFunction(new Name($method, []), null)) {
+ return $errors;
+ } elseif (!isset($method) && $this->reflectionProvider->hasFunction(new Name($className, []), null)) {
+ return $errors;
+ } else {
+ $error = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an invalid %s.',
+ $fullName,
+ $isMethod ? 'method' : 'class or function'
+ ));
+
+ if (strpos($className, '\\') === false) {
+ $error->tip('The @covers annotation requires a fully qualified name.');
+ }
+
+ $errors[] = $error->build();
+ }
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderDeclarationRule.php b/src/Rules/PHPUnit/DataProviderDeclarationRule.php
new file mode 100644
index 00000000..37c586de
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderDeclarationRule.php
@@ -0,0 +1,81 @@
+
+ */
+class DataProviderDeclarationRule implements Rule
+{
+
+ /**
+ * Data provider helper.
+ *
+ * @var DataProviderHelper
+ */
+ private $dataProviderHelper;
+
+ /**
+ * When set to true, it reports data provider method with incorrect name case.
+ *
+ * @var bool
+ */
+ private $checkFunctionNameCase;
+
+ /**
+ * When phpstan-deprecation-rules is installed, it reports deprecated usages.
+ *
+ * @var bool
+ */
+ private $deprecationRulesInstalled;
+
+ public function __construct(
+ DataProviderHelper $dataProviderHelper,
+ bool $checkFunctionNameCase,
+ bool $deprecationRulesInstalled
+ )
+ {
+ $this->dataProviderHelper = $dataProviderHelper;
+ $this->checkFunctionNameCase = $checkFunctionNameCase;
+ $this->deprecationRulesInstalled = $deprecationRulesInstalled;
+ }
+
+ public function getNodeType(): string
+ {
+ return Node\Stmt\ClassMethod::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+
+ if ($classReflection === null || !$classReflection->isSubclassOf(TestCase::class)) {
+ return [];
+ }
+
+ $errors = [];
+
+ foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $node, $classReflection) as $dataProviderValue => [$dataProviderClassReflection, $dataProviderMethodName, $lineNumber]) {
+ $errors = array_merge(
+ $errors,
+ $this->dataProviderHelper->processDataProvider(
+ $dataProviderValue,
+ $dataProviderClassReflection,
+ $dataProviderMethodName,
+ $lineNumber,
+ $this->checkFunctionNameCase,
+ $this->deprecationRulesInstalled
+ )
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php
new file mode 100644
index 00000000..6651b051
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderHelper.php
@@ -0,0 +1,275 @@
+reflectionProvider = $reflectionProvider;
+ $this->fileTypeMapper = $fileTypeMapper;
+ $this->phpunit10OrNewer = $phpunit10OrNewer;
+ }
+
+ /**
+ * @return iterable
+ */
+ public function getDataProviderMethods(
+ Scope $scope,
+ ClassMethod $node,
+ ClassReflection $classReflection
+ ): iterable
+ {
+ $docComment = $node->getDocComment();
+ if ($docComment !== null) {
+ $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ $classReflection->getName(),
+ $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
+ $node->name->toString(),
+ $docComment->getText()
+ );
+ foreach ($this->getDataProviderAnnotations($methodPhpDoc) as $annotation) {
+ $dataProviderValue = $this->getDataProviderAnnotationValue($annotation);
+ if ($dataProviderValue === null) {
+ // Missing value is already handled in NoMissingSpaceInMethodAnnotationRule
+ continue;
+ }
+
+ $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue);
+ $dataProviderMethod[] = $node->getLine();
+
+ yield $dataProviderValue => $dataProviderMethod;
+ }
+ }
+
+ if (!$this->phpunit10OrNewer) {
+ return;
+ }
+
+ foreach ($node->attrGroups as $attrGroup) {
+ foreach ($attrGroup->attrs as $attr) {
+ $dataProviderMethod = null;
+ if ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataprovider') {
+ $dataProviderMethod = $this->parseDataProviderAttribute($attr, $classReflection);
+ } elseif ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataproviderexternal') {
+ $dataProviderMethod = $this->parseDataProviderExternalAttribute($attr);
+ }
+ if ($dataProviderMethod === null) {
+ continue;
+ }
+
+ yield from $dataProviderMethod;
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getDataProviderAnnotations(?ResolvedPhpDocBlock $phpDoc): array
+ {
+ if ($phpDoc === null) {
+ return [];
+ }
+
+ $phpDocNodes = $phpDoc->getPhpDocNodes();
+
+ $annotations = [];
+
+ foreach ($phpDocNodes as $docNode) {
+ $annotations = array_merge(
+ $annotations,
+ $docNode->getTagsByName('@dataProvider')
+ );
+ }
+
+ return $annotations;
+ }
+
+ /**
+ * @return RuleError[] errors
+ */
+ public function processDataProvider(
+ string $dataProviderValue,
+ ?ClassReflection $classReflection,
+ string $methodName,
+ int $lineNumber,
+ bool $checkFunctionNameCase,
+ bool $deprecationRulesInstalled
+ ): array
+ {
+ if ($classReflection === null) {
+ $error = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related class not found.',
+ $dataProviderValue
+ ))->line($lineNumber)->build();
+
+ return [$error];
+ }
+
+ try {
+ $dataProviderMethodReflection = $classReflection->getNativeMethod($methodName);
+ } catch (MissingMethodFromReflectionException $missingMethodFromReflectionException) {
+ $error = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method not found.',
+ $dataProviderValue
+ ))->line($lineNumber)->build();
+
+ return [$error];
+ }
+
+ $errors = [];
+
+ if ($checkFunctionNameCase && $methodName !== $dataProviderMethodReflection->getName()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method is used with incorrect case: %s.',
+ $dataProviderValue,
+ $dataProviderMethodReflection->getName()
+ ))->line($lineNumber)->build();
+ }
+
+ if (!$dataProviderMethodReflection->isPublic()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method must be public.',
+ $dataProviderValue
+ ))->line($lineNumber)->build();
+ }
+
+ if ($deprecationRulesInstalled && $this->phpunit10OrNewer && !$dataProviderMethodReflection->isStatic()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method must be static in PHPUnit 10 and newer.',
+ $dataProviderValue
+ ))->line($lineNumber)->build();
+ }
+
+ return $errors;
+ }
+
+ private function getDataProviderAnnotationValue(PhpDocTagNode $phpDocTag): ?string
+ {
+ if (preg_match('/^[^ \t]+/', (string) $phpDocTag->value, $matches) !== 1) {
+ return null;
+ }
+
+ return $matches[0];
+ }
+
+ /**
+ * @return array{ClassReflection|null, string}
+ */
+ private function parseDataProviderAnnotationValue(Scope $scope, string $dataProviderValue): array
+ {
+ $parts = explode('::', $dataProviderValue, 2);
+ if (count($parts) <= 1) {
+ return [$scope->getClassReflection(), $dataProviderValue];
+ }
+
+ if ($this->reflectionProvider->hasClass($parts[0])) {
+ return [$this->reflectionProvider->getClass($parts[0]), $parts[1]];
+ }
+
+ return [null, $dataProviderValue];
+ }
+
+ /**
+ * @return array|null
+ */
+ private function parseDataProviderExternalAttribute(Attribute $attribute): ?array
+ {
+ if (count($attribute->args) !== 2) {
+ return null;
+ }
+ $methodNameArg = $attribute->args[1]->value;
+ if (!$methodNameArg instanceof String_) {
+ return null;
+ }
+ $classNameArg = $attribute->args[0]->value;
+ if ($classNameArg instanceof ClassConstFetch && $classNameArg->class instanceof Name) {
+ $className = $classNameArg->class->toString();
+ } elseif ($classNameArg instanceof String_) {
+ $className = $classNameArg->value;
+ } else {
+ return null;
+ }
+
+ $dataProviderClassReflection = null;
+ if ($this->reflectionProvider->hasClass($className)) {
+ $dataProviderClassReflection = $this->reflectionProvider->getClass($className);
+ $className = $dataProviderClassReflection->getName();
+ }
+
+ return [
+ sprintf('%s::%s', $className, $methodNameArg->value) => [
+ $dataProviderClassReflection,
+ $methodNameArg->value,
+ $attribute->getLine(),
+ ],
+ ];
+ }
+
+ /**
+ * @return array|null
+ */
+ private function parseDataProviderAttribute(Attribute $attribute, ClassReflection $classReflection): ?array
+ {
+ if (count($attribute->args) !== 1) {
+ return null;
+ }
+ $methodNameArg = $attribute->args[0]->value;
+ if (!$methodNameArg instanceof String_) {
+ return null;
+ }
+
+ return [
+ $methodNameArg->value => [
+ $classReflection,
+ $methodNameArg->value,
+ $attribute->getLine(),
+ ],
+ ];
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderHelperFactory.php b/src/Rules/PHPUnit/DataProviderHelperFactory.php
new file mode 100644
index 00000000..7fc8af0f
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderHelperFactory.php
@@ -0,0 +1,57 @@
+reflectionProvider = $reflectionProvider;
+ $this->fileTypeMapper = $fileTypeMapper;
+ }
+
+ public function create(): DataProviderHelper
+ {
+ $phpUnit10OrNewer = false;
+ if ($this->reflectionProvider->hasClass(TestCase::class)) {
+ $testCase = $this->reflectionProvider->getClass(TestCase::class);
+ $file = $testCase->getFileName();
+ if ($file !== null) {
+ $phpUnitRoot = dirname($file, 3);
+ $phpUnitComposer = $phpUnitRoot . '/composer.json';
+ if (is_file($phpUnitComposer)) {
+ $composerJson = @file_get_contents($phpUnitComposer);
+ if ($composerJson !== false) {
+ $json = json_decode($composerJson, true);
+ $version = $json['extra']['branch-alias']['dev-main'] ?? null;
+ if ($version !== null) {
+ $majorVersion = (int) explode('.', $version)[0];
+ if ($majorVersion >= 10) {
+ $phpUnit10OrNewer = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return new DataProviderHelper($this->reflectionProvider, $this->fileTypeMapper, $phpUnit10OrNewer);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/MockMethodCallRule.php b/src/Rules/PHPUnit/MockMethodCallRule.php
index ae554e1f..90dc0945 100644
--- a/src/Rules/PHPUnit/MockMethodCallRule.php
+++ b/src/Rules/PHPUnit/MockMethodCallRule.php
@@ -6,10 +6,7 @@
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
-use PHPStan\Type\Constant\ConstantStringType;
-use PHPStan\Type\Generic\GenericObjectType;
-use PHPStan\Type\IntersectionType;
-use PHPStan\Type\ObjectType;
+use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;
@@ -32,9 +29,6 @@ public function getNodeType(): string
public function processNode(Node $node, Scope $scope): array
{
- /** @var Node\Expr\MethodCall $node */
- $node = $node;
-
if (!$node->name instanceof Node\Identifier || $node->name->name !== 'method') {
return [];
}
@@ -44,53 +38,55 @@ public function processNode(Node $node, Scope $scope): array
}
$argType = $scope->getType($node->getArgs()[0]->value);
- if (!($argType instanceof ConstantStringType)) {
+ if (count($argType->getConstantStrings()) === 0) {
return [];
}
- $method = $argType->getValue();
- $type = $scope->getType($node->var);
+ $errors = [];
+ foreach ($argType->getConstantStrings() as $constantString) {
+ $method = $constantString->getValue();
+ $type = $scope->getType($node->var);
- if (
- $type instanceof IntersectionType
- && (
- in_array(MockObject::class, $type->getReferencedClasses(), true)
- || in_array(Stub::class, $type->getReferencedClasses(), true)
- )
- && !$type->hasMethod($method)->yes()
- ) {
- $mockClass = array_filter($type->getReferencedClasses(), static function (string $class): bool {
- return $class !== MockObject::class && $class !== Stub::class;
- });
+ if (
+ (
+ in_array(MockObject::class, $type->getObjectClassNames(), true)
+ || in_array(Stub::class, $type->getObjectClassNames(), true)
+ )
+ && !$type->hasMethod($method)->yes()
+ ) {
+ $mockClasses = array_filter($type->getObjectClassNames(), static function (string $class): bool {
+ return $class !== MockObject::class && $class !== Stub::class;
+ });
+ if (count($mockClasses) === 0) {
+ continue;
+ }
- return [
- sprintf(
+ $errors[] = RuleErrorBuilder::message(sprintf(
'Trying to mock an undefined method %s() on class %s.',
$method,
- implode('&', $mockClass)
- ),
- ];
- }
+ implode('&', $mockClasses)
+ ))->build();
+ continue;
+ }
- if (
- $type instanceof GenericObjectType
- && $type->getClassName() === InvocationMocker::class
- && count($type->getTypes()) > 0
- ) {
- $mockClass = $type->getTypes()[0];
+ $mockedClassObject = $type->getTemplateType(InvocationMocker::class, 'TMockedClass');
+ if ($mockedClassObject->hasMethod($method)->yes()) {
+ continue;
+ }
- if ($mockClass instanceof ObjectType && !$mockClass->hasMethod($method)->yes()) {
- return [
- sprintf(
- 'Trying to mock an undefined method %s() on class %s.',
- $method,
- $mockClass->getClassName()
- ),
- ];
+ $classNames = $mockedClassObject->getObjectClassNames();
+ if (count($classNames) === 0) {
+ continue;
}
+
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ 'Trying to mock an undefined method %s() on class %s.',
+ $method,
+ implode('|', $classNames)
+ ))->build();
}
- return [];
+ return $errors;
}
}
diff --git a/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php
new file mode 100644
index 00000000..89e3e8ff
--- /dev/null
+++ b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php
@@ -0,0 +1,49 @@
+
+ */
+class NoMissingSpaceInClassAnnotationRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ * @var AnnotationHelper
+ */
+ private $annotationHelper;
+
+ public function __construct(AnnotationHelper $annotationHelper)
+ {
+ $this->annotationHelper = $annotationHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null || $classReflection->isSubclassOf(TestCase::class) === false) {
+ return [];
+ }
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ return $this->annotationHelper->processDocComment($docComment);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php
new file mode 100644
index 00000000..77577206
--- /dev/null
+++ b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php
@@ -0,0 +1,49 @@
+
+ */
+class NoMissingSpaceInMethodAnnotationRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ * @var AnnotationHelper
+ */
+ private $annotationHelper;
+
+ public function __construct(AnnotationHelper $annotationHelper)
+ {
+ $this->annotationHelper = $annotationHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassMethodNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null || $classReflection->isSubclassOf(TestCase::class) === false) {
+ return [];
+ }
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ return $this->annotationHelper->processDocComment($docComment);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php
index 01414061..5a640a1c 100644
--- a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php
+++ b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php
@@ -97,7 +97,7 @@ private function hasParentClassCall(?array $stmts, string $methodName): bool
continue;
}
- if (strtolower($stmt->expr->name->name) === $methodName) {
+ if ($stmt->expr->name->toLowerString() === $methodName) {
return true;
}
}
diff --git a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php
index 36203e65..963b4f57 100644
--- a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php
+++ b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php
@@ -3,6 +3,8 @@
namespace PHPStan\Type\PHPUnit\Assert;
use Closure;
+use Countable;
+use EmptyIterator;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\Identical;
@@ -11,11 +13,13 @@
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Instanceof_;
use PhpParser\Node\Name;
+use PhpParser\Node\Param;
+use PhpParser\Node\Scalar\LNumber;
+use PhpParser\Node\Stmt;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierContext;
-use PHPStan\Type\Constant\ConstantStringType;
use ReflectionObject;
use function array_key_exists;
use function count;
@@ -121,15 +125,10 @@ private static function getExpressionResolvers(): array
{
if (self::$resolvers === null) {
self::$resolvers = [
- 'InstanceOf' => static function (Scope $scope, Arg $class, Arg $object): ?Instanceof_ {
- $classType = $scope->getType($class->value);
- if (!$classType instanceof ConstantStringType) {
- return null;
- }
-
+ 'InstanceOf' => static function (Scope $scope, Arg $class, Arg $object): Instanceof_ {
return new Instanceof_(
$object->value,
- new Name($classType->getValue())
+ $class->value
);
},
'Same' => static function (Scope $scope, Arg $expected, Arg $actual): Identical {
@@ -156,6 +155,18 @@ private static function getExpressionResolvers(): array
new ConstFetch(new Name('null'))
);
},
+ 'Empty' => static function (Scope $scope, Arg $actual): Expr\BinaryOp\BooleanOr {
+ return new Expr\BinaryOp\BooleanOr(
+ new Instanceof_($actual->value, new Name(EmptyIterator::class)),
+ new Expr\BinaryOp\BooleanOr(
+ new Expr\BinaryOp\BooleanAnd(
+ new Instanceof_($actual->value, new Name(Countable::class)),
+ new Identical(new FuncCall(new Name('count'), [new Arg($actual->value)]), new LNumber(0))
+ ),
+ new Expr\Empty_($actual->value)
+ )
+ );
+ },
'IsArray' => static function (Scope $scope, Arg $actual): FuncCall {
return new FuncCall(new Name('is_array'), [$actual]);
},
@@ -190,12 +201,12 @@ private static function getExpressionResolvers(): array
return new FuncCall(new Name('is_scalar'), [$actual]);
},
'InternalType' => static function (Scope $scope, Arg $type, Arg $value): ?FuncCall {
- $typeType = $scope->getType($type->value);
- if (!$typeType instanceof ConstantStringType) {
+ $typeNames = $scope->getType($type->value)->getConstantStrings();
+ if (count($typeNames) !== 1) {
return null;
}
- switch ($typeType->getValue()) {
+ switch ($typeNames[0]->getValue()) {
case 'numeric':
$functionName = 'is_numeric';
break;
@@ -253,12 +264,58 @@ private static function getExpressionResolvers(): array
]
);
},
- 'ArrayHasKey' => static function (Scope $scope, Arg $key, Arg $array): FuncCall {
- return new FuncCall(new Name('array_key_exists'), [$key, $array]);
+ 'ArrayHasKey' => static function (Scope $scope, Arg $key, Arg $array): Expr {
+ return new Expr\BinaryOp\BooleanOr(
+ new Expr\BinaryOp\BooleanAnd(
+ new Expr\Instanceof_($array->value, new Name('ArrayAccess')),
+ new Expr\MethodCall($array->value, 'offsetExists', [$key])
+ ),
+ new FuncCall(new Name('array_key_exists'), [$key, $array])
+ );
},
'ObjectHasAttribute' => static function (Scope $scope, Arg $property, Arg $object): FuncCall {
return new FuncCall(new Name('property_exists'), [$object, $property]);
},
+ 'ObjectHasProperty' => static function (Scope $scope, Arg $property, Arg $object): FuncCall {
+ return new FuncCall(new Name('property_exists'), [$object, $property]);
+ },
+ 'Contains' => static function (Scope $scope, Arg $needle, Arg $haystack): Expr {
+ return new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('true')))])
+ );
+ },
+ 'ContainsEquals' => static function (Scope $scope, Arg $needle, Arg $haystack): Expr {
+ return new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new Expr\BinaryOp\BooleanAnd(
+ new Expr\BooleanNot(new Expr\Empty_($haystack->value)),
+ new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('false')))])
+ )
+ );
+ },
+ 'ContainsOnlyInstancesOf' => static function (Scope $scope, Arg $className, Arg $haystack): Expr {
+ return new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new Identical(
+ $haystack->value,
+ new FuncCall(new Name('array_filter'), [
+ $haystack,
+ new Arg(new Expr\Closure([
+ 'static' => true,
+ 'params' => [
+ new Param(new Expr\Variable('_')),
+ ],
+ 'stmts' => [
+ new Stmt\Return_(
+ new FuncCall(new Name('is_a'), [new Arg(new Expr\Variable('_')), $className])
+ ),
+ ],
+ ])),
+ ])
+ )
+ );
+ },
];
}
diff --git a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php
index cb0b3a17..4f74fe68 100644
--- a/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php
+++ b/src/Type/PHPUnit/MockObjectDynamicReturnTypeExtension.php
@@ -7,10 +7,8 @@
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
-use PHPStan\Type\IntersectionType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
-use PHPStan\Type\TypeWithClassName;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use function array_filter;
@@ -33,19 +31,15 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
{
$type = $scope->getType($methodCall->var);
- if (!($type instanceof IntersectionType)) {
- return new ObjectType(InvocationMocker::class);
- }
-
- $mockClasses = array_values(array_filter($type->getTypes(), static function (Type $type): bool {
- return !$type instanceof TypeWithClassName || $type->getClassName() !== MockObject::class;
+ $mockClasses = array_values(array_filter($type->getObjectClassNames(), static function (string $class): bool {
+ return $class !== MockObject::class;
}));
if (count($mockClasses) !== 1) {
return new ObjectType(InvocationMocker::class);
}
- return new GenericObjectType(InvocationMocker::class, $mockClasses);
+ return new GenericObjectType(InvocationMocker::class, [new ObjectType($mockClasses[0])]);
}
}
diff --git a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
index 87e5a3af..08986404 100644
--- a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
+++ b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
@@ -43,6 +43,7 @@ public function testRule(): void
[
'Call to method PHPUnit\Framework\Assert::assertSame() with array and array will always evaluate to false.',
39,
+ 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false> in your %configurationFile%>.',
],
[
'Call to method PHPUnit\Framework\Assert::assertSame() with 1 and 1 will always evaluate to true.',
diff --git a/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php
new file mode 100644
index 00000000..69a7de2f
--- /dev/null
+++ b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php
@@ -0,0 +1,65 @@
+
+ */
+class ClassCoversExistsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new ClassCoversExistsRule(
+ new CoversHelper($reflection),
+ $reflection
+ );
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/class-coverage.php'], [
+ [
+ '@coversDefaultClass references an invalid class \Not\A\Class.',
+ 8,
+ ],
+ [
+ '@coversDefaultClass is defined multiple times.',
+ 23,
+ ],
+ [
+ '@covers value \Not\A\Class references an invalid class or function.',
+ 31,
+ ],
+ [
+ '@covers value does not specify anything.',
+ 43,
+ ],
+ [
+ '@covers value NotFullyQualified references an invalid class or function.',
+ 50,
+ 'The @covers annotation requires a fully qualified name.',
+ ],
+ [
+ '@covers value \DateTimeInterface references an interface.',
+ 64,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php
new file mode 100644
index 00000000..b886b460
--- /dev/null
+++ b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php
@@ -0,0 +1,65 @@
+
+ */
+class ClassMethodCoversExistsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new ClassMethodCoversExistsRule(
+ new CoversHelper($reflection),
+ self::getContainer()->getByType(FileTypeMapper::class)
+ );
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/method-coverage.php'], [
+ [
+ '@covers value \Not\A\Class::ignoreThis references an invalid method.',
+ 14,
+ ],
+ [
+ '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.',
+ 28,
+ ],
+ [
+ '@covers value \Not\A\Class::foo references an invalid method.',
+ 35,
+ ],
+ [
+ '@coversDefaultClass defined on class method testBadCoversDefault.',
+ 50,
+ ],
+ [
+ '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.',
+ 62,
+ ],
+ [
+ 'Class already @covers \PHPUnit\Framework\TestCase so the method @covers is redundant.',
+ 85,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php
new file mode 100644
index 00000000..18cc12b3
--- /dev/null
+++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php
@@ -0,0 +1,77 @@
+
+ */
+class DataProviderDeclarationRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new DataProviderDeclarationRule(
+ new DataProviderHelper($reflection, self::getContainer()->getByType(FileTypeMapper::class),true),
+ true,
+ true
+ );
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/data-provider-declaration.php'], [
+ [
+ '@dataProvider providebaz related method is used with incorrect case: provideBaz.',
+ 16,
+ ],
+ [
+ '@dataProvider provideQux related method must be static in PHPUnit 10 and newer.',
+ 16,
+ ],
+ [
+ '@dataProvider provideQuux related method must be public.',
+ 16,
+ ],
+ [
+ '@dataProvider provideNonExisting related method not found.',
+ 70,
+ ],
+ [
+ '@dataProvider NonExisting::provideNonExisting related class not found.',
+ 70,
+ ],
+ [
+ '@dataProvider provideNonExisting related method not found.',
+ 85,
+ ],
+ [
+ '@dataProvider provideNonExisting2 related method not found.',
+ 86,
+ ],
+ [
+ '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.',
+ 87,
+ ],
+ [
+ '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.',
+ 88,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+}
diff --git a/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
new file mode 100644
index 00000000..4c065466
--- /dev/null
+++ b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
@@ -0,0 +1,56 @@
+
+ */
+class ImpossibleCheckTypeMethodCallRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ImpossibleCheckTypeMethodCallRule::class);
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/impossible-assert-method-call.php'], [
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{} will always evaluate to true.',
+ 14,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{1, 2, 3} will always evaluate to false.',
+ 15,
+ ],
+ ]);
+ }
+
+ public function testBug141(): void
+ {
+ $this->analyse([__DIR__ . '/data/bug-141.php'], [
+ [
+ "Call to method PHPUnit\Framework\Assert::assertEmpty() with non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> will always evaluate to false.",
+ 23,
+ 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false> in your %configurationFile%>.',
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ __DIR__ . '/../../../vendor/phpstan/phpstan-strict-rules/rules.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php
new file mode 100644
index 00000000..e28fde15
--- /dev/null
+++ b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php
@@ -0,0 +1,87 @@
+
+ */
+class NoMissingSpaceInClassAnnotationRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new NoMissingSpaceInClassAnnotationRule(new AnnotationHelper());
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/InvalidClassCoversAnnotation.php'], [
+ [
+ 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.',
+ 36,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php
new file mode 100644
index 00000000..2926ec93
--- /dev/null
+++ b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php
@@ -0,0 +1,87 @@
+
+ */
+class NoMissingSpaceInMethodAnnotationRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new NoMissingSpaceInMethodAnnotationRule(new AnnotationHelper());
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/InvalidMethodCoversAnnotation.php'], [
+ [
+ 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.',
+ 12,
+ ],
+ [
+ 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.',
+ 19,
+ ],
+ [
+ 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 27,
+ ],
+ [
+ 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 27,
+ ],
+ [
+ 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.',
+ 33,
+ ],
+ [
+ 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.',
+ 39,
+ ],
+ [
+ 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.',
+ 45,
+ ],
+ [
+ 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.',
+ 52,
+ ],
+ [
+ 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.',
+ 58,
+ ],
+ [
+ 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.',
+ 64,
+ ],
+ [
+ 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.',
+ 70,
+ ],
+ [
+ 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.',
+ 76,
+ ],
+ [
+ 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.',
+ 82,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php
new file mode 100644
index 00000000..11f9b904
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/InvalidClassCoversAnnotation.php
@@ -0,0 +1,38 @@
+= 5.3
+ * @testDox
+ * @testDox foo bar
+ * @testWith
+ * @testWith ['foo', 'bar']
+ * @ticket
+ * @ticket 1234
+ * @uses
+ * @uses foo
+ */
+class InvalidClassCoversAnnotation extends \PHPUnit\Framework\TestCase
+{
+}
diff --git a/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php
new file mode 100644
index 00000000..9154937b
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php
@@ -0,0 +1,83 @@
+= 5.3
+ */
+ public function requiresAnnotation() {}
+
+ /**
+ * @testDox
+ * @testDox foo bar
+ */
+ public function testDox() {}
+
+ /**
+ * @testWith
+ * @testWith ['foo', 'bar']
+ */
+ public function testWith() {}
+
+ /**
+ * @ticket
+ * @ticket 1234
+ */
+ public function ticket() {}
+
+ /**
+ * @uses
+ * @uses foo
+ */
+ public function uses() {}
+}
diff --git a/tests/Rules/PHPUnit/data/bug-141.php b/tests/Rules/PHPUnit/data/bug-141.php
new file mode 100644
index 00000000..30011049
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/bug-141.php
@@ -0,0 +1,53 @@
+ $a
+ */
+ public function doFoo(array $a): void
+ {
+ $this->assertEmpty($a);
+ }
+
+ /**
+ * @param non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> $a
+ */
+ public function doBar(array $a): void
+ {
+ $this->assertEmpty($a);
+ }
+
+ public function doBaz(): void
+ {
+ $expected = [
+ '0.6.0' => true,
+ '1.0.0' => true,
+ '1.0.x-dev' => true,
+ '1.1.x-dev' => true,
+ 'dev-feature-b' => true,
+ 'dev-feature/a-1.0-B' => true,
+ 'dev-master' => true,
+ '9999999-dev' => true, // alias of dev-master
+ ];
+
+ /** @var array */
+ $packages = ['0.6.0', '1.0.0', '1'];
+
+ foreach ($packages as $version) {
+ if (isset($expected[$version])) {
+ unset($expected[$version]);
+ } else {
+ throw new \Exception('Unexpected version '.$version);
+ }
+ }
+
+ $this->assertEmpty($expected);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/class-coverage.php b/tests/Rules/PHPUnit/data/class-coverage.php
new file mode 100644
index 00000000..c231f772
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/class-coverage.php
@@ -0,0 +1,66 @@
+assertEmpty($c);
+ $this->assertEmpty([]);
+ $this->assertEmpty([1, 2, 3]);
+ }
+
+ public function doBar(object $o): void
+ {
+ $this->assertEmpty($o);
+ }
+
+ /**
+ * @param class-string<\Exception> $name
+ * @return void
+ */
+ public function doBaz(\Exception $e, string $name): void
+ {
+ $this->assertInstanceOf($name, $e);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/method-coverage.php b/tests/Rules/PHPUnit/data/method-coverage.php
new file mode 100644
index 00000000..77ed1ab8
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/method-coverage.php
@@ -0,0 +1,87 @@
+createStub(Bar::class)->method('doBadThing');
}
+ public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock)
+ {
+ $mock->method('doFoo');
+ }
+
}
class Bar {
@@ -51,3 +56,18 @@ public function method(string $string)
return $string;
}
};
+
+final class FinalFoo
+{
+
+}
+
+class FinalFooTest extends \PHPUnit\Framework\TestCase
+{
+
+ public function testMockFinalClass()
+ {
+ $this->createMock(FinalFoo::class)->method('doFoo');
+ }
+
+}
diff --git a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php
index f2a254d7..2d43f8bc 100644
--- a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php
+++ b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php
@@ -11,11 +11,15 @@ class AssertFunctionTypeSpecifyingExtensionTest extends TypeInferenceTestCase
/** @return mixed[] */
public function dataFileAsserts(): iterable
{
- if (!function_exists('PHPUnit\\Framework\\assertInstanceOf')) {
- return [];
+ if (function_exists('PHPUnit\\Framework\\assertInstanceOf')) {
+ yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function.php');
}
- yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function.php');
+ if (function_exists('PHPUnit\\Framework\\assertObjectHasProperty')) {
+ yield from $this->gatherAssertTypes(__DIR__ . '/data/assert-function-9.6.11.php');
+ }
+
+ return [];
}
/**
diff --git a/tests/Type/PHPUnit/data/assert-function-9.6.11.php b/tests/Type/PHPUnit/data/assert-function-9.6.11.php
new file mode 100644
index 00000000..dea6b851
--- /dev/null
+++ b/tests/Type/PHPUnit/data/assert-function-9.6.11.php
@@ -0,0 +1,17 @@
+ $class
+ */
+ public function assertInstanceOfWorksWithTemplate($o, $class): void
+ {
+ assertInstanceOf($class, $o);
+ assertType(\DateTimeInterface::class, $o);
+ }
+
+ public function arrayHasNumericKey(array $a, \ArrayAccess $b): void {
assertArrayHasKey(0, $a);
assertType('array&hasOffset(0)', $a);
+
+ assertArrayHasKey(0, $b);
+ assertType('ArrayAccess', $b);
}
- public function arrayHasStringKey(array $a): void
+ public function arrayHasStringKey(array $a, \ArrayAccess $b): void
{
assertArrayHasKey('key', $a);
assertType("array&hasOffset('key')", $a);
+
+ assertArrayHasKey('key', $b);
+ assertType("ArrayAccess", $b);
}
public function objectHasAttribute(object $a): void
@@ -36,4 +57,37 @@ public function objectHasAttribute(object $a): void
assertType("object&hasProperty(property)", $a);
}
+ public function testEmpty($a): void
+ {
+ assertEmpty($a);
+ assertType("0|0.0|''|'0'|array{}|Countable|EmptyIterator|false|null", $a);
+ }
+
+ public function contains(array $a, \Traversable $b): void
+ {
+ assertContains('foo', $a);
+ assertType('non-empty-array', $a);
+
+ assertContains('foo', $b);
+ assertType('Traversable', $b);
+ }
+
+ public function containsEquals(array $a, \Traversable $b): void
+ {
+ assertContainsEquals('foo', $a);
+ assertType('non-empty-array', $a);
+
+ assertContainsEquals('foo', $b);
+ assertType('Traversable', $b);
+ }
+
+ public function containsOnlyInstancesOf(array $a, \Traversable $b): void
+ {
+ assertContainsOnlyInstancesOf(\stdClass::class, $a);
+ assertType('array', $a);
+
+ assertContainsOnlyInstancesOf(\stdClass::class, $b);
+ assertType('Traversable', $b);
+ }
+
}