diff --git a/AGENTS.md b/AGENTS.md index f931a99..10a6f37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,10 @@ Guidance for AI agents working in this repository. For install and usage see `RE - Publishes `dist/*.dist` configs into a consuming project. - `bin/php-in-k8s` - Runs PHP commands inside a local Kubernetes pod. +- `bin/coverage-check` + - Checks the total line coverage of a Cobertura report against a minimum percentage. +- `bin/print-section` + - Prints a section header, used by the `ci` script to separate the steps. ## Code layout @@ -35,7 +39,8 @@ Guidance for AI agents working in this repository. For install and usage see `RE - `PHPCodeSniffer/Kununu/` holds the PHP_CodeSniffer standard: `ruleset.xml` plus `Sniffs/`. These are the one exception to the namespace above; see Conventions below. - `bin/` - - The `code-tools` and `php-in-k8s` executables. + - The executables listed above. They are standalone scripts, outside the PSR-4 namespace, and + are published to the consumer's `vendor/bin` through the `bin` entry in `composer.json`. - `dist/` - `*.dist` config templates published into consuming projects. - Root configs: the repo's own tool configs are `*.dist` too (`php-cs-fixer.php.dist`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abd1807..d598013 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,7 @@ composer phpstan # PHPStan static analysis composer psalm # Psalm static analysis composer test # Run the full PHPUnit test suite composer test-coverage # Run the full PHPUnit test suite with a coverage report +composer coverage-check # Check the line coverage of the last coverage run against the minimum ``` All scripts are defined in `composer.json` under `scripts`. @@ -65,6 +66,10 @@ The same tools run in CI (`.github/workflows/continuous-integration.yml`), which - PHPStan - Psalm - PHPUnit tests +- Coverage check ([`coverage-check`](docs/CoverageCheck/README.md)) + +Each step is preceded by a header printed with [`print-section`](docs/PrintSection/README.md), so +the output of the individual tools stays easy to tell apart. #### Shell scripts @@ -107,6 +112,13 @@ Run the test suite with a coverage report: ```console composer test-coverage ``` + +Check the result of that run against the 95% minimum: + +```console +composer coverage-check +``` + ### Documentation Each feature under `src/` has a corresponding per-tool deep Markdown page document in `docs/`, one folder per tool, indexed from `README.md`. diff --git a/README.md b/README.md index 5b5cf0a..e2d5232 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # kununu Code Tools -Shared development tooling for kununu PHP projects. It centralizes the static-analysis and code-style configuration our repositories use, and ships two helper executables. +Shared development tooling for kununu PHP projects. It centralizes the static-analysis and code-style configuration our repositories use, and ships the helper executables our repositories share. ## Installation @@ -28,7 +28,9 @@ Every tool works out of the box. Use [`bin/code-tools`](docs/CodeTools/README.md | [Rector](docs/Rector/README.md) | Automated refactoring and PHP version upgrades | | [Architecture Sniffer](docs/ArchitectureSniffer/README.md) | Architecture and dependency rules, powered by PHPAT | | [`bin/code-tools`](docs/CodeTools/README.md) | Publishes the configuration templates above into your project | +| [`bin/coverage-check`](docs/CoverageCheck/README.md) | Checks the total line coverage of a Cobertura report against a minimum | | [`bin/php-in-k8s`](docs/PHPInK8s/README.md) | Runs PHP commands inside a local Kubernetes pod | +| [`bin/print-section`](docs/PrintSection/README.md) | Prints a section header, to separate the steps of a multi-tool script | ## Upgrading diff --git a/bin/coverage-check b/bin/coverage-check new file mode 100755 index 0000000..3209309 --- /dev/null +++ b/bin/coverage-check @@ -0,0 +1,150 @@ +#!/usr/bin/env php + + */ +final readonly class CoverageCheck +{ + private const string COLOR_MASK = "\033[%sm"; + private const string COLOR_CYAN = '0;36'; + private const string COLOR_RED = '0;31'; + private const string COLOR_GREEN = '0;32'; + private const string COLOR_RESET = '0'; + + private bool $useColors; + private float $minimumPercentage; + private string $reportPath; + private float $validLines; + private float $coveredLines; + private float $coveragePercentage; + + public function __construct(mixed $reportPath, mixed $minimum) + { + $this->useColors = getenv('NO_COLOR') === false; + + $this->validateRequiredArguments($reportPath, $minimum); + + $this->minimumPercentage = $this->getMinimumPercentage($minimum); + $this->reportPath = $this->getReportPath($reportPath); + $this->validLines = $this->getValidLines($coverage = $this->getCoverage()); + $this->coveredLines = $this->getCoveredLines($coverage); + $this->coveragePercentage = $this->getCoveragePercentage(); + } + + public function __invoke(): string + { + return $this->colorize( + sprintf( + 'Line coverage: %.2f%% (%d/%d lines) | Minimum: %.2f%%', + $this->coveragePercentage, + $this->coveredLines, + $this->validLines, + $this->minimumPercentage, + ), + $this->hasPassed() ? self::COLOR_GREEN : self::COLOR_RED + ); + } + + public function hasPassed(): bool + { + return $this->coveragePercentage >= $this->minimumPercentage; + } + + private function validateRequiredArguments(mixed $report, mixed $minimum): void + { + if (null === $report || null === $minimum) { + $this->warning(sprintf('Usage: %s ', basename(__FILE__))); + } + } + + private function getMinimumPercentage(mixed $minimum): float + { + if (!is_numeric($minimum)) { + $this->warning(sprintf('The minimum percentage must be a number, got "%s".', $minimum)); + } + + $minimum = (float) $minimum; + + if ($minimum < 0.0 || $minimum > 100.0) { + $this->warning(sprintf('The minimum percentage must be between 0 and 100, got %s.', $minimum)); + } + + return round($minimum, 2); + } + + private function getReportPath(mixed $report): string + { + if (!is_file($report)) { + $this->warning(sprintf('Coverage report "%s" not found!', $report)); + } + + return (string) $report; + } + + private function getCoverage(): SimpleXMLElement + { + $coverage = @simplexml_load_file($this->reportPath); + + if (false === $coverage) { + $this->warning(sprintf('Could not read the coverage report "%s".', $this->reportPath)); + } + + return $coverage; + } + + private function getValidLines(SimpleXMLElement $coverage): float + { + $valid = (int) ($coverage['lines-valid'] ?? 0); + + if (0 === $valid) { + $this->warning(sprintf('No line coverage found in report "%s".', $this->reportPath)); + } + + return (float) $valid; + } + + private function getCoveredLines(SimpleXMLElement $coverage): float + { + $covered = (int) ($coverage['lines-covered'] ?? 0); + + return (float) $covered; + } + + private function getCoveragePercentage(): float + { + return round(($this->coveredLines / $this->validLines) * 100.0, 2); + } + + private function colorMask(string $color): string + { + return sprintf(self::COLOR_MASK, $color); + } + + private function colorize(string $text, string $color): string + { + return $this->useColors + ? sprintf('%s%s%s', $this->colorMask($color), $text, $this->colorMask(self::COLOR_RESET)) + : $text; + } + + private function warning(string $message): never + { + throw new RuntimeException($this->colorize($message, self::COLOR_CYAN)); + } +} + +try { + $coverageCheck = new CoverageCheck($argv[1] ?? null, $argv[2] ?? null); + + printf('%s%s', $coverageCheck(), PHP_EOL); + + exit($coverageCheck->hasPassed() ? 0 : 1); +} catch (RuntimeException $e) { + fwrite(STDERR, sprintf('%s%s', $e->getMessage(), PHP_EOL)); + + exit(1); +} diff --git a/bin/print-section b/bin/print-section new file mode 100755 index 0000000..b46de41 --- /dev/null +++ b/bin/print-section @@ -0,0 +1,92 @@ +#!/usr/bin/env php + [width] + * + * Colors are omitted when the NO_COLOR environment variable is set. + */ +final readonly class Section +{ + private const int DEFAULT_WIDTH = 80; + private const string FILLER = '━'; + private const string COLOR = "\033[1;36m"; + private const string RESET = "\033[0m"; + + private string $label; + private int $width; + private bool $color; + + public function __construct(?string $label, mixed $width) + { + $this->label = $this->getLabel($label); + $this->width = $this->getWidth($width); + $this->color = getenv('NO_COLOR') === false; + } + + public function __invoke(): string + { + return $this->getText(); + } + + private function getLabel(?string $label): string + { + if (null === $label || '' === trim($label)) { + $this->fail(sprintf('Usage: %s