#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Validate * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /validate/check_changelog.php * BRIEF: Validates CHANGELOG.md structure and format */ declare(strict_types=1); require_once __DIR__ . '/../../vendor/autoload.php'; use MokoEnterprise\CliFramework; /** * Validates that CHANGELOG.md exists (in root, src/, or docs/) and follows Keep a Changelog format. * * By default passes as long as any ## [...] heading is present. * Use --strict to also require an ## [Unreleased] section. */ class CheckChangelog extends CliFramework { /** Directories searched for CHANGELOG.md, relative to --path (case-insensitive match). */ private const SEARCH_DIRS = ['', 'src', 'docs']; /** * Configure available arguments. */ protected function configure(): void { $this->setDescription('Validates CHANGELOG.md structure and format'); $this->addArgument('--path', 'Repository path to check', '.'); $this->addArgument('--strict', 'Also require an [Unreleased] section', false); } /** * Validate CHANGELOG.md. * * @return int Exit code: 0 on pass, 1 on failure. */ protected function run(): int { $path = rtrim($this->getArgument('--path'), '/\\'); $strict = (bool) $this->getArgument('--strict'); $this->section('Checking CHANGELOG.md'); $found = $this->findChangelog($path); if ($found === null) { $this->status(false, 'CHANGELOG.md found (checked root, src/, docs/)'); $this->printSummary(0, 1, $this->elapsed()); return 1; } $rel = ltrim(str_replace(str_replace('\\', '/', $path), '', str_replace('\\', '/', $found)), '/'); $this->status(true, "CHANGELOG.md found: {$rel}"); // Error if CHANGELOG exists at root AND in a subdirectory simultaneously if ($rel !== 'CHANGELOG.md' && is_file($path . '/CHANGELOG.md')) { $this->status(false, 'CHANGELOG.md duplicate: exists at root AND ' . dirname($rel)); $this->printSummary(0, 1, $this->elapsed()); return 1; } $content = (string) file_get_contents($found); $passed = 1; $failed = 0; // Require Keep a Changelog format (any versioned heading) if (preg_match('/^## \[/m', $content)) { $this->status(true, 'Keep a Changelog format (## [...])'); $passed++; } else { $this->status(false, 'Keep a Changelog format (## [...]) — no versioned headings found'); $failed++; } // --strict: also require an [Unreleased] section if ($strict) { if (preg_match('/^## \[Unreleased\]/mi', $content)) { $this->status(true, '[Unreleased] section present'); $passed++; } else { $this->status(false, '[Unreleased] section missing (required by --strict)'); $failed++; } } $this->printSummary($passed, $failed, $this->elapsed()); return $failed > 0 ? 1 : 0; } /** * Find CHANGELOG.md case-insensitively in root, src/, or docs/. * * @param string $repoPath Absolute path to the repository root. * @return string|null Absolute path to the found file, or null if not found. */ private function findChangelog(string $repoPath): ?string { foreach (self::SEARCH_DIRS as $sub) { $dir = $sub === '' ? $repoPath : $repoPath . '/' . $sub; if (!is_dir($dir)) { continue; } $entries = @scandir($dir); if ($entries === false) { continue; } foreach ($entries as $entry) { if (strcasecmp($entry, 'CHANGELOG.md') === 0 && is_file($dir . '/' . $entry)) { return $dir . '/' . $entry; } } } return null; } } $script = new CheckChangelog('check_changelog', 'Validates CHANGELOG.md structure and format'); exit($script->execute());