Public Access
feat: manifest_integrity.php — org-wide manifest validation #252
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* 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.00
|
||||
* 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());
|
||||
Reference in New Issue
Block a user