Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`,
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
150 changes: 150 additions & 0 deletions bin/coverage-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);

/**
* Checks that the line coverage of a Cobertura report meets a minimum threshold.
*
* Usage: coverage-check <cobertura-report> <minimum-percentage>
*/
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 <cobertura-report-file> <minimum-percentage>', 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);
}
92 changes: 92 additions & 0 deletions bin/print-section
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);

/**
* Prints a centered section label, used to separate the steps of the "ci" script.
*
* Usage: print-section <label> [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 <label> [width]', basename(__FILE__)));
}

return trim($label);
}

private function getWidth(mixed $width): int
{
$width ??= self::DEFAULT_WIDTH;

if (!is_numeric($width) || (int) $width < 1) {
$this->fail(sprintf('The width must be a positive number, got "%s".', $width));
}

return (int) $width;
}

private function getText(): string
{
$length = (int) preg_match_all('/./u', $this->label);
$filler = max(0, $this->width - $length - 2);
$left = intdiv($filler, 2);

$section = sprintf(
'%s %s %s',
str_repeat(self::FILLER, $left),
$this->label,
str_repeat(self::FILLER, $filler - $left)
);

return sprintf(
'%s%s%s%s%s',
PHP_EOL,
$this->color ? self::COLOR : '',
trim($section),
$this->color ? self::RESET : '',
PHP_EOL
);
}

private function fail(string $message): never
{
throw new RuntimeException($message);
}
}

try {
printf('%s', new Section($argv[1] ?? null, $argv[2] ?? null)());

exit(0);
} catch (RuntimeException $e) {
fwrite(STDERR, sprintf('%s%s', $e->getMessage(), PHP_EOL));

exit(1);
}
1 change: 1 addition & 0 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
return $config
->addPathToExclude(__DIR__ . '/tests')
->ignoreErrorsOnPath(__DIR__ . '/src/PHPCodeSniffer/Kununu/Sniffs/', [ErrorType::UNKNOWN_CLASS])
->ignoreErrorsOnExtension('ext-simplexml', [ErrorType::UNUSED_DEPENDENCY])
->ignoreErrorsOnPackages(
[
'friendsofphp/php-cs-fixer',
Expand Down
23 changes: 20 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"require": {
"php": ">=8.4",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-tokenizer": "*",
"composer-plugin-api": "^2.0",
"composer/composer": "^2.10",
Expand Down Expand Up @@ -67,7 +68,9 @@
},
"bin": [
"bin/code-tools",
"bin/php-in-k8s"
"bin/coverage-check",
"bin/php-in-k8s",
"bin/print-section"
],
"config": {
"allow-plugins": {
Expand All @@ -83,17 +86,30 @@
"post-install-cmd": "@git-hook",
"post-update-cmd": "@git-hook",
"ci": [
"bin/print-section 'Security Audit'",
"composer audit --abandoned=report",
"bin/print-section 'Dependency Analyser'",
"composer-dependency-analyser",
"bin/print-section 'Composer Require Checker'",
"composer-require-checker",
"bin/print-section 'Composer Normalize'",
"composer normalize --dry-run --indent-size 2 --indent-style space --no-check-lock --no-update-lock",
"bin/print-section 'PHP CS Fixer'",
"@cs-fix",
"bin/print-section 'PHP CodeSniffer'",
"@sniffer",
"bin/print-section 'Rector'",
"@rector",
"bin/print-section 'PHPStan'",
"@phpstan",
"bin/print-section 'Psalm'",
"@psalm",
"@test-coverage"
"bin/print-section 'Tests & Coverage'",
"@test-coverage",
"bin/print-section 'Coverage Check'",
"@coverage-check"
],
"coverage-check": "bin/coverage-check tests/.results/tests-cobertura.xml 95",
"cs": "php-cs-fixer check --config=php-cs-fixer.php.dist",
"cs-fix": "php-cs-fixer fix --config=php-cs-fixer.php.dist",
"git-hook": "Kununu\\CodeTools\\PHPCSFixer\\CsFixerPlugin::installGitHooks",
Expand All @@ -106,11 +122,12 @@
"test": "@php -d zend.assertions=1 vendor/bin/phpunit --testsuite Full --testdox --testdox-summary --no-coverage --no-logging",
"test-coverage": [
"@putenv XDEBUG_MODE=coverage",
"@php -d zend.assertions=1 vendor/bin/phpunit --testsuite Full --testdox --testdox-summary --log-junit tests/.results/tests-junit.xml --coverage-clover tests/.results/tests-clover.xml --coverage-html tests/.results/html/"
"@php -d zend.assertions=1 vendor/bin/phpunit --testsuite Full --testdox --testdox-summary --log-junit tests/.results/tests-junit.xml --coverage-clover tests/.results/tests-clover.xml --coverage-cobertura tests/.results/tests-cobertura.xml --coverage-html tests/.results/html/"
]
},
"scripts-descriptions": {
"ci": "Run all CI checks",
"coverage-check": "Check that the line coverage of the last coverage run is at least 95%",
"cs": "Run PHP CS Fixer",
"git-hook": "Install the PHP CS Fixer git pre-commit hook in this repository",
"phpstan": "Run PHPStan",
Expand Down
Loading