#!/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/manifest_integrity.php * VERSION: 09.26.01 * BRIEF: Cross-check manifest API fields against repo contents across the org */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class ManifestIntegrityCli extends CliFramework { protected function configure(): void { $this->setDescription('Cross-check manifest fields against repo contents across the org'); $this->addArgument('--path', 'Single repo path (local mode)', ''); $this->addArgument('--org', 'Gitea org (bulk mode)', 'MokoConsulting'); $this->addArgument('--repo', 'Single repo name (remote mode)', ''); $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', ''); $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1'); $this->addArgument('--fix', 'Push fixes for detected drift', false); $this->addArgument('--json', 'Output as JSON', false); $this->addArgument('--quiet', 'Only show repos with issues', false); } protected function run(): int { $path = $this->getArgument('--path'); $org = $this->getArgument('--org'); $repoName = $this->getArgument('--repo'); $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: ''; $apiBase = rtrim($this->getArgument('--api-base'), '/'); $fixMode = (bool) $this->getArgument('--fix'); $jsonMode = (bool) $this->getArgument('--json'); $quiet = (bool) $this->getArgument('--quiet'); if ($token === '') { $this->log('ERROR', 'API token required (use --token or GITEA_TOKEN env)'); return 1; } // ── Mode selection ────────────────────────────────────────── if ($path !== '') { // Local mode: detect from source + compare to API return $this->checkLocal($path, $org, $repoName, $token, $apiBase, $fixMode, $jsonMode); } if ($repoName !== '') { // Single remote repo return $this->checkRemoteRepo($org, $repoName, $token, $apiBase, $fixMode, $jsonMode); } // Bulk mode: all repos in org return $this->checkOrg($org, $token, $apiBase, $fixMode, $jsonMode, $quiet); } // ===================================================================== // Local mode — detect from source, compare to API // ===================================================================== private function checkLocal(string $path, string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int { $root = realpath($path) ?: $path; if (!is_dir($root)) { $this->log('ERROR', "Path does not exist: {$path}"); return 1; } if ($repoName === '') { $repoName = $this->detectRepoName($root); } // Run manifest_detect logic $detected = $this->runDetect($root, $repoName); $current = $this->fetchManifest($apiBase, $org, $repoName, $token); if ($current === null) { $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}"); return 1; } $issues = $this->validate($current, $detected, $repoName); if ($json) { echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } else { $this->printIssues($repoName, $issues); } if ($fix && !empty($issues)) { return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues); } return empty($issues) ? 0 : 1; } // ===================================================================== // Remote single repo mode — fetch source files via API // ===================================================================== private function checkRemoteRepo(string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int { $current = $this->fetchManifest($apiBase, $org, $repoName, $token); if ($current === null) { $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}"); return 1; } $issues = $this->validateManifestOnly($current, $repoName); if ($json) { echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } else { $this->printIssues($repoName, $issues); } if ($fix && !empty($issues)) { return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues); } return empty($issues) ? 0 : 1; } // ===================================================================== // Bulk org mode — check all repos // ===================================================================== private function checkOrg(string $org, string $token, string $apiBase, bool $fix, bool $json, bool $quiet): int { $repos = $this->fetchOrgRepos($apiBase, $org, $token); if ($repos === null) { $this->log('ERROR', "Failed to fetch repos for org {$org}"); return 1; } $this->log('INFO', "Manifest Integrity Check — {$org} (" . count($repos) . " repos)"); $allResults = []; $totalIssues = 0; $reposWithIssues = 0; foreach ($repos as $repo) { $name = $repo['name']; $manifest = $this->fetchManifest($apiBase, $org, $name, $token); if ($manifest === null) { if (!$quiet) { $this->log('WARN', "{$name}: no manifest"); } continue; } $issues = $this->validateManifestOnly($manifest, $name); if (!empty($issues)) { $reposWithIssues++; $totalIssues += count($issues); if ($json) { $allResults[] = ['repo' => $name, 'issues' => $issues]; } else { $this->printIssues($name, $issues); } if ($fix) { $this->applyFixes($apiBase, $org, $name, $token, $manifest, $issues); } } elseif (!$quiet && !$json) { $this->log('OK', "{$name}: clean"); } } if ($json) { echo json_encode($allResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } else { echo "\n"; $level = $reposWithIssues > 0 ? 'WARN' : 'OK'; $this->log($level, sprintf( 'Summary: %d repos checked, %d with issues (%d total issues)', count($repos), $reposWithIssues, $totalIssues )); } return $reposWithIssues > 0 ? 1 : 0; } // ===================================================================== // Validation rules // ===================================================================== /** * Full validation: compare API manifest against locally-detected fields. */ private function validate(array $current, array $detected, string $repoName): array { $issues = []; // Required fields that should never be empty $required = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point']; foreach ($required as $field) { if (empty($current[$field])) { $fix = $detected[$field] ?? null; $issues[] = [ 'field' => $field, 'severity' => 'error', 'message' => 'Missing required field', 'current' => '', 'fix' => $fix, ]; } } // Drift detection: detected value differs from API foreach ($detected as $field => $detectedValue) { $currentValue = $current[$field] ?? ''; if ($detectedValue !== '' && $currentValue !== '' && $detectedValue !== $currentValue) { // Version drift is expected on dev branches (suffix) if ($field === 'version' && strpos($detectedValue, $currentValue) === 0) { continue; // e.g., detected "02.34.50-dev" vs API "02.34.50" } if ($field === 'version' && strpos($currentValue, $detectedValue) === 0) { continue; } $issues[] = [ 'field' => $field, 'severity' => 'warn', 'message' => 'Drift: source differs from manifest', 'current' => $currentValue, 'fix' => $detectedValue, ]; } } // Platform-specific structure validation $platform = $current['platform'] ?? ''; $issues = array_merge($issues, $this->validatePlatformStructure($platform, $current, $repoName)); return $issues; } /** * API-only validation: check manifest fields for completeness and consistency * without access to source files. */ private function validateManifestOnly(array $manifest, string $repoName): array { $issues = []; // Required fields $required = ['platform', 'name', 'version', 'language']; foreach ($required as $field) { if (empty($manifest[$field])) { $issues[] = [ 'field' => $field, 'severity' => 'error', 'message' => 'Missing required field', 'current' => '', 'fix' => null, ]; } } // Recommended fields $recommended = ['package_type', 'entry_point', 'license_spdx', 'description']; foreach ($recommended as $field) { if (empty($manifest[$field])) { $issues[] = [ 'field' => $field, 'severity' => 'info', 'message' => 'Recommended field is empty', 'current' => '', 'fix' => null, ]; } } // Platform-specific checks $platform = $manifest['platform'] ?? ''; $issues = array_merge($issues, $this->validatePlatformStructure($platform, $manifest, $repoName)); return $issues; } /** * Platform-specific validation rules. */ private function validatePlatformStructure(string $platform, array $manifest, string $repoName): array { $issues = []; switch ($platform) { case 'joomla': case 'waas-component': // Joomla repos must have element_name if (empty($manifest['element_name'])) { $issues[] = [ 'field' => 'element_name', 'severity' => 'error', 'message' => 'Joomla repos require element_name', 'current' => '', 'fix' => null, ]; } // Language should be PHP if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') { $issues[] = [ 'field' => 'language', 'severity' => 'warn', 'message' => 'Joomla repos should have language=PHP', 'current' => $manifest['language'], 'fix' => 'PHP', ]; } break; case 'dolibarr': case 'crm-module': if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') { $issues[] = [ 'field' => 'language', 'severity' => 'warn', 'message' => 'Dolibarr repos should have language=PHP', 'current' => $manifest['language'], 'fix' => 'PHP', ]; } break; case 'go': if (!empty($manifest['language']) && $manifest['language'] !== 'Go') { $issues[] = [ 'field' => 'language', 'severity' => 'warn', 'message' => 'Go repos should have language=Go', 'current' => $manifest['language'], 'fix' => 'Go', ]; } break; case 'mcp': if (!empty($manifest['language']) && !in_array($manifest['language'], ['TypeScript', 'JavaScript'], true)) { $issues[] = [ 'field' => 'language', 'severity' => 'warn', 'message' => 'MCP repos should have language=TypeScript or JavaScript', 'current' => $manifest['language'], 'fix' => null, ]; } break; } // Version format check: should be XX.YY.ZZ $version = $manifest['version'] ?? ''; if ($version !== '' && !preg_match('/^\d{2}\.\d{2}\.\d{2}/', $version)) { // Allow semver for node/go repos if (!in_array($platform, ['mcp', 'node', 'go'], true)) { $issues[] = [ 'field' => 'version', 'severity' => 'info', 'message' => 'Version does not match XX.YY.ZZ format', 'current' => $version, 'fix' => null, ]; } } return $issues; } // ===================================================================== // Output // ===================================================================== private function printIssues(string $repoName, array $issues): void { if (empty($issues)) { return; } $errors = count(array_filter($issues, fn($i) => $i['severity'] === 'error')); $warns = count(array_filter($issues, fn($i) => $i['severity'] === 'warn')); $infos = count($issues) - $errors - $warns; echo "\n"; $summary = []; if ($errors > 0) $summary[] = "{$errors} error(s)"; if ($warns > 0) $summary[] = "{$warns} warning(s)"; if ($infos > 0) $summary[] = "{$infos} info"; $this->log($errors > 0 ? 'ERROR' : 'WARN', "{$repoName} — " . implode(', ', $summary)); foreach ($issues as $issue) { $icon = match ($issue['severity']) { 'error' => 'ERROR', 'warn' => 'WARN', default => 'INFO', }; $msg = sprintf(' %-18s %s', $issue['field'], $issue['message']); if ($issue['current'] !== '') { $msg .= " (current: {$issue['current']})"; } if ($issue['fix'] !== null) { $msg .= " → fix: {$issue['fix']}"; } $this->log($icon, $msg); } } // ===================================================================== // Fix application // ===================================================================== private function applyFixes(string $apiBase, string $org, string $repo, string $token, array $current, array $issues): int { $fixes = []; foreach ($issues as $issue) { if ($issue['fix'] !== null && $issue['fix'] !== '') { $fixes[$issue['field']] = $issue['fix']; } } if (empty($fixes)) { $this->log('INFO', "{$repo}: no auto-fixable issues"); return 0; } $merged = array_merge($current, $fixes); $url = "{$apiBase}/repos/{$org}/{$repo}/manifest"; $payload = json_encode($merged); $ctx = stream_context_create([ 'http' => [ 'method' => 'PUT', 'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n", 'content' => $payload, 'timeout' => 10, ], ]); $body = @file_get_contents($url, false, $ctx); if ($body === false) { $this->log('ERROR', "{$repo}: failed to push fixes"); return 1; } $this->log('OK', "{$repo}: fixed " . implode(', ', array_keys($fixes))); return 0; } // ===================================================================== // API helpers // ===================================================================== private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array { $url = "{$apiBase}/repos/{$org}/{$repo}/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) return null; $data = json_decode($body, true); return is_array($data) ? $data : null; } private function fetchOrgRepos(string $apiBase, string $org, string $token): ?array { $allRepos = []; $page = 1; $limit = 50; while (true) { $url = "{$apiBase}/orgs/{$org}/repos?page={$page}&limit={$limit}"; $ctx = stream_context_create([ 'http' => [ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", 'timeout' => 15, ], ]); $body = @file_get_contents($url, false, $ctx); if ($body === false) return null; $repos = json_decode($body, true); if (!is_array($repos) || empty($repos)) break; $allRepos = array_merge($allRepos, $repos); if (count($repos) < $limit) break; $page++; } // Filter out archived and empty repos return array_filter($allRepos, fn($r) => !($r['archived'] ?? false) && !($r['empty'] ?? false)); } // ===================================================================== // Detection (delegates to manifest_detect logic) // ===================================================================== private function runDetect(string $root, string $repoName): array { $script = __DIR__ . '/manifest_detect.php'; $redirect = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null'; $cmd = sprintf( 'php %s --path %s --repo %s --json --quiet %s', escapeshellarg($script), escapeshellarg($root), escapeshellarg($repoName), $redirect ); $output = shell_exec($cmd) ?? ''; // Extract JSON object from output (skip banner/log lines) if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $output, $m)) { $data = json_decode($m[0], true); if (is_array($data)) { return $data; } } 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); } } $app = new ManifestIntegrityCli(); exit($app->execute());