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