diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fbf15ef..f340ca5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -33,21 +33,6 @@ jobs: XDEBUG_MODE: coverage run: composer check - - - name: Output line coverage - run: head cache/coverage/coverage.txt | grep Lines - - - - name: Report patch coverage - if: github.event_name == 'pull_request' - run: | - if git diff --name-only ${{ github.event.pull_request.base.sha }} | grep -q '\.php$'; then - git diff ${{ github.event.pull_request.base.sha }} > changes.patch - vendor/bin/phpcov patch-coverage --path-prefix $(pwd) cache/coverage/coverage.cov changes.patch || true - else - echo "No PHP files changed, skipping patch coverage report" - fi - cda: runs-on: ubuntu-latest strategy: @@ -66,7 +51,9 @@ jobs: ini-file: development - name: Update dependencies - run: composer update --no-progress --prefer-dist --no-interaction + run: | + composer remove --dev shipmonk/coverage-guard --no-update + composer update --no-progress --prefer-dist --no-interaction - name: Run dependency analyser run: composer check:dependencies @@ -89,7 +76,9 @@ jobs: ini-file: development - name: Update dependencies - run: composer update --no-progress --prefer-dist --no-interaction + run: | + composer remove --dev shipmonk/coverage-guard --no-update + composer update --no-progress --prefer-dist --no-interaction - name: Run PHPStan run: composer check:types @@ -113,7 +102,9 @@ jobs: ini-file: development - name: Update dependencies - run: composer update --no-progress --${{ matrix.dependency-version }} --prefer-dist --no-interaction ${{ matrix.php-version == '8.5' && '--ignore-platform-req=php+' || '' }} + run: | + composer remove --dev shipmonk/coverage-guard --no-update + composer update --no-progress --${{ matrix.dependency-version }} --prefer-dist --no-interaction - name: Run tests run: vendor/bin/phpunit --no-coverage tests diff --git a/composer.json b/composer.json index 72282c6..a2dec8c 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "phpunit/phpunit": "^9.6.22", "shipmonk/coding-standard": "^0.2.0", "shipmonk/composer-dependency-analyser": "^1.8.2", + "shipmonk/coverage-guard": "dev-master", "shipmonk/name-collision-detector": "^2.1.1", "shipmonk/phpstan-dev": "^0.1.1", "shipmonk/phpstan-rules": "^4.1.0", @@ -78,7 +79,7 @@ "@check:ec", "@check:cs", "@check:types", - "@check:tests", + "@check:coverage", "@check:collisions", "@check:dependencies" ], @@ -87,6 +88,10 @@ "composer normalize --dry-run --no-update-lock", "composer validate --strict" ], + "check:coverage": [ + "XDEBUG_MODE=coverage phpunit tests --coverage-clover cache/clover.xml", + "coverage-guard check cache/clover.xml" + ], "check:cs": "phpcs", "check:dependencies": "composer-dependency-analyser", "check:ec": "ec src tests", diff --git a/composer.lock b/composer.lock index 11dab57..0247bde 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "365ce4a7f734059566fad5f02f7be3b3", + "content-hash": "3ae9ed58fe00d5280196e2dc4af70ee5", "packages": [ { "name": "phpstan/phpstan", @@ -4689,6 +4689,67 @@ }, "time": "2025-02-10T13:31:57+00:00" }, + { + "name": "shipmonk/coverage-guard", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/shipmonk-rnd/coverage-guard.git", + "reference": "2f1281f7883ce7933ee029241fd517bb14cafcd6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/shipmonk-rnd/coverage-guard/zipball/2f1281f7883ce7933ee029241fd517bb14cafcd6", + "reference": "2f1281f7883ce7933ee029241fd517bb14cafcd6", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.19.1 || ^5.0", + "php": "^8.1" + }, + "require-dev": { + "editorconfig-checker/editorconfig-checker": "10.7.0", + "ergebnis/composer-normalize": "2.48.2", + "phpstan/phpstan": "2.1.32", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.7", + "phpunit/php-code-coverage": "^10.1", + "phpunit/phpunit": "~10.5.58", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "shipmonk/coding-standard": "^0.2.0", + "shipmonk/composer-dependency-analyser": "1.8.3", + "shipmonk/dead-code-detector": "0.13.3", + "shipmonk/name-collision-detector": "2.1.1", + "shipmonk/phpstan-rules": "4.2.1" + }, + "default-branch": true, + "bin": [ + "bin/coverage-guard" + ], + "type": "library", + "autoload": { + "psr-4": { + "ShipMonk\\CoverageGuard\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Enforce code coverage in your CI. Not by percentage, but target core methods. No more untested Facades, Controllers, or Repositories. Allows you to start enforcing coverage for new code only!", + "keywords": [ + "code coverage", + "git diff", + "patch", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/shipmonk-rnd/coverage-guard/issues", + "source": "https://github.com/shipmonk-rnd/coverage-guard/tree/master" + }, + "time": "2025-11-28T14:42:05+00:00" + }, { "name": "shipmonk/name-collision-detector", "version": "2.1.1", @@ -7085,7 +7146,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "shipmonk/coverage-guard": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/coverage-guard.php b/coverage-guard.php new file mode 100644 index 0000000..f7d9755 --- /dev/null +++ b/coverage-guard.php @@ -0,0 +1,65 @@ +addRule(new class implements CoverageRule { + + public function inspect( + CodeBlock $codeBlock, + InspectionContext $context + ): ?CoverageError + { + if (!$codeBlock instanceof ClassMethodBlock) { + return null; + } + + if ($codeBlock->getExecutableLinesCount() < 5) { + return null; + } + + $coverage = $codeBlock->getCoveragePercentage(); + $classReflection = $codeBlock->getMethodReflection()->getDeclaringClass(); + $requiredCoverage = $this->getRequiredCoverage($classReflection); + + if ($codeBlock->getCoveragePercentage() < $requiredCoverage) { + return CoverageError::create("Method {$codeBlock->getMethodName()} requires $requiredCoverage% coverage, but has only $coverage%."); + } + + return null; + } + + /** + * @param ReflectionClass $classReflection + */ + private function getRequiredCoverage(ReflectionClass $classReflection): int + { + $isPoor = in_array($classReflection->getName(), [ + BackwardCompatibilityChecker::class, + ReflectionHelper::class, + ], true); + + $isCore = $classReflection->implementsInterface(MemberUsageProvider::class) + || $classReflection->implementsInterface(Collector::class) + || $classReflection->implementsInterface(Rule::class); + + return match (true) { + $isCore => 80, + $isPoor => 20, + default => 50, + }; + } + +}); + +return $config; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 09decbc..0821c24 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,13 +15,6 @@ src - - - - - - - diff --git a/tests/Rule/data/providers/custom.php b/tests/Rule/data/providers/custom.php index c57f54c..39ee3bf 100644 --- a/tests/Rule/data/providers/custom.php +++ b/tests/Rule/data/providers/custom.php @@ -7,6 +7,8 @@ class Methods const SOME_CONSTANT = 1; // error: Unused CustomProvider\Methods::SOME_CONSTANT public function method(): void {} + + public function mixedTestThatExcludersCanExcludeProvidedUsage(): void {} // error: Unused CustomProvider\Methods::mixedTestThatExcludersCanExcludeProvidedUsage (all usages excluded by mixedPrefix excluder) } class Constants diff --git a/tests/Rule/data/providers/phpbench.php b/tests/Rule/data/providers/phpbench.php index 647adc2..0c78d18 100644 --- a/tests/Rule/data/providers/phpbench.php +++ b/tests/Rule/data/providers/phpbench.php @@ -28,6 +28,11 @@ public function benchWithParams(array $params): void { } + #[ParamProviders([1])] + public function benchWithInvalidAttributeDontBreakIt(array $params): void + { + } + public function provideParams(): array { return [];