Something went wrong. Try again.
A large plugin that adds lightweight DAM interface to Piwigo
Something went wrong. Try again.
19 kB · 480 lines
PHP
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481<?php
declare(strict_types=1);
final class QualityReport{ private const PHP_COVERAGE_THRESHOLD = 78.0;
private const FRONTEND_COVERAGE_THRESHOLD = 80.0;
private const BRANCH_COVERAGE_THRESHOLD = 70.0;
/** @return array<string, mixed> */ public static function generate(string $root, string $sourceState): array { $root = realpath($root) ?: throw new RuntimeException('quality_root_invalid'); if (!in_array($sourceState, ['working_tree', 'clean_revision'], true)) { throw new RuntimeException('quality_source_state_invalid'); }
$revision = self::git($root, ['rev-parse', 'HEAD']); if ($sourceState === 'clean_revision' && self::git($root, ['status', '--porcelain']) !== '') { throw new RuntimeException('quality_tree_dirty'); }
$inputDirectory = $root.'/build/quality/'.($sourceState === 'working_tree' ? 'working-tree' : $revision); $outputDirectory = $inputDirectory; $coverage = [ 'php' => self::coverage($inputDirectory.'/php/clover.xml', true), 'frontend' => self::frontendCoverage($inputDirectory.'/frontend/coverage-summary.json'), ]; self::assertCoverageThresholds($coverage);
$phpmdViolations = self::phpmdViolations($inputDirectory.'/complexity/phpmd.xml'); if ($phpmdViolations !== 0) { throw new RuntimeException('quality_complexity_failed'); } $complexity = [ 'php_baseline_count' => $phpmdViolations, 'frontend_violation_count' => self::biomeViolationCount($inputDirectory.'/checks/biome.json'), ]; if ($complexity['frontend_violation_count'] !== 0) { throw new RuntimeException('quality_complexity_failed'); }
self::assertPhpstan($inputDirectory.'/checks/phpstan.json'); $dependencies = self::dependencies( $inputDirectory.'/dependencies/composer.json', $inputDirectory.'/dependencies/npm-full.json', $inputDirectory.'/dependencies/npm-production.json', );
$summary = [ 'format' => 1, 'git_revision' => $revision, 'source_state' => $sourceState, 'generated_at' => gmdate('c'), 'runtime' => [ 'php_version' => PHP_VERSION, 'node_version' => self::nodeVersion(), ], 'checks' => [ 'php_coverage' => 'passed', 'frontend_coverage' => 'passed', 'phpmd' => 'passed', 'phpstan' => 'passed', 'biome' => 'passed', 'composer_audit' => 'passed', 'npm_full_audit' => 'passed', 'npm_production_audit' => 'passed', ], 'coverage' => $coverage, 'complexity' => $complexity, 'dependencies' => $dependencies, ];
if (!is_dir($outputDirectory) && !mkdir($outputDirectory, 0777, true) && !is_dir($outputDirectory)) { throw new RuntimeException('quality_output_unavailable'); } self::writeJson($outputDirectory.'/summary.json', $summary);
return $summary; }
/** @return array{statements: float, branches: ?float, functions: float, lines: float} */ private static function coverage(string $path, bool $allowUnsupportedBranches): array { $metrics = self::xml($path, 'coverage')->xpath('/coverage/project/metrics'); if (!is_array($metrics) || count($metrics) !== 1) { throw new RuntimeException('quality_artifact_malformed'); }
$attributes = $metrics[0]->attributes(); if ($attributes === null) { throw new RuntimeException('quality_artifact_malformed'); } $statements = self::coveragePercentage($attributes, 'statements', 'coveredstatements'); $branches = self::coveragePercentage($attributes, 'conditionals', 'coveredconditionals', $allowUnsupportedBranches); $functions = self::coveragePercentage($attributes, 'methods', 'coveredmethods');
return [ 'statements' => $statements, 'branches' => $branches, 'functions' => $functions, 'lines' => $statements, ]; }
/** @return array{statements: float, branches: float, functions: float, lines: float} */ private static function frontendCoverage(string $path): array { $summary = self::json($path)['total'] ?? null; if (!is_array($summary)) { throw new RuntimeException('quality_artifact_malformed'); }
return [ 'statements' => self::frontendCoveragePercentage($summary, 'statements'), 'branches' => self::frontendCoveragePercentage($summary, 'branches'), 'functions' => self::frontendCoveragePercentage($summary, 'functions'), 'lines' => self::frontendCoveragePercentage($summary, 'lines'), ]; }
/** @param array<string, mixed> $summary */ private static function frontendCoveragePercentage(array $summary, string $metric): float { $coverage = $summary[$metric] ?? null; if (!is_array($coverage) || !is_int($coverage['total'] ?? null) || !is_int($coverage['covered'] ?? null) || !(is_int($coverage['pct'] ?? null) || is_float($coverage['pct'] ?? null))) { throw new RuntimeException('quality_artifact_malformed'); }
$total = $coverage['total']; $covered = $coverage['covered']; $reportedPercentage = (float) $coverage['pct']; if ($total < 0 || $covered < 0 || $covered > $total || !is_finite($reportedPercentage) || $reportedPercentage < 0 || $reportedPercentage > 100) { throw new RuntimeException('quality_artifact_malformed'); } if ($total === 0) { throw new RuntimeException('quality_coverage_unsupported:'.$metric); }
$percentage = floor(10000 * $covered / $total) / 100; if (abs($reportedPercentage - $percentage) > 0.000001) { throw new RuntimeException('quality_artifact_malformed'); }
return $percentage; }
/** @param \SimpleXMLElement $attributes */ private static function coveragePercentage(\SimpleXMLElement $attributes, string $totalName, string $coveredName, bool $allowUnsupported = false): ?float { if (!isset($attributes[$totalName], $attributes[$coveredName]) || !ctype_digit((string) $attributes[$totalName]) || !ctype_digit((string) $attributes[$coveredName])) { throw new RuntimeException('quality_artifact_malformed'); }
$total = (int) $attributes[$totalName]; $covered = (int) $attributes[$coveredName]; if ($covered > $total) { throw new RuntimeException('quality_artifact_malformed'); }
if ($total === 0) { if ($allowUnsupported) { return null; } throw new RuntimeException('quality_coverage_unsupported:'.$totalName); }
return round(100 * $covered / $total, 2); }
/** @param array{php: array{statements: float, branches: ?float, functions: float, lines: float}, frontend: array{statements: float, branches: ?float, functions: float, lines: float}} $coverage */ private static function assertCoverageThresholds(array $coverage): void { foreach ($coverage as $language => $metrics) { $metricsToCheck = $language === 'php' ? ['lines'] : ['statements', 'functions', 'lines']; foreach ($metricsToCheck as $metric) { $threshold = $language === 'php' ? self::PHP_COVERAGE_THRESHOLD : self::FRONTEND_COVERAGE_THRESHOLD; if ($metrics[$metric] < $threshold) { throw new RuntimeException('quality_threshold_failed:'.$language.'_'.$metric); } } if ($language === 'frontend' && ($metrics['branches'] === null || $metrics['branches'] < self::BRANCH_COVERAGE_THRESHOLD)) { throw new RuntimeException('quality_threshold_failed:'.$language.'_branches'); } } }
private static function phpmdViolations(string $path): int { return count(self::xml($path, 'pmd')->xpath('//violation') ?: []); }
private static function biomeViolationCount(string $path): int { $report = self::json($path); $summary = $report['summary'] ?? null; $diagnostics = $report['diagnostics'] ?? null; if (!is_array($summary) || !is_array($diagnostics) || !is_int($summary['errors'] ?? null)) { throw new RuntimeException('quality_artifact_malformed'); } if ($summary['errors'] !== 0) { throw new RuntimeException('quality_static_analysis_failed'); }
$violations = 0; foreach ($diagnostics as $diagnostic) { if (!is_array($diagnostic)) { throw new RuntimeException('quality_artifact_malformed'); } if (($diagnostic['category'] ?? null) === 'lint/complexity/noExcessiveCognitiveComplexity') { ++$violations; } }
return $violations; }
private static function assertPhpstan(string $path): void { $report = self::json($path); $totals = $report['totals'] ?? null; if (!is_array($totals) || !is_int($totals['errors'] ?? null) || !is_int($totals['file_errors'] ?? null)) { throw new RuntimeException('quality_artifact_malformed'); } if ($totals['errors'] !== 0 || $totals['file_errors'] !== 0) { throw new RuntimeException('quality_static_analysis_failed'); } }
/** @return array{composer: list<array{id: string, severity: string, production_reachable: bool}>, npm: list<array{id: string, severity: string, production_reachable: bool}>} */ private static function dependencies(string $composerPath, string $npmFullPath, string $npmProductionPath): array { $composer = self::composerAdvisories(self::json($composerPath)); $npm = [ ...self::npmAdvisories(self::json($npmFullPath), false), ...self::npmAdvisories(self::json($npmProductionPath), true), ]; $npm = self::uniqueAdvisories($npm);
if ($composer !== [] || array_filter($npm, static fn (array $advisory): bool => $advisory['production_reachable']) !== []) { throw new RuntimeException('quality_dependency_failed'); } foreach ($npm as $advisory) { if (self::severityAtLeastModerate($advisory['severity'])) { throw new RuntimeException('quality_dependency_failed'); } }
return ['composer' => $composer, 'npm' => $npm]; }
/** @param array<string, mixed> $report * @return list<array{id: string, severity: string, production_reachable: bool}> */ private static function composerAdvisories(array $report): array { $raw = $report['advisories'] ?? null; if (!is_array($raw)) { throw new RuntimeException('quality_artifact_malformed'); }
$advisories = []; foreach ($raw as $package => $entries) { $entries = is_array($entries) && array_is_list($entries) ? $entries : [$entries]; foreach ($entries as $entry) { if (!is_array($entry)) { throw new RuntimeException('quality_artifact_malformed'); } $id = $entry['advisoryId'] ?? $entry['cve'] ?? $entry['id'] ?? $package; $severity = $entry['severity'] ?? null; if (!is_string($id) || !is_string($severity)) { throw new RuntimeException('quality_artifact_malformed'); } $advisories[] = self::advisory($id, $severity, true); } }
return self::uniqueAdvisories($advisories); }
/** @param array<string, mixed> $report * @return list<array{id: string, severity: string, production_reachable: bool}> */ private static function npmAdvisories(array $report, bool $productionReachable): array { $vulnerabilities = $report['vulnerabilities'] ?? null; if (!is_array($vulnerabilities)) { throw new RuntimeException('quality_artifact_malformed'); }
$advisories = []; foreach ($vulnerabilities as $package => $vulnerability) { if (!is_string($package) || !is_array($vulnerability) || !is_string($vulnerability['severity'] ?? null)) { throw new RuntimeException('quality_artifact_malformed'); } $via = $vulnerability['via'] ?? null; if (!is_array($via)) { throw new RuntimeException('quality_artifact_malformed'); } foreach ($via as $source) { if (is_string($source) && array_key_exists($source, $vulnerabilities)) { continue; } $id = self::npmAdvisoryId($source, $package); $severity = is_array($source) && is_string($source['severity'] ?? null) ? $source['severity'] : $vulnerability['severity']; $advisories[] = self::advisory($id, $severity, $productionReachable); } }
return $advisories; }
private static function npmAdvisoryId(mixed $source, string $package): string { if (is_string($source) && $source !== '') { return $source; } if (!is_array($source)) { throw new RuntimeException('quality_artifact_malformed'); } foreach (['url', 'name', 'source'] as $key) { if (!isset($source[$key])) { continue; } $value = (string) $source[$key]; if (preg_match('/(GHSA-[A-Za-z0-9-]+|CVE-\d{4}-\d+)$/', $value, $matches) === 1) { return $matches[1]; } if ($key !== 'url' && $value !== '') { return $value; } }
return $package; }
/** @return array{id: string, severity: string, production_reachable: bool} */ private static function advisory(string $id, string $severity, bool $productionReachable): array { $severity = strtolower($severity); if ($id === '' || !in_array($severity, ['info', 'low', 'moderate', 'high', 'critical'], true)) { throw new RuntimeException('quality_artifact_malformed'); }
return ['id' => $id, 'severity' => $severity, 'production_reachable' => $productionReachable]; }
/** @param list<array{id: string, severity: string, production_reachable: bool}> $advisories * @return list<array{id: string, severity: string, production_reachable: bool}> */ private static function uniqueAdvisories(array $advisories): array { $unique = []; foreach ($advisories as $advisory) { $key = $advisory['id'].'|'.$advisory['severity'].'|'.(int) $advisory['production_reachable']; $unique[$key] = $advisory; }
return array_values($unique); }
private static function severityAtLeastModerate(string $severity): bool { return in_array($severity, ['moderate', 'high', 'critical'], true); }
/** @return array<string, mixed> */ private static function json(string $path): array { self::requiredFile($path); try { $decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { throw new RuntimeException('quality_artifact_malformed'); } if (!is_array($decoded)) { throw new RuntimeException('quality_artifact_malformed'); }
return $decoded; }
private static function xml(string $path, string $root): \SimpleXMLElement { self::requiredFile($path); $previous = libxml_use_internal_errors(true); try { $xml = simplexml_load_string((string) file_get_contents($path), \SimpleXMLElement::class, LIBXML_NONET); } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); } if (!$xml instanceof \SimpleXMLElement || $xml->getName() !== $root) { throw new RuntimeException('quality_artifact_malformed'); }
return $xml; }
private static function requiredFile(string $path): void { if (!is_file($path) || filesize($path) === 0) { throw new RuntimeException('quality_artifact_missing'); } }
/** @param array<string, mixed> $summary */ private static function writeJson(string $path, array $summary): void { $temporaryPath = tempnam(dirname($path), 'quality-summary-'); if ($temporaryPath === false) { throw new RuntimeException('quality_output_unavailable'); } try { if (file_put_contents($temporaryPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n") === false || !rename($temporaryPath, $path)) { throw new RuntimeException('quality_output_unavailable'); } } finally { if (is_file($temporaryPath)) { unlink($temporaryPath); } } }
private static function nodeVersion(): string { return self::command(['node', '--version']); }
/** @param list<string> $arguments */ private static function git(string $root, array $arguments): string { return self::command(['git', '-C', $root, ...$arguments]); }
/** @param list<string> $command */ private static function command(array $command): string { $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) { throw new RuntimeException('quality_command_unavailable'); } $output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); if (proc_close($process) !== 0) { throw new RuntimeException('quality_command_failed:'.trim($error)); }
return trim($output); }}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { try { $sourceState = $argv[1] ?? 'working_tree'; $token = getenv('QUALITY_RUN_TOKEN'); $lockTokenPath = dirname(__DIR__).'/build/quality/working-tree/.working-tree.lock/token'; if (!is_string($token) || $token === '' || !is_file($lockTokenPath) || !hash_equals(trim((string) file_get_contents($lockTokenPath)), $token)) { throw new RuntimeException('quality_owner_required'); } $summary = QualityReport::generate(dirname(__DIR__), $sourceState); fwrite(STDOUT, json_encode($summary, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"); } catch (Throwable $error) { fwrite(STDERR, $error->getMessage()."\n"); exit(1); }}