From c7b6f98f933692f5eeba0b0c84db1ba24442010e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 18 Jun 2026 15:53:43 -0500 Subject: [PATCH] feat: add joomla_metadata_validate CLI command (#257) Validates MokoGitea repo metadata against the actual Joomla extension manifest XML to catch update delivery mismatches before production. Checks: - package_type matches - Element name derived correctly (prefix + lowercase + clean) - Display name matches tag - Version consistency (ignoring -dev/-rc suffixes) - PHP minimum matches composer.json - Description match (informational) Supports: - Local mode: reads .mokogitea/manifest.xml + Joomla XML from disk - API mode: fetches metadata via Gitea API (--token) - CI mode: --ci flag exits 1 on errors - JSON output: --json for workflow integration Handles all Joomla types: package, component, module, plugin, template, library, file. Replicates Joomla's InputFilter::clean('cmd') for element name derivation. Refs mokoplatform #257 --- cli/joomla_metadata_validate.php | 515 +++++++++++++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 cli/joomla_metadata_validate.php diff --git a/cli/joomla_metadata_validate.php b/cli/joomla_metadata_validate.php new file mode 100644 index 0000000..dac1cc9 --- /dev/null +++ b/cli/joomla_metadata_validate.php @@ -0,0 +1,515 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: mokoplatform.CLI + * INGROUP: mokoplatform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform + * PATH: /cli/joomla_metadata_validate.php + * VERSION: 09.30.00 + * BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class JoomlaMetadataValidateCli extends CliFramework +{ + /** Joomla element prefix map — must match MokoGitea's cleanJoomlaElement() */ + private const JOOMLA_PREFIX = [ + 'package' => 'pkg_', + 'component' => 'com_', + 'module' => 'mod_', + 'template' => 'tpl_', + 'library' => 'lib_', + 'file' => 'file_', + ]; + + protected function configure(): void + { + $this->setDescription('Validate MokoGitea repo metadata against Joomla extension manifest XML'); + $this->addArgument('--path', 'Repo root path (default: current directory)', '.'); + $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', ''); + $this->addArgument('--org', 'Gitea org', 'MokoConsulting'); + $this->addArgument('--repo', 'Repo name (auto-detected from git if empty)', ''); + $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1'); + $this->addArgument('--ci', 'CI mode: exit 1 on any error', false); + $this->addArgument('--json', 'Output as JSON', false); + } + + protected function run(): int + { + $path = realpath($this->getArgument('--path')) ?: $this->getArgument('--path'); + $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: ''; + $org = $this->getArgument('--org'); + $repoName = $this->getArgument('--repo'); + $apiBase = rtrim($this->getArgument('--api-base'), '/'); + $ciMode = (bool) $this->getArgument('--ci'); + $jsonMode = (bool) $this->getArgument('--json'); + + if (!is_dir($path)) { + $this->log('ERROR', "Path does not exist: {$path}"); + return 1; + } + + if ($repoName === '') { + $repoName = $this->detectRepoName($path); + } + + // ── Step 1: Find the Joomla extension manifest XML ────────── + $joomlaXml = $this->findJoomlaManifest($path); + + if ($joomlaXml === null) { + $this->log('ERROR', 'No Joomla extension manifest XML found'); + return 1; + } + + $this->log('INFO', "Joomla manifest: {$joomlaXml['path']}"); + + // ── Step 2: Load MokoGitea metadata ───────────────────────── + $metadata = $this->loadMetadata($path, $org, $repoName, $token, $apiBase); + + if ($metadata === null) { + $this->log('ERROR', 'Could not load MokoGitea metadata'); + return 1; + } + + // ── Step 3: Compare ───────────────────────────────────────── + $results = $this->compare($metadata, $joomlaXml, $path); + + // ── Step 4: Output ────────────────────────────────────────── + if ($jsonMode) { + echo json_encode([ + 'repo' => $repoName, + 'results' => $results, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + $this->printResults($repoName, $results); + } + + $errors = count(array_filter($results, fn($r) => $r['status'] === 'error')); + + return ($ciMode && $errors > 0) ? 1 : 0; + } + + // ================================================================= + // Find Joomla manifest XML + // ================================================================= + + private function findJoomlaManifest(string $root): ?array + { + // Search common locations for a Joomla extension manifest + $candidates = []; + + // Package manifest: source/pkg_*.xml + foreach (glob("{$root}/source/pkg_*.xml") as $file) { + $candidates[] = $file; + } + + // Component manifest: source/packages/com_*/[name].xml + foreach (glob("{$root}/source/packages/com_*/*.xml") as $file) { + $basename = basename($file); + // Skip access.xml, config.xml, etc. + if (in_array($basename, ['access.xml', 'config.xml'], true)) { + continue; + } + $candidates[] = $file; + } + + // Direct source/*.xml + foreach (glob("{$root}/source/*.xml") as $file) { + if (basename($file) !== 'pkg_mokosuitebackup.xml') { + // Already caught above + } + $candidates[] = $file; + } + + // src/ fallback + foreach (glob("{$root}/src/pkg_*.xml") as $file) { + $candidates[] = $file; + } + + // Find the first one that has + foreach (array_unique($candidates) as $file) { + $content = file_get_contents($file); + if ($content === false) { + continue; + } + + if (preg_match('/]*type=["\']([^"\']+)["\']/', $content, $typeMatch)) { + $xml = @simplexml_load_string($content); + if ($xml === false) { + continue; + } + + $type = strtolower($typeMatch[1]); + $relPath = str_replace($root . '/', '', $file); + $relPath = str_replace($root . '\\', '', $relPath); + + return [ + 'path' => $relPath, + 'type' => $type, + 'xml' => $xml, + ]; + } + } + + return null; + } + + // ================================================================= + // Load metadata (from .mokogitea/manifest.xml or API) + // ================================================================= + + private function loadMetadata(string $root, string $org, string $repoName, string $token, string $apiBase): ?array + { + // Try local .mokogitea/manifest.xml first + $localManifest = "{$root}/.mokogitea/manifest.xml"; + + if (is_file($localManifest)) { + $xml = @simplexml_load_file($localManifest); + + if ($xml !== false) { + $identity = $xml->identity ?? $xml; + $governance = $xml->governance ?? $xml; + $build = $xml->build ?? $xml; + + return [ + 'name' => (string) ($identity->name ?? ''), + 'display_name' => (string) ($identity->{'display-name'} ?? ''), + 'description' => (string) ($identity->description ?? ''), + 'version' => (string) ($identity->version ?? ''), + 'platform' => (string) ($governance->platform ?? ''), + 'package_type' => (string) ($build->{'package-type'} ?? ''), + 'language' => (string) ($build->language ?? ''), + 'entry_point' => (string) ($build->{'entry-point'} ?? ''), + 'source' => 'local', + ]; + } + } + + // Fall back to API + if ($token !== '') { + $url = "{$apiBase}/repos/{$org}/{$repoName}/manifest"; + $ctx = stream_context_create([ + 'http' => [ + 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", + 'timeout' => 10, + ], + ]); + + $body = @file_get_contents($url, false, $ctx); + + if ($body !== false) { + $data = json_decode($body, true); + if (is_array($data)) { + $data['source'] = 'api'; + return $data; + } + } + } + + return null; + } + + // ================================================================= + // Compare metadata against Joomla manifest + // ================================================================= + + private function compare(array $metadata, array $joomlaXml, string $root): array + { + $results = []; + $xml = $joomlaXml['xml']; + $type = $joomlaXml['type']; + + // 1. Extension type vs package_type + $metaType = $this->normalizePackageType($metadata['package_type'] ?? ''); + $results[] = [ + 'field' => 'package_type', + 'metadata' => $metaType, + 'joomla' => $type, + 'status' => ($metaType === $type) ? 'ok' : 'error', + 'message' => ($metaType === $type) + ? "matches " + : "metadata has \"{$metaType}\" but Joomla manifest has \"{$type}\"", + ]; + + // 2. Element name + $metaName = strtolower($metadata['name'] ?? ''); + $metaElement = $this->deriveElement($metaType, $metaName); + $joomlaElement = $this->extractJoomlaElement($xml, $type); + + $elementMatch = ($metaElement === $joomlaElement); + $results[] = [ + 'field' => 'element', + 'metadata' => $metaElement, + 'joomla' => $joomlaElement, + 'status' => $elementMatch ? 'ok' : 'error', + 'message' => $elementMatch + ? "derived correctly" + : "metadata derives \"{$metaElement}\" but Joomla uses \"{$joomlaElement}\"", + ]; + + // 3. Display name + $metaDisplay = $metadata['display_name'] ?? ''; + $joomlaName = (string) ($xml->name ?? ''); + + if ($metaDisplay !== '' && $joomlaName !== '') { + $displayMatch = ($metaDisplay === $joomlaName); + $results[] = [ + 'field' => 'display_name', + 'metadata' => $metaDisplay, + 'joomla' => $joomlaName, + 'status' => $displayMatch ? 'ok' : 'warn', + 'message' => $displayMatch + ? 'matches' + : "metadata has \"{$metaDisplay}\" but Joomla has \"{$joomlaName}\"", + ]; + } + + // 4. Version + $metaVersion = $metadata['version'] ?? ''; + $joomlaVersion = (string) ($xml->version ?? ''); + + if ($metaVersion !== '' && $joomlaVersion !== '') { + // Strip dev/rc suffixes for comparison (CI bumps these) + $metaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $metaVersion); + $joomlaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $joomlaVersion); + $versionMatch = ($metaBase === $joomlaBase); + + $results[] = [ + 'field' => 'version', + 'metadata' => $metaVersion, + 'joomla' => $joomlaVersion, + 'status' => $versionMatch ? 'ok' : 'warn', + 'message' => $versionMatch + ? 'matches (base version)' + : "metadata has \"{$metaVersion}\" but Joomla has \"{$joomlaVersion}\"", + ]; + } + + // 5. PHP minimum (from composer.json) + $composerPhp = $this->readComposerPhpRequirement($root); + $metaPhp = $metadata['php_minimum'] ?? ''; + + if ($composerPhp !== '' && $metaPhp !== '') { + $phpMatch = ($metaPhp === $composerPhp); + $results[] = [ + 'field' => 'php_minimum', + 'metadata' => $metaPhp, + 'joomla' => $composerPhp . ' (composer.json)', + 'status' => $phpMatch ? 'ok' : 'warn', + 'message' => $phpMatch + ? 'matches composer.json' + : "metadata has \"{$metaPhp}\" but composer.json requires \"{$composerPhp}\"", + ]; + } + + // 6. Description + $metaDesc = $metadata['description'] ?? ''; + $joomlaDesc = (string) ($xml->description ?? ''); + + // Joomla descriptions are often language keys, skip those + if ($metaDesc !== '' && $joomlaDesc !== '' && !str_starts_with($joomlaDesc, 'COM_') && !str_starts_with($joomlaDesc, 'PKG_')) { + $descMatch = ($metaDesc === $joomlaDesc); + $results[] = [ + 'field' => 'description', + 'metadata' => substr($metaDesc, 0, 60) . (strlen($metaDesc) > 60 ? '...' : ''), + 'joomla' => substr($joomlaDesc, 0, 60) . (strlen($joomlaDesc) > 60 ? '...' : ''), + 'status' => $descMatch ? 'ok' : 'info', + 'message' => $descMatch ? 'matches' : 'descriptions differ (informational)', + ]; + } + + return $results; + } + + // ================================================================= + // Helpers + // ================================================================= + + /** + * Normalize package_type — map MokoGitea types to Joomla types. + */ + private function normalizePackageType(string $type): string + { + return match (strtolower($type)) { + 'joomla-extension' => 'package', // legacy mapping + default => strtolower($type), + }; + } + + /** + * Derive the Joomla element name from type + name. + * Replicates MokoGitea's cleanJoomlaElement() + prefix logic. + */ + private function deriveElement(string $type, string $name): string + { + // Clean: lowercase, strip non-alphanumeric except . _ - + $clean = strtolower($name); + $clean = preg_replace('/[^a-z0-9._-]/', '', $clean); + + $prefix = self::JOOMLA_PREFIX[$type] ?? ''; + + return $prefix . $clean; + } + + /** + * Extract the element name from a Joomla manifest XML. + * Follows the same logic as Joomla's InstallerAdapter::getElement(). + */ + private function extractJoomlaElement(\SimpleXMLElement $xml, string $type): string + { + switch ($type) { + case 'package': + $packagename = (string) ($xml->packagename ?? ''); + if ($packagename !== '') { + return 'pkg_' . strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $packagename)); + } + break; + + case 'component': + $element = (string) ($xml->element ?? ''); + if ($element !== '') { + $element = strtolower($element); + return str_starts_with($element, 'com_') ? $element : 'com_' . $element; + } + $name = (string) ($xml->name ?? ''); + $name = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name)); + return str_starts_with($name, 'com_') ? $name : 'com_' . $name; + + case 'module': + $element = (string) ($xml->element ?? ''); + if ($element !== '') { + return strtolower($element); + } + break; + + case 'plugin': + // Plugins derive element from the file attribute + if (isset($xml->files)) { + foreach ($xml->files->children() as $file) { + $plugin = (string) ($file->attributes()->plugin ?? ''); + if ($plugin !== '') { + return strtolower($plugin); + } + } + } + break; + + case 'library': + $libname = (string) ($xml->libraryname ?? ''); + if ($libname !== '') { + return strtolower($libname); + } + break; + } + + // Fallback: use tag + $name = (string) ($xml->name ?? ''); + return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name)); + } + + /** + * Read PHP version requirement from composer.json. + */ + private function readComposerPhpRequirement(string $root): string + { + $composerFile = "{$root}/composer.json"; + + if (!is_file($composerFile)) { + return ''; + } + + $data = json_decode(file_get_contents($composerFile), true); + + if (!is_array($data)) { + return ''; + } + + $phpReq = $data['require']['php'] ?? ''; + + // Extract version number from constraint like ">=8.1" + if (preg_match('/(\d+\.\d+)/', $phpReq, $m)) { + return $m[1]; + } + + return ''; + } + + private function detectRepoName(string $root): string + { + $gitConfig = "{$root}/.git/config"; + + if (!file_exists($gitConfig)) { + return basename($root); + } + + $content = file_get_contents($gitConfig); + + if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) { + return $m[1]; + } + + return basename($root); + } + + // ================================================================= + // Output + // ================================================================= + + private function printResults(string $repoName, array $results): void + { + $errors = count(array_filter($results, fn($r) => $r['status'] === 'error')); + $warns = count(array_filter($results, fn($r) => $r['status'] === 'warn')); + $oks = count(array_filter($results, fn($r) => $r['status'] === 'ok')); + + $this->log('INFO', "Validating {$repoName} Joomla metadata...\n"); + + foreach ($results as $r) { + $icon = match ($r['status']) { + 'ok' => "\xE2\x9C\x93", // ✓ + 'error' => "\xE2\x9C\x97", // ✗ + 'warn' => "\xE2\x9A\xA0", // ⚠ + default => "\xE2\x84\xB9", // ℹ + }; + + $line = sprintf( + " %s %-16s %s", + $icon, + $r['field'], + $r['message'] + ); + + $this->log( + match ($r['status']) { + 'error' => 'ERROR', + 'warn' => 'WARN', + 'ok' => 'OK', + default => 'INFO', + }, + $line + ); + } + + echo "\n"; + + if ($errors > 0) { + $this->log('ERROR', "{$errors} error(s) — update delivery will fail"); + } elseif ($warns > 0) { + $this->log('WARN', "All critical checks passed, {$warns} warning(s)"); + } else { + $this->log('OK', "All {$oks} checks passed"); + } + } +} + +$app = new JoomlaMetadataValidateCli(); +exit($app->execute());