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
105 changes: 105 additions & 0 deletions .github/scripts/coverage-gate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

/**
* This file is part of the package magicsunday/jsonmapper.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

/*
* Fails when line coverage falls below a threshold.
*
* PHPUnit produces a Clover report but does not itself enforce a minimum, so the "coverage >= 90 %"
* definition of done in AGENTS.md was never machine-checked. This script reads the Clover report's
* project-level metrics and exits non-zero when the covered-statement ratio is under the threshold,
* turning the documented target into a gate.
*
* Usage: php coverage-gate.php <clover.xml> [threshold-percent]
*/

$cloverPath = $argv[1] ?? '';

// A non-numeric threshold argument would coerce to 0.0 and silently turn the gate into an
// always-pass, defeating its purpose without any signal. Reject it - and anything outside 0-100 -
// as a usage error rather than let a misconfiguration disable the gate.
if (isset($argv[2]) && !is_numeric($argv[2])) {
fwrite(\STDERR, sprintf("Coverage threshold must be numeric, got: %s\n", $argv[2]));

exit(2);
}

$threshold = isset($argv[2]) ? (float) $argv[2] : 90.0;

if (($threshold < 0.0) || ($threshold > 100.0)) {
fwrite(\STDERR, sprintf("Coverage threshold must be between 0 and 100, got: %s\n", $argv[2] ?? ''));

exit(2);
}

if (($cloverPath === '') || !is_file($cloverPath)) {
fwrite(\STDERR, sprintf("Coverage report not found: %s\n", $cloverPath === '' ? '<missing argument>' : $cloverPath));

exit(2);
}

$document = new DOMDocument();

// Take libxml errors internal so a malformed report is reported once, by the branch below, rather
// than also emitting a raw libxml warning to the CI log ahead of it - the house bar is clean CLI
// output, and this is the parse-error path the script means to own.
$previousUseInternalErrors = libxml_use_internal_errors(true);
$loaded = $document->load($cloverPath);
libxml_clear_errors();
libxml_use_internal_errors($previousUseInternalErrors);

if ($loaded === false) {
fwrite(\STDERR, sprintf("Coverage report could not be parsed: %s\n", $cloverPath));

exit(2);
}

$metricsNodes = (new DOMXPath($document))->query('/coverage/project/metrics');

if (($metricsNodes === false) || ($metricsNodes->length === 0)) {
fwrite(\STDERR, "Coverage report has no project metrics element.\n");

exit(2);
}

$metrics = $metricsNodes->item(0);

if (!$metrics instanceof DOMElement) {
fwrite(\STDERR, "Coverage report project metrics element is malformed.\n");

exit(2);
}

$statements = (int) $metrics->getAttribute('statements');
$covered = (int) $metrics->getAttribute('coveredstatements');

if ($statements === 0) {
fwrite(\STDERR, "Coverage report counts no statements.\n");

exit(2);
}

$percent = ($covered / $statements) * 100.0;

printf(
"Line coverage: %s%% (%d/%d statements), threshold %s%%.\n",
number_format($percent, 2),
$covered,
$statements,
number_format($threshold, 2),
);

if ($percent < $threshold) {
fwrite(\STDERR, sprintf("Coverage %s%% is below the required %s%%.\n", number_format($percent, 2), number_format($threshold, 2)));

exit(1);
}

exit(0);
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,44 @@ jobs:
- name: Construct the mapper and map a payload
run: |
php .github/scripts/production-install-smoke.php

coverage:
name: Coverage gate
runs-on: ubuntu-latest

steps:
# A single interpreter is enough: line coverage does not vary by PHP version, so the
# gate is not run per matrix leg. pcov rather than Xdebug for a faster coverage run.
- name: Set up PHP version 8.3
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.3'
coverage: pcov
tools: composer:v2

- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Get composer cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-8.3-composer-${{ hashFiles('**/composer.json') }}
restore-keys: |
${{ runner.os }}-8.3-composer-

- name: Install dependencies
run: |
composer install --prefer-dist --no-interaction --no-progress

# Runs the coverage suite and fails when line coverage drops below the target the
# definition of done in AGENTS.md documents.
- name: Enforce the coverage target
run: |
composer ci:test:php:coverage:gate
31 changes: 31 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,34 @@ jobs:
uses: magicsunday/.github/.github/workflows/yamllint.yml@main
permissions:
contents: read

composer-audit:
# dependency-review only inspects dependencies a pull request adds or changes; this audits
# the whole resolved set for newly published advisories, on every push and pull request and
# on the weekly schedule so a vulnerability disclosed against an existing dependency is
# caught over time rather than only when that dependency next changes.
name: Composer audit
runs-on: ubuntu-latest

steps:
- name: Set up PHP version 8.3
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.3'
tools: composer:v2

- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

# This library does not commit composer.lock, so there is nothing for `--locked` to
# audit. Resolve fresh against the declared constraints first - which is what a consumer
# install does - then audit that resolved set.
- name: Install dependencies
run: |
composer install --prefer-dist --no-interaction --no-progress

- name: Audit resolved dependencies
run: |
composer audit --no-interaction
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ List changed API surfaces and relevant attributes/converters in the “Reference
## 8) Definition of Done (DoD)

* ✅ PHPUnit green (positive & negative cases covered).
* ✅ **Coverage ≥ 90 %** (`composer ci:test:php:unit:coverage`).
* ✅ **Coverage ≥ 90 %** — machine-enforced in CI by the `coverage` job via
`composer ci:test:php:coverage:gate`, which runs `ci:test:php:unit:coverage` and fails the build
when line coverage drops below the threshold.
* ✅ PHPStan passes (at least all modified files).
* ✅ Rector & CS fixer clean; commit formatting changes.
* ✅ **CPD** detects no relevant duplicates.
Expand Down Expand Up @@ -285,7 +287,7 @@ List changed API surfaces and relevant attributes/converters in the “Reference
* [ ] `composer ci:test:php:rector`
* [ ] `composer ci:test:php:cpd`
* [ ] `composer ci:test:php:unit`
* [ ] `composer ci:test:php:unit:coverage`
* [ ] `composer ci:test:php:coverage:gate` (coverage ≥ 90 %, enforced in CI)
* [ ] No `mixed`, `empty()`, or nested ternaries.
* [ ] Classes/tests mirror namespaces; descriptive names; inline comments only when logic is complex.
* [ ] Value objects/enums instead of magic strings.
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@
"phpunit --configuration phpunit.xml"
],
"ci:test:php:unit:coverage": [
"XDEBUG_MODE=coverage phpunit --configuration phpunit.xml --coverage-html .build/coverage/"
"XDEBUG_MODE=coverage phpunit --configuration phpunit.xml --coverage-html .build/coverage/ --coverage-clover .build/coverage/clover.xml"
],
"ci:test:php:coverage:gate": [
"@ci:test:php:unit:coverage",
"php .github/scripts/coverage-gate.php .build/coverage/clover.xml 90"
],
"ci:test": [
"@ci:test:php:lint",
Expand Down