#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * DEFGROUP: mokocli.Validate * INGROUP: mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /validate/check_repo_health.php * BRIEF: Repository health checker — validates against current Moko standards (wiki-first, no docs/) * * Categories: * Required Files (40) — README, LICENSE, CHANGELOG, CONTRIBUTING, SECURITY, CLAUDE.md, .gitignore, Makefile * Manifest & Config (20) — .mokocli, workflows, README quality, CODE_OF_CONDUCT, .gitignore content, CLAUDE.md quality * Documentation (15) — wiki-first: docs/ must NOT exist, CHANGELOG [Unreleased] * License Headers (15) — Copyright, SPDX, FILE INFORMATION in source files * Disallowed (10) — TODO.md, vendor/, node_modules/, .claude/, wiki/, .mcp.json, renovate.json, profile.ps1 * Workflows (15) — repo-health, sync-roadmap-wiki, CI/deploy * Security (20) — SECURITY.md, scanning, no renovate.json, no secrets * Rulesets (15) — main protected, dev branch, rulesets * Deployment (10) — deploy workflow, build system * * Note: This file uses curl_init for HTTP requests to the Gitea API. * No shell commands or child processes are invoked. */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoCli\{AuditLogger, CliFramework, MetricsCollector, PluginFactory}; /** * Repository Health Checker * * Validates repository structure, standards compliance, and configuration * against mokocli definitions. Produces a health score and report. * * @since 04.00.00 */ class RepoHealthChecker extends CliFramework { private AuditLogger $logger; private MetricsCollector $metrics; private string $apiBaseUrl = 'https://git.mokoconsulting.tech/api/v1'; private array $results = [ 'categories' => [], 'checks' => [], 'score' => 0, 'max_score' => 0, 'percentage' => 0.0, 'level' => 'unknown', ]; protected function configure(): void { $this->setDescription('Check repository health against Moko standards'); $this->addArgument('--path', 'Repository path', '.'); $this->addArgument('--threshold', 'Minimum health %', '70'); $this->addArgument('--json', 'JSON output', false); $this->addArgument('--create-issue', 'Create Gitea issue', false); $this->addArgument('--repo', 'owner/repo', ''); } protected function initialize(): void { parent::initialize(); $this->logger = new AuditLogger('repo_health_checker'); $this->metrics = new MetricsCollector(); $config = \MokoCli\Config::load(); $this->apiBaseUrl = rtrim($config->getString('gitea.url', 'https://git.mokoconsulting.tech'), '/') . '/api/v1'; } protected function run(): int { $path = $this->getArgument('--path'); $threshold = (float)$this->getArgument('--threshold'); $repo = $this->getArgument('--repo'); $this->section('Required Files'); $this->checkRequiredFiles($path); $this->section('Manifest & Config'); $this->checkManifest($path); $this->section('Documentation'); $this->checkDocumentation($path); $this->section('License Headers'); $this->checkLicenseHeaders($path); $this->section('Disallowed Items'); $this->checkDisallowed($path); $this->section('Workflows'); $this->checkWorkflows($path); $this->section('Security'); $this->checkSecurity($path); $this->section('Rulesets'); $this->checkRulesets($repo); $this->section('Deployment'); $this->checkDeployment($path); $this->calculateScore(); if ($this->getArgument('--json')) { echo json_encode($this->results, JSON_PRETTY_PRINT) . PHP_EOL; } else { $this->displayResults(); } if ($this->getArgument('--create-issue') && $repo) { $this->createHealthIssue($repo); } return $this->results['percentage'] >= $threshold ? 0 : 1; } // ── Required Files (40 pts) ────────────────────────────────────── private function checkRequiredFiles(string $p): void { $cat = 'required_files'; $this->initCategory($cat, 'Required Files', 40); foreach ( [ 'README.md' => 8, 'LICENSE' => 8, 'CHANGELOG.md' => 5, 'CONTRIBUTING.md' => 4, 'SECURITY.md' => 4, 'CLAUDE.md' => 5, '.gitignore' => 3, 'Makefile' => 3, ] as $file => $pts ) { $this->addCheck($cat, "{$file} exists", file_exists("{$p}/{$file}"), $pts); } // Negative checks — files that should NOT be committed foreach (['.mcp.json', 'TODO.md', 'renovate.json'] as $bad) { if (file_exists("{$p}/{$bad}")) { $this->addCheck($cat, "{$bad} should not be committed", false, 0); } } if (is_dir("{$p}/.claude")) { $this->addCheck($cat, '.claude/ should not be committed', false, 0); } if (is_dir("{$p}/wiki")) { $this->addCheck($cat, 'wiki/ should not be committed', false, 0); } } // ── Manifest & Config (15 pts) ─────────────────────────────────── private function checkManifest(string $p): void { $cat = 'manifest'; $this->initCategory($cat, 'Manifest & Config', 15); $this->addCheck( $cat, '.mokogitea/.mokocli manifest', file_exists("{$p}/.gitea/.mokocli"), 5 ); $this->addCheck( $cat, 'Workflows directory', is_dir("{$p}/.gitea/workflows") || is_dir("{$p}/.github/workflows"), 5 ); $this->addCheck( $cat, 'README >500 chars', file_exists("{$p}/README.md") && strlen(file_get_contents("{$p}/README.md")) > 500, 3 ); $this->addCheck( $cat, 'CODE_OF_CONDUCT.md', file_exists("{$p}/CODE_OF_CONDUCT.md"), 2 ); // .gitignore must contain key exclusions $gitignoreOk = false; if (file_exists("{$p}/.gitignore")) { $gi = file_get_contents("{$p}/.gitignore"); $gitignoreOk = str_contains($gi, '.claude/') && str_contains($gi, 'TODO.md') && str_contains($gi, '*.min.css') && str_contains($gi, '*.min.js') && str_contains($gi, 'wiki/'); } $this->addCheck( $cat, '.gitignore has .claude/, TODO.md, *.min.css/js, wiki/', $gitignoreOk, 3 ); // CLAUDE.md should have project overview $claudeOk = false; if (file_exists("{$p}/CLAUDE.md")) { $claude = file_get_contents("{$p}/CLAUDE.md"); $claudeOk = strlen($claude) > 200 && str_contains($claude, 'mokocli'); } $this->addCheck( $cat, 'CLAUDE.md has project context + mokocli ref', $claudeOk, 2 ); } // ── Documentation: Wiki-First (15 pts) ─────────────────────────── private function checkDocumentation(string $p): void { $cat = 'documentation'; $this->initCategory($cat, 'Documentation (Wiki-First)', 15); $this->addCheck( $cat, 'No docs/ directory (wiki-first)', !is_dir("{$p}/docs"), 10 ); // CHANGELOG must have [Unreleased] section for release workflow $hasUnreleased = false; if (file_exists("{$p}/CHANGELOG.md")) { $cl = file_get_contents("{$p}/CHANGELOG.md"); $hasUnreleased = (bool)preg_match('/##\s*\[?Unreleased/i', $cl); } $this->addCheck( $cat, 'CHANGELOG has [Unreleased] section', $hasUnreleased, 5 ); } // ── License Headers (15 pts) ──────────────────────────────────── private function checkLicenseHeaders(string $p): void { $cat = 'license_headers'; $this->initCategory($cat, 'License Headers', 15); // Collect source files by scanning directories $extensions = ['php', 'ts', 'css', 'yml', 'yaml']; $files = []; $dirs = [$p]; while ($dirs) { $dir = array_pop($dirs); $base = basename($dir); if (in_array($base, ['vendor', 'node_modules', 'dist', '.git'], true)) { continue; } $items = @scandir($dir); if (!$items) { continue; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $full = "{$dir}/{$item}"; if (is_dir($full)) { $dirs[] = $full; continue; } $ext = pathinfo($item, PATHINFO_EXTENSION); if (in_array($ext, $extensions, true)) { $files[] = $full; } } } $total = count($files); $withCopyright = 0; $withSpdx = 0; $withFileInfo = 0; foreach ($files as $fullPath) { $header = ''; $handle = @fopen($fullPath, 'r'); if (!$handle) { continue; } for ($j = 0; $j < 20 && !feof($handle); $j++) { $header .= (string) fgets($handle); } fclose($handle); if (str_contains($header, 'Copyright')) { $withCopyright++; } if (str_contains($header, 'SPDX-License-Identifier:')) { $withSpdx++; } if ( str_contains($header, 'FILE INFORMATION') || str_contains($header, 'DEFGROUP:') || str_contains($header, 'BRIEF:') ) { $withFileInfo++; } } if ($total === 0) { $this->addCheck($cat, 'No source files found', true, 15); return; } $copyrightPct = $withCopyright / $total * 100; $spdxPct = $withSpdx / $total * 100; $fileInfoPct = $withFileInfo / $total * 100; $this->addCheck( $cat, sprintf('Copyright headers (%.0f%% of %d files)', $copyrightPct, $total), $copyrightPct >= 80, 5 ); $this->addCheck( $cat, sprintf('SPDX-License-Identifier (%.0f%%)', $spdxPct), $spdxPct >= 80, 5 ); $this->addCheck( $cat, sprintf('FILE INFORMATION block (%.0f%%)', $fileInfoPct), $fileInfoPct >= 70, 5 ); } // ── Disallowed Items (10 pts) ──────────────────────────────────── private function checkDisallowed(string $p): void { $cat = 'disallowed'; $this->initCategory($cat, 'Disallowed Items', 10); $this->addCheck( $cat, 'No TODO.md (use issues)', !file_exists("{$p}/TODO.md"), 2 ); $this->addCheck( $cat, 'No vendor/ committed', !is_dir("{$p}/vendor") || file_exists("{$p}/vendor/.gitkeep"), 2 ); $this->addCheck( $cat, 'No node_modules/', !is_dir("{$p}/node_modules"), 2 ); $this->addCheck( $cat, 'No .claude/ committed', !is_dir("{$p}/.claude"), 1 ); $this->addCheck( $cat, 'No .mcp.json committed', !file_exists("{$p}/.mcp.json"), 1 ); $this->addCheck( $cat, 'No renovate.json', !file_exists("{$p}/renovate.json"), 1 ); $this->addCheck( $cat, 'No profile.ps1', !file_exists("{$p}/profile.ps1"), 1 ); } // ── Workflows (15 pts) ─────────────────────────────────────────── private function checkWorkflows(string $p): void { $cat = 'workflows'; $this->initCategory($cat, 'Workflows', 15); $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; $exists = is_dir($wf); $this->addCheck($cat, 'Workflows directory', $exists, 5); $this->addCheck( $cat, 'repo-health.yml', $exists && file_exists("{$wf}/repo-health.yml"), 3 ); $this->addCheck( $cat, 'sync-roadmap-wiki.yml', $exists && file_exists("{$wf}/sync-roadmap-wiki.yml"), 3 ); $this->addCheck( $cat, 'CI/deploy workflow', $exists && (!empty(glob("{$wf}/ci*.yml")) || !empty(glob("{$wf}/deploy*.yml")) || !empty(glob("{$wf}/build*.yml"))), 4 ); } // ── Security (20 pts) ──────────────────────────────────────────── private function checkSecurity(string $p): void { $cat = 'security'; $this->initCategory($cat, 'Security', 20); $this->addCheck($cat, 'SECURITY.md', file_exists("{$p}/SECURITY.md"), 5); $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; $hasScan = is_dir($wf) && (!empty(glob("{$wf}/*gitleaks*.yml")) || !empty(glob("{$wf}/*trivy*.yml")) || !empty(glob("{$wf}/*security*.yml"))); $this->addCheck($cat, 'Security scanning workflow', $hasScan, 5); $this->addCheck( $cat, 'No renovate.json (removed from ecosystem)', !file_exists("{$p}/renovate.json"), 5 ); $secrets = false; foreach (['.env', '.env.local', 'credentials.json'] as $s) { if (file_exists("{$p}/{$s}")) { $secrets = true; break; } } $this->addCheck($cat, 'No secret files committed', !$secrets, 5); } // ── Rulesets (15 pts) ──────────────────────────────────────────── private function checkRulesets(string $repo): void { $cat = 'rulesets'; $this->initCategory($cat, 'Branch Rulesets', 15); if (!$repo) { $this->addCheck($cat, 'Main branch protected', false, 5); $this->addCheck($cat, 'Dev branch exists', false, 5); $this->addCheck($cat, 'Branch rulesets configured', false, 5); return; } $token = getenv('GH_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; if (!$token) { $this->addCheck($cat, 'Main branch protected', false, 5); $this->addCheck($cat, 'Dev branch exists', false, 5); $this->addCheck($cat, 'Branch rulesets configured', false, 5); return; } // Gitea uses /branch_protections endpoint (not /branches/main/protection) $protections = $this->apiFetch("repos/{$repo}/branch_protections", $token); $mainProtected = false; foreach ($protections as $bp) { if (($bp['branch_name'] ?? '') === 'main') { $mainProtected = true; break; } } $this->addCheck($cat, 'Main branch protected', $mainProtected, 5); $this->addCheck( $cat, 'Dev branch exists', $this->apiCheck("repos/{$repo}/branches/dev", $token), 5 ); $this->addCheck($cat, 'Branch protections configured', count($protections) > 0, 5); } // ── Deployment (10 pts) ────────────────────────────────────────── private function checkDeployment(string $p): void { $cat = 'deployment'; $this->initCategory($cat, 'Deployment', 10); $wf = is_dir("{$p}/.gitea/workflows") ? "{$p}/.gitea/workflows" : "{$p}/.github/workflows"; $this->addCheck( $cat, 'Deploy workflow', is_dir($wf) && !empty(glob("{$wf}/deploy*.yml")), 5 ); $this->addCheck( $cat, 'Build system', file_exists("{$p}/Makefile") || file_exists("{$p}/package.json") || file_exists("{$p}/composer.json"), 5 ); } // ── Helpers ────────────────────────────────────────────────────── private function initCategory(string $key, string $name, int $maxPts): void { $this->results['categories'][$key] = [ 'name' => $name, 'max_points' => $maxPts, 'earned_points' => 0, 'checks_passed' => 0, 'checks_failed' => 0, ]; } private function addCheck(string $cat, string $name, bool $passed, int $pts): void { $this->results['checks'][] = ['category' => $cat, 'name' => $name, 'passed' => $passed, 'points' => $pts]; if ($passed) { $this->results['categories'][$cat]['earned_points'] += $pts; $this->results['categories'][$cat]['checks_passed']++; } else { $this->results['categories'][$cat]['checks_failed']++; } } private function calculateScore(): void { $earned = $max = 0; foreach ($this->results['categories'] as $c) { $earned += $c['earned_points']; $max += $c['max_points']; } $this->results['score'] = $earned; $this->results['max_score'] = $max; $this->results['percentage'] = $max > 0 ? ($earned / $max * 100) : 0; $pct = $this->results['percentage']; $this->results['level'] = match (true) { $pct >= 90 => 'excellent', $pct >= 80 => 'good', $pct >= 70 => 'fair', $pct >= 60 => 'poor', default => 'critical', }; } private function displayResults(): void { $this->section('Results'); foreach ($this->results['checks'] as $c) { $this->status($c['passed'], $c['name'], $c['passed'] ? '' : "{$c['points']} pts"); } $p = count(array_filter($this->results['checks'], fn($c) => $c['passed'])); $f = count(array_filter($this->results['checks'], fn($c) => !$c['passed'])); $this->printSummary($p, $f, $this->elapsed()); $this->log(sprintf( "Score: %d/%d (%.1f%%) — %s", $this->results['score'], $this->results['max_score'], $this->results['percentage'], strtoupper($this->results['level']) )); } private function apiCheck(string $path, string $token): bool { $ch = curl_init("{$this->apiBaseUrl}/{$path}"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'User-Agent: mokocli'], ]); curl_exec($ch); $s = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $s === 200; } private function apiFetch(string $path, string $token): array { $ch = curl_init("{$this->apiBaseUrl}/{$path}"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'User-Agent: mokocli'], ]); $body = (string)curl_exec($ch); $s = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $s === 200 ? (json_decode($body, true) ?? []) : []; } private function createHealthIssue(string $repo): void { $pct = round($this->results['percentage'], 1); $title = "Repository Health: {$this->results['level']} ({$pct}%)"; $body = "## Health Check\n\nScore: {$this->results['score']}/{$this->results['max_score']} ({$pct}%)\n\n"; $body .= "| Category | Score |\n|---|---|\n"; foreach ($this->results['categories'] as $c) { $body .= "| {$c['name']} | {$c['earned_points']}/{$c['max_points']} |\n"; } $failed = array_filter($this->results['checks'], fn($c) => !$c['passed']); if ($failed) { $body .= "\n### Failed\n"; foreach ($failed as $c) { $body .= "- {$c['name']} ({$c['points']}pts)\n"; } } $token = getenv('GH_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; if (!$token) { return; } $ch = curl_init("{$this->apiBaseUrl}/repos/{$repo}/issues"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['title' => $title, 'body' => $body]), CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Content-Type: application/json', 'User-Agent: mokocli']]); curl_exec($ch); curl_close($ch); } } $app = new RepoHealthChecker(); exit($app->execute());