Files
Jonathan Miller 3f3b1f79a0
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 45s
chore: add PHPDoc to Priority 1 Enterprise classes
Added @since, @param, @see tags to:
- CliFramework: class-level @since, 2 undocumented methods
- GitHubAdapter: class @since/@see, constructor @param, property docs
- MokoGiteaAdapter: class @since/@see, constructor @param, property docs
- ApiClient: class @since

Wiki: created Coding-Standards page with full PHPDoc standard,
PHPCS exclusion rationale, and file structure patterns.

Partial progress on #137

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:26:07 -05:00

1462 lines
48 KiB
PHP

<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Enterprise.CLI
* INGROUP: MokoStandards.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/CliFramework.php
* BRIEF: CLI base classes — CliFramework (current) and CLIApp (legacy)
* NOTE: All new scripts must extend CliFramework, not CLIApp.
* CLIApp remains for backward-compatibility with existing scripts.
*/
declare(strict_types=1);
namespace MokoEnterprise;
use DateTime;
use DateTimeZone;
use Exception;
/**
* Base class for CLI applications with common functionality
*/
abstract class CLIApp
{
private const VERSION = '04.06.00';
protected string $name;
protected string $description;
protected string $version;
protected array $options = [];
protected array $arguments = [];
protected bool $verbose = false;
protected bool $quiet = false;
protected bool $dryRun = false;
protected bool $jsonOutput = false;
// Enterprise features
protected ?MetricsCollector $metrics = null;
protected ?object $auditLogger = null;
public function __construct(string $name, string $description = '', string $version = self::VERSION)
{
$this->name = $name;
$this->description = $description ?: "{$name} - MokoStandards CLI Tool";
$this->version = $version;
}
/**
* Setup script-specific arguments
*
* Return an associative array where keys are option specs and values are descriptions.
* Option spec format: 'name:' for required value, 'name::' for optional value, 'name' for flag
*
* @return array<string, string> Option specifications and descriptions
*/
abstract protected function setupArguments(): array;
/**
* Main execution logic
*
* @return int Exit code (0 for success, non-zero for error)
*/
abstract protected function run(): int;
/**
* Get common CLI options
*
* @return array<string, string> Common options
*/
protected function getCommonOptions(): array
{
return [
'version' => 'Display version information',
'verbose' => 'Enable verbose output',
'v' => 'Alias for --verbose',
'quiet' => 'Suppress non-error output',
'q' => 'Alias for --quiet',
'dry-run' => 'Perform dry run without making changes',
'json' => 'Output results in JSON format',
'metrics' => 'Collect and display metrics',
'help' => 'Display this help message',
'h' => 'Alias for --help',
];
}
/**
* Parse command line arguments
*/
protected function parseArguments(): void
{
$shortOpts = 'vqh';
$longOpts = [
'version',
'verbose',
'quiet',
'dry-run',
'json',
'metrics',
'help',
];
// Add custom options
$customOpts = $this->setupArguments();
foreach (array_keys($customOpts) as $opt) {
if (str_ends_with($opt, '::')) {
$longOpts[] = rtrim($opt, ':') . '::';
} elseif (str_ends_with($opt, ':')) {
$longOpts[] = rtrim($opt, ':') . ':';
} else {
$longOpts[] = $opt;
}
}
// Use $GLOBALS['argv'] instead of getopt() so that bin/moko's in-process
// dispatch (via require) is respected. PHP's getopt() reads from the C-level
// argv set at process start and ignores runtime changes to $argv/$_SERVER['argv'].
$this->options = $this->parseArgv($GLOBALS['argv'] ?? [], $longOpts, $shortOpts);
$this->arguments = array_slice($GLOBALS['argv'] ?? [], 1);
// Handle common flags
if (isset($this->options['version'])) {
echo "{$this->name} v{$this->version}\n";
exit(0);
}
if (isset($this->options['help']) || isset($this->options['h'])) {
$this->printHelp();
exit(0);
}
$this->verbose = isset($this->options['verbose']) || isset($this->options['v']);
$this->quiet = isset($this->options['quiet']) || isset($this->options['q']);
$this->dryRun = isset($this->options['dry-run']);
$this->jsonOutput = isset($this->options['json']);
}
/**
* Parse an argv array using getopt-compatible option specs.
*
* Replaces PHP's getopt() so callers can inject a custom argv (e.g. bin/moko
* in-process dispatch sets $GLOBALS['argv'] before require-ing a script).
*
* @param list<string> $argv Full argv including $argv[0] (script path)
* @param list<string> $longOpts Long option specs, e.g. ['verbose', 'repos:', 'path::']
* @param string $shortOpts Short option chars, e.g. 'vqh'
* @return array<string, string|false|list<string>> Parsed options (same shape as getopt())
*/
private function parseArgv(array $argv, array $longOpts, string $shortOpts): array
{
$result = [];
$tokens = array_slice($argv, 1);
// Build lookup: option-name → 0=flag, 1=required-value, 2=optional-value
$specs = [];
foreach ($longOpts as $spec) {
if (str_ends_with($spec, '::')) {
$specs[rtrim($spec, ':')] = 2;
} elseif (str_ends_with($spec, ':')) {
$specs[rtrim($spec, ':')] = 1;
} else {
$specs[$spec] = 0;
}
}
for ($i = 0, $len = strlen($shortOpts); $i < $len; $i++) {
$c = $shortOpts[$i];
if ($c === ':') {
continue;
}
$mode = 0;
if (isset($shortOpts[$i + 1]) && $shortOpts[$i + 1] === ':') {
$mode = isset($shortOpts[$i + 2]) && $shortOpts[$i + 2] === ':' ? 2 : 1;
}
$specs[$c] = $mode;
}
for ($i = 0, $n = count($tokens); $i < $n; $i++) {
$tok = $tokens[$i];
if ($tok === '--') {
break; // end of options
}
if (str_starts_with($tok, '--')) {
$name = substr($tok, 2);
$val = null;
if (str_contains($name, '=')) {
[$name, $val] = explode('=', $name, 2);
}
$mode = $specs[$name] ?? -1;
if ($mode === -1) {
continue; // unknown option
}
if ($mode === 1 && $val === null) {
$val = $tokens[++$i] ?? false;
} elseif ($mode === 0) {
$val = false;
} elseif ($mode === 2 && $val === null) {
$val = false;
}
// Support repeated options as arrays (matches getopt() behaviour)
if (array_key_exists($name, $result)) {
$result[$name] = array_merge((array) $result[$name], [$val]);
} else {
$result[$name] = $val;
}
} elseif (str_starts_with($tok, '-') && strlen($tok) > 1) {
$chars = substr($tok, 1);
for ($j = 0, $jn = strlen($chars); $j < $jn; $j++) {
$c = $chars[$j];
$mode = $specs[$c] ?? -1;
if ($mode === -1) {
continue;
}
$val = false;
if ($mode === 1) {
$rest = substr($chars, $j + 1);
if ($rest !== '') {
$val = $rest;
$j = $jn; // consumed rest of cluster
} else {
$val = $tokens[++$i] ?? false;
}
}
$result[$c] = $val;
}
}
}
return $result;
}
/**
* Print help message
*/
protected function printHelp(): void
{
echo "{$this->description}\n\n";
echo "Usage: {$this->name} [options]\n\n";
echo "Options:\n";
$allOpts = array_merge($this->getCommonOptions(), $this->setupArguments());
foreach ($allOpts as $opt => $desc) {
$optName = rtrim($opt, ':');
$hasValue = str_ends_with($opt, ':');
$optDisplay = strlen($optName) === 1 ? "-{$optName}" : "--{$optName}";
if ($hasValue) {
$optDisplay .= ' <value>';
}
echo sprintf(" %-30s %s\n", $optDisplay, $desc);
}
echo "\n";
}
/**
* Setup logging
*/
protected function setupLogging(): void
{
if ($this->quiet) {
error_reporting(E_ERROR);
} elseif ($this->verbose) {
error_reporting(E_ALL);
}
}
/**
* Setup enterprise features
*/
protected function setupEnterpriseFeatures(): void
{
if (isset($this->options['metrics'])) {
try {
$this->metrics = new MetricsCollector($this->name);
$this->log("Metrics collection enabled", 'INFO');
} catch (Exception $e) {
$this->log("Metrics collection unavailable: " . $e->getMessage(), 'WARNING');
}
}
}
/**
* Execute the CLI application
*
* @return int Exit code
*/
public function execute(): int
{
try {
$this->parseArguments();
$this->setupLogging();
$this->setupEnterpriseFeatures();
$this->log("Starting {$this->name} v{$this->version}", 'INFO');
if ($this->dryRun) {
$this->log("DRY RUN MODE - No changes will be made", 'INFO');
}
$startTime = microtime(true);
if ($this->metrics !== null) {
$timer = $this->metrics->startTimer('main_execution');
$exitCode = $this->run();
$timer->stop($exitCode === 0);
} else {
$exitCode = $this->run();
}
$duration = microtime(true) - $startTime;
$this->log(sprintf("Completed {$this->name} with exit code %d (%.2fs)", $exitCode, $duration), 'INFO');
if ($this->metrics !== null && !$this->quiet) {
$this->metrics->printSummary();
}
return $exitCode;
} catch (Exception $e) {
$this->log("Unhandled exception: " . $e->getMessage(), 'ERROR');
if ($this->verbose) {
$this->log($e->getTraceAsString(), 'ERROR');
}
return 1;
}
}
/**
* Get option value
*
* @param string $name Option name
* @param mixed $default Default value if not set
* @return mixed Option value
*/
protected function getOption(string $name, $default = null)
{
return $this->options[$name] ?? $default;
}
/**
* Check if option is set
*
* @param string $name Option name
* @return bool True if option is set
*/
protected function hasOption(string $name): bool
{
return isset($this->options[$name]);
}
/**
* Log a message
*
* @param string $message Message to log
* @param string $level Log level (INFO, WARNING, ERROR)
*/
protected function log(string $message, string $level = 'INFO'): void
{
if ($this->quiet && $level !== 'ERROR') {
return;
}
if (!$this->verbose && $level === 'DEBUG') {
return;
}
$timestamp = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$formatted = "[{$timestamp}] {$level}: {$message}\n";
if ($level === 'ERROR') {
fwrite(STDERR, $formatted);
} else {
echo $formatted;
}
}
/**
* Print result in appropriate format
*
* @param mixed $result Result to print
*/
protected function printResult($result): void
{
if ($this->jsonOutput) {
echo json_encode($result, JSON_PRETTY_PRINT) . "\n";
} else {
if (is_array($result)) {
echo var_export($result, true) . "\n";
} else {
echo $result . "\n";
}
}
}
/**
* Ask for user confirmation
*
* @param string $message Confirmation message
* @param bool $default Default response
* @return bool True if user confirms
*/
protected function confirm(string $message, bool $default = false): bool
{
if ($this->dryRun) {
$this->log("[DRY RUN] Would ask: {$message}", 'INFO');
return false;
}
$suffix = $default ? ' [Y/n]' : ' [y/N]';
echo $message . $suffix . ': ';
$handle = fopen('php://stdin', 'r');
$response = trim(fgets($handle));
fclose($handle);
if (empty($response)) {
return $default;
}
return in_array(strtolower($response), ['y', 'yes'], true);
}
/**
* Print colored output (if terminal supports it)
*
* @param string $text Text to print
* @param string $color Color name (red, green, yellow, blue, cyan, gray, bold, dim)
*/
protected function printColored(string $text, string $color): void
{
$colors = [
'red' => "\033[31m",
'green' => "\033[32m",
'yellow' => "\033[33m",
'blue' => "\033[34m",
'cyan' => "\033[36m",
'gray' => "\033[90m",
'bold' => "\033[1m",
'dim' => "\033[2m",
'reset' => "\033[0m",
];
if (isset($colors[$color]) && $this->isColorEnabled()) {
echo $colors[$color] . $text . $colors['reset'];
} else {
echo $text;
}
}
// =========================================================================
// Console graphics — visual primitives added to CLIApp
// =========================================================================
/**
* Return whether ANSI colour output is enabled.
*
* Disabled when --no-color is passed, NO_COLOR env var is set, or
* stdout is not an interactive terminal.
*/
protected function isColorEnabled(): bool
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
if (in_array('--no-color', $_SERVER['argv'] ?? [], true) || getenv('NO_COLOR') !== false) {
return $cache = false;
}
return $cache = stream_isatty(STDOUT);
}
/**
* Return the terminal width (defaults to 80).
*/
protected function termWidth(): int
{
$cols = (int) getenv('COLUMNS');
return ($cols > 40) ? $cols : 80;
}
/**
* Wrap text in an ANSI code; returns plain text when colour is off.
*/
protected function colorize(string $code, string $text): string
{
if (!$this->isColorEnabled()) {
return $text;
}
return $code . $text . "\033[0m";
}
/**
* Print a script header banner.
*
* @param string $name Script name (defaults to $this->name).
* @param string $desc One-line description.
* @param string $ver Version string (defaults to $this->version).
*/
protected function printBanner(string $name = '', string $desc = '', string $ver = ''): void
{
$name = $name ?: $this->name;
$ver = $ver ?: $this->version;
$w = min($this->termWidth(), 70);
$inner = $w - 2;
$titlePad = str_pad(" {$name} v{$ver}", $inner);
$descPad = ($desc !== '') ? str_pad(" {$desc}", $inner) : null;
echo "\n";
echo $this->colorize(
"\033[36m",
"\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}"
) . "\n";
echo $this->colorize("\033[36m", "\u{2502}")
. $this->colorize("\033[1m", $titlePad)
. $this->colorize("\033[36m", "\u{2502}") . "\n";
if ($descPad !== null) {
echo $this->colorize("\033[36m", "\u{2502}")
. $this->colorize("\033[2m", $descPad)
. $this->colorize("\033[36m", "\u{2502}") . "\n";
}
echo $this->colorize(
"\033[36m",
"\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}"
) . "\n\n";
}
/**
* Print a section header rule.
*
* Output example: ── Section Title ──────────────────────────
*/
protected function section(string $title): void
{
$w = $this->termWidth();
$text = " {$title} ";
$fill = max(0, $w - strlen($text) - 4);
echo "\n";
echo $this->colorize(
"\033[36m",
str_repeat("\u{2500}", 2) . $text . str_repeat("\u{2500}", $fill)
) . "\n\n";
}
/**
* Print a plain horizontal divider.
*/
protected function printDivider(): void
{
echo $this->colorize("\033[2m", str_repeat("\u{2500}", $this->termWidth())) . "\n";
}
/**
* Print a single pass/fail status line.
*
* @param bool $passed Whether the check passed.
* @param string $label Check description.
* @param string $detail Optional detail in dim text.
*/
protected function statusLine(bool $passed, string $label, string $detail = ''): void
{
[$icon, $color] = $passed
? ["\u{2713}", "\033[32m"]
: ["\u{2717}", "\033[31m"];
$suffix = ($detail !== '')
? ' ' . $this->colorize("\033[2m", "— {$detail}")
: '';
echo ' ' . $this->colorize($color . "\033[1m", $icon) . ' ' . $label . $suffix . "\n";
}
/**
* Render an in-place progress bar.
*
* @param int $current Items done.
* @param int $total Total items.
* @param string $label Optional trailing label.
* @param bool $newline Finish bar with newline.
*/
protected function progress(int $current, int $total, string $label = '', bool $newline = false): void
{
$barWidth = min(30, $this->termWidth() - 22);
$filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0;
$pct = ($total > 0) ? (int) round(100 * $current / $total) : 0;
$bar = $this->colorize("\033[32m", str_repeat("\u{2588}", $filled))
. $this->colorize("\033[2m", str_repeat("\u{2591}", $barWidth - $filled));
$line = sprintf(
' [%s] %s %s%s',
$bar,
$this->colorize("\033[1m", sprintf('%3d%%', $pct)),
$this->colorize("\033[2m", "({$current}/{$total})"),
($label !== '') ? " {$label}" : ''
);
echo $newline ? "\r{$line}\n" : "\r{$line}";
}
/**
* Print a bordered summary box.
*
* @param array<string, string|int|float> $rows Label => value pairs.
* @param bool|null $passed Border colour: green/red/cyan.
*/
protected function printSummaryBox(array $rows, ?bool $passed = null): void
{
$color = match ($passed) {
true => "\033[32m",
false => "\033[31m",
default => "\033[36m",
};
$maxKey = max(array_map('strlen', array_keys($rows)));
$inner = $maxKey + 20;
echo "\n";
echo $this->colorize($color, "\u{250C}" . str_repeat("\u{2500}", $inner) . "\u{2510}") . "\n";
foreach ($rows as $label => $value) {
$valStr = (string) $value;
$padding = $inner - strlen($label) - strlen($valStr) - 4;
$row = ' ' . $this->colorize("\033[1m", $label)
. str_repeat(' ', max(1, $padding)) . $valStr . ' ';
echo $this->colorize($color, "\u{2502}") . $row . $this->colorize($color, "\u{2502}") . "\n";
}
echo $this->colorize($color, "\u{2514}" . str_repeat("\u{2500}", $inner) . "\u{2518}") . "\n\n";
}
public function getVersion(): string
{
return $this->version;
}
}
/**
* CLI for validation operations
*/
class ValidationCLI extends CLIApp
{
protected function setupArguments(): array
{
return [
'check:' => 'Type of validation (all, paths, markdown, licenses, workflows, security)',
'dir:' => 'Directory to validate (default: current directory)',
];
}
protected function run(): int
{
$check = $this->getOption('check', 'all');
$dir = $this->getOption('dir', '.');
$this->log("Running validation: {$check}", 'INFO');
try {
$validator = new UnifiedValidator();
if (in_array($check, ['all', 'paths'], true)) {
$validator->addPlugin(new PathValidatorPlugin());
}
if (in_array($check, ['all', 'markdown'], true)) {
$validator->addPlugin(new MarkdownValidatorPlugin());
}
$context = [
'paths' => [$dir],
'scan_dir' => $dir,
];
$results = $validator->validateAll($context);
if (!$this->jsonOutput) {
$validator->printSummary();
} else {
$resultData = array_map(function ($r) {
return [
'plugin' => $r->pluginName,
'passed' => $r->passed,
'message' => $r->message,
'details' => $r->details,
];
}, $results);
$this->printResult($resultData);
}
return $validator->allPassed() ? 0 : 1;
} catch (Exception $e) {
$this->log("Validation error: " . $e->getMessage(), 'ERROR');
return 1;
}
}
}
// =============================================================================
// CliFramework — current base class for all MokoStandards CLI scripts
// =============================================================================
/**
* Base class for MokoStandards CLI scripts.
*
* Provides argument parsing, a structured lifecycle, and a full console
* graphics system (banners, coloured log levels, progress bars, status
* lines, section headers, summary boxes) that gracefully degrades when the
* terminal does not support ANSI escape codes.
*
* Lifecycle: configure() -> parseArguments() -> printBanner() -> initialize() -> run()
*
* All new scripts must extend CliFramework and implement configure() + run().
*
* @since 04.00.15
* @see CLIApp Legacy base class (deprecated)
*/
abstract class CliFramework
{
// -------------------------------------------------------------------------
// ANSI colour constants
// -------------------------------------------------------------------------
protected const C_RESET = "\033[0m";
protected const C_BOLD = "\033[1m";
protected const C_DIM = "\033[2m";
protected const C_RED = "\033[31m";
protected const C_GREEN = "\033[32m";
protected const C_YELLOW = "\033[33m";
protected const C_BLUE = "\033[34m";
protected const C_MAGENTA = "\033[35m";
protected const C_CYAN = "\033[36m";
protected const C_GRAY = "\033[90m";
// -------------------------------------------------------------------------
// Unicode graphic characters
// -------------------------------------------------------------------------
protected const ICON_OK = "\u{2713}"; // ✓
protected const ICON_FAIL = "\u{2717}"; // ✗
protected const ICON_WARN = "\u{26A0}"; // ⚠
protected const ICON_INFO = "\u{2192}"; // →
protected const ICON_DRY = "\u{25CC}"; // ◌
protected const BOX_H = "\u{2500}"; // ─
protected const BOX_V = "\u{2502}"; // │
protected const BOX_TL = "\u{250C}"; // ┌
protected const BOX_TR = "\u{2510}"; // ┐
protected const BOX_BL = "\u{2514}"; // └
protected const BOX_BR = "\u{2518}"; // ┘
protected const BAR_FILL = "\u{2588}"; // █
protected const BAR_EMPTY = "\u{2591}"; // ░
// -------------------------------------------------------------------------
// Script properties (set by configure())
// -------------------------------------------------------------------------
/** @var string One-line description shown in the banner. */
private string $description = '';
/** @var string Script name. */
private string $scriptName = '';
/** @var string Script version shown in the banner. */
private string $scriptVersion = '04.00.15';
// -------------------------------------------------------------------------
// Argument definitions registered via addArgument()
// -------------------------------------------------------------------------
/** @var array<string, array{desc: string, default: mixed}> */
private array $argDefs = [];
/** @var array<string, mixed> Parsed argument values. */
private array $parsedArgs = [];
// -------------------------------------------------------------------------
// Runtime flags (set from CLI arguments)
// -------------------------------------------------------------------------
protected bool $quiet = false;
protected bool $verbose = false;
protected bool $dryRun = false;
// -------------------------------------------------------------------------
// Internal state
// -------------------------------------------------------------------------
/** @var bool|null Cached terminal-colour detection result. */
private ?bool $colorEnabled = null;
/** @var bool Whether a progress bar is currently active (needs clearing). */
private bool $progressActive = false;
/** @var float Script start time for elapsed-time reporting. */
private float $startTime;
// =========================================================================
// Constructor
// =========================================================================
/**
* @param string $name Script name (e.g. 'check_changelog').
* @param string $version Script version string.
*/
public function __construct(string $name = '', string $version = '04.00.15')
{
$this->scriptName = $name ?: basename($_SERVER['argv'][0] ?? 'script', '.php');
$this->scriptVersion = $version;
$this->startTime = microtime(true);
}
// =========================================================================
// Abstract methods — implement in each script
// =========================================================================
/**
* Register arguments and set the description.
* Called automatically by execute() before argument parsing.
*/
abstract protected function configure(): void;
/**
* Main script logic.
*
* @return int Exit code: 0 = success, 1 = failure, 2 = misuse.
*/
abstract protected function run(): int;
// =========================================================================
// Optional override
// =========================================================================
/**
* Post-parse initialisation hook.
* Override to set up services after arguments are available.
*/
protected function initialize(): void
{
}
// =========================================================================
// Lifecycle
// =========================================================================
/**
* Run the script: configure -> parse -> banner -> initialize -> run.
*
* @return int Exit code.
*/
public function execute(): int
{
$this->configure();
$this->parseArguments();
if ($this->hasRawArg('--help') || $this->hasRawArg('-h')) {
$this->printHelp();
return 0;
}
$this->quiet = $this->hasRawArg('--quiet') || $this->hasRawArg('-q');
$this->verbose = $this->hasRawArg('--verbose') || $this->hasRawArg('-v');
$this->dryRun = $this->hasRawArg('--dry-run');
if (!$this->quiet) {
$this->printBanner();
}
if ($this->dryRun && !$this->quiet) {
$this->printDryRunNotice();
}
$this->initialize();
try {
$code = $this->run();
} catch (\Exception $e) {
$this->clearProgress();
$this->log('ERROR', $e->getMessage());
return 1;
}
return $code;
}
// =========================================================================
// Argument registration
// =========================================================================
/**
* Set the one-line description shown in the banner and help.
*/
protected function setDescription(string $desc): void
{
$this->description = $desc;
}
/**
* Register an argument.
*
* @param string $name Argument name with dashes, e.g. '--path'.
* @param string $desc Short description for the help screen.
* @param mixed $default Default value; pass false for boolean flags.
*/
protected function addArgument(string $name, string $desc, mixed $default = null): void
{
$this->argDefs[$name] = ['desc' => $desc, 'default' => $default];
}
/**
* Get a parsed argument value.
*
* @param string $name Argument name, e.g. '--path'.
* @param mixed $fallback Override the registered default for this call.
* @return mixed
*/
protected function getArgument(string $name, mixed $fallback = null): mixed
{
if (array_key_exists($name, $this->parsedArgs)) {
return $this->parsedArgs[$name];
}
if ($fallback !== null) {
return $fallback;
}
return $this->argDefs[$name]['default'] ?? null;
}
// =========================================================================
// Argument parsing (internal)
// =========================================================================
/**
* Parse CLI arguments from $_SERVER['argv'] into registered argument definitions.
*
* @since 04.00.15
*/
private function parseArguments(): void
{
$argv = array_slice($_SERVER['argv'] ?? [], 1);
$len = count($argv);
for ($i = 0; $i < $len; $i++) {
$token = $argv[$i];
if (!str_starts_with($token, '-')) {
continue;
}
if (str_contains($token, '=')) {
[$key, $val] = explode('=', $token, 2);
$this->parsedArgs[$key] = $val;
} elseif (
isset($argv[$i + 1])
&& !str_starts_with($argv[$i + 1], '-')
&& isset($this->argDefs[$token])
&& $this->argDefs[$token]['default'] !== false
) {
$this->parsedArgs[$token] = $argv[$i + 1];
$i++;
} else {
$this->parsedArgs[$token] = true;
}
}
}
/** Check if a raw flag was passed on the command line. */
private function hasRawArg(string $flag): bool
{
return in_array($flag, $_SERVER['argv'] ?? [], true)
|| array_key_exists($flag, $this->parsedArgs);
}
// =========================================================================
// Help screen
// =========================================================================
/**
* Print auto-generated help screen from registered arguments.
*
* @since 04.00.15
*/
protected function printHelp(): void
{
$w = $this->termWidth();
echo $this->c(self::C_BOLD . self::C_CYAN, $this->scriptName);
if ($this->description !== '') {
echo ' — ' . $this->description;
}
echo "\n";
echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $w)) . "\n\n";
echo $this->c(self::C_BOLD, 'Usage:') . " php {$this->scriptName}.php [options]\n\n";
echo $this->c(self::C_BOLD, 'Options:') . "\n";
$builtIn = [
'--help' => ['desc' => 'Show this help message', 'default' => null],
'--dry-run' => ['desc' => 'Preview changes without writing', 'default' => null],
'--verbose' => ['desc' => 'Show detailed output', 'default' => null],
'--quiet' => ['desc' => 'Suppress all non-error output', 'default' => null],
'--no-color' => ['desc' => 'Disable ANSI colour output', 'default' => null],
];
foreach (array_merge($this->argDefs, $builtIn) as $name => $def) {
$default = $def['default'];
$hint = ($default !== null && $default !== false)
? $this->c(self::C_DIM, " (default: {$default})")
: '';
printf(
" %s%-22s%s%s%s\n",
self::C_CYAN,
$name,
self::C_RESET,
$def['desc'],
$hint
);
}
echo "\n";
}
// =========================================================================
// Console graphics — banner
// =========================================================================
/** Print the script header banner with name, description, and version. */
protected function printBanner(): void
{
$w = min($this->termWidth(), 70);
$inner = $w - 2;
$name = $this->scriptName;
$ver = 'v' . $this->scriptVersion;
$desc = $this->description;
$titleRaw = " {$name} {$ver}";
$titleStyled = " {$name} " . $this->c(self::C_DIM, $ver);
$titleLine = $this->padRight($titleStyled, $inner, strlen($titleRaw));
$descLine = ($desc !== '') ? $this->padRight(" {$desc}", $inner) : null;
echo "\n";
echo $this->c(
self::C_CYAN,
self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR
) . "\n";
echo $this->c(self::C_CYAN, self::BOX_V)
. $this->c(self::C_BOLD, $titleLine)
. $this->c(self::C_CYAN, self::BOX_V) . "\n";
if ($descLine !== null) {
echo $this->c(self::C_CYAN, self::BOX_V)
. $this->c(self::C_DIM, $descLine)
. $this->c(self::C_CYAN, self::BOX_V) . "\n";
}
echo $this->c(
self::C_CYAN,
self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR
) . "\n\n";
}
/** Print the dry-run notice box. */
protected function printDryRunNotice(): void
{
$w = min($this->termWidth(), 70);
$msg = ' ' . self::ICON_DRY . ' DRY-RUN MODE — no changes will be written ';
$row = $this->padRight($msg, $w - 2);
echo $this->c(
self::C_YELLOW . self::C_BOLD,
self::BOX_TL . str_repeat(self::BOX_H, $w - 2) . self::BOX_TR
) . "\n";
echo $this->c(
self::C_YELLOW . self::C_BOLD,
self::BOX_V . $row . self::BOX_V
) . "\n";
echo $this->c(
self::C_YELLOW . self::C_BOLD,
self::BOX_BL . str_repeat(self::BOX_H, $w - 2) . self::BOX_BR
) . "\n\n";
}
// =========================================================================
// Console graphics — sections and dividers
// =========================================================================
/**
* Print a section header line.
*
* Output example: ── Scanning Files ─────────────────────────────
*/
protected function section(string $title): void
{
if ($this->quiet) {
return;
}
$this->clearProgress();
$w = $this->termWidth();
$text = " {$title} ";
$fill = max(0, $w - strlen($text) - 4);
echo "\n";
echo $this->c(
self::C_CYAN,
str_repeat(self::BOX_H, 2) . $text . str_repeat(self::BOX_H, $fill)
) . "\n\n";
}
/** Print a plain horizontal divider. */
protected function printDivider(): void
{
if ($this->quiet) {
return;
}
$this->clearProgress();
echo $this->c(self::C_DIM, str_repeat(self::BOX_H, $this->termWidth())) . "\n";
}
// =========================================================================
// Console graphics — logging
// =========================================================================
/**
* Log a message with a level badge and timestamp.
*
* Two calling conventions:
* log('INFO', 'message') — explicit level
* log('message') — defaults to INFO
*
* @param string $levelOrMessage Level (INFO|SUCCESS|WARNING|ERROR|DEBUG) or message.
* @param string $message Message text when first arg is a level name.
*/
protected function log(string $levelOrMessage, string $message = ''): void
{
if ($message === '') {
$level = 'INFO';
$text = $levelOrMessage;
} else {
$level = strtoupper($levelOrMessage);
$text = $message;
}
if ($this->quiet && !in_array($level, ['ERROR', 'WARNING'], true)) {
return;
}
if (!$this->verbose && $level === 'DEBUG') {
return;
}
$this->clearProgress();
[$icon, $color] = $this->levelStyle($level);
$badge = $this->c($color . self::C_BOLD, sprintf('[%-7s]', $level));
$icon = $this->c($color, $icon);
$ts = $this->c(
self::C_GRAY,
(new \DateTime('now', new \DateTimeZone('UTC')))->format('H:i:s')
);
$line = "{$ts} {$icon} {$badge} {$text}\n";
if ($level === 'ERROR') {
fwrite(STDERR, $line);
} else {
echo $line;
}
}
/** Log a success message. */
protected function success(string $message): void
{
$this->log('SUCCESS', $message);
}
/** Log a warning message. */
protected function warning(string $message): void
{
$this->log('WARNING', $message);
}
/** Alias for warning(). */
protected function warn(string $message): void
{
$this->warning($message);
}
/**
* Log an error message and exit.
*
* @param string $message Error description.
* @param int $exitCode Process exit code.
* @return never
*/
protected function error(string $message, int $exitCode = 1): never
{
$this->clearProgress();
$this->log('ERROR', $message);
exit($exitCode);
}
// =========================================================================
// Console graphics — status lines (individual check results)
// =========================================================================
/**
* Print a single check-result status line.
*
* Output examples:
* ✓ CHANGELOG.md exists
* ✗ README.md missing — expected at repo root
*
* @param bool $passed Whether the check passed.
* @param string $label Check description.
* @param string $detail Optional detail shown in dim text.
*/
protected function status(bool $passed, string $label, string $detail = ''): void
{
if ($this->quiet) {
return;
}
$this->clearProgress();
[$icon, $color] = $passed
? [self::ICON_OK, self::C_GREEN]
: [self::ICON_FAIL, self::C_RED];
$suffix = ($detail !== '')
? ' ' . $this->c(self::C_DIM, "— {$detail}")
: '';
echo ' ' . $this->c($color . self::C_BOLD, $icon) . ' ' . $label . $suffix . "\n";
}
// =========================================================================
// Console graphics — progress bar
// =========================================================================
/**
* Render an in-place progress bar (overwrites the current terminal line).
*
* Call with $newline = true on the final item to finalise the bar.
*
* @param int $current Items processed so far.
* @param int $total Total items.
* @param string $label Optional label shown after the bar.
* @param bool $newline Finalise the bar with a newline.
*/
protected function progress(int $current, int $total, string $label = '', bool $newline = false): void
{
if ($this->quiet) {
return;
}
$barWidth = min(30, $this->termWidth() - 22);
$filled = ($total > 0) ? (int) round($barWidth * $current / $total) : 0;
$pct = ($total > 0) ? (int) round(100 * $current / $total) : 0;
$bar = $this->c(self::C_GREEN, str_repeat(self::BAR_FILL, $filled))
. $this->c(self::C_DIM, str_repeat(self::BAR_EMPTY, $barWidth - $filled));
$counter = $this->c(self::C_DIM, "({$current}/{$total})");
$percent = $this->c(self::C_BOLD, sprintf('%3d%%', $pct));
$suffix = ($label !== '') ? " {$label}" : '';
$line = " [{$bar}] {$percent} {$counter}{$suffix}";
if ($newline) {
echo "\r{$line}\n";
$this->progressActive = false;
} else {
echo "\r{$line}";
$this->progressActive = true;
}
}
/** Clear the active progress bar line. */
protected function clearProgress(): void
{
if ($this->progressActive) {
echo "\r" . str_repeat(' ', $this->termWidth()) . "\r";
$this->progressActive = false;
}
}
// =========================================================================
// Console graphics — summary box
// =========================================================================
/**
* Print a bordered summary box from a label => value map.
*
* @param array<string, string|int|float> $rows Label => value pairs.
* @param bool|null $passed Colours the border green/red/neutral.
*/
protected function printSummaryBox(array $rows, ?bool $passed = null): void
{
if ($this->quiet) {
return;
}
$this->clearProgress();
$color = match ($passed) {
true => self::C_GREEN,
false => self::C_RED,
default => self::C_CYAN,
};
$maxKey = max(array_map('strlen', array_keys($rows)));
$inner = $maxKey + 20;
echo "\n";
echo $this->c($color, self::BOX_TL . str_repeat(self::BOX_H, $inner) . self::BOX_TR) . "\n";
foreach ($rows as $label => $value) {
$valStr = (string) $value;
$valVis = strlen((string) preg_replace('/\033\[[0-9;]*m/', '', $valStr));
$padding = $inner - strlen($label) - $valVis - 4;
$row = ' ' . $this->c(self::C_BOLD, $label)
. str_repeat(' ', max(1, $padding)) . $valStr . ' ';
echo $this->c($color, self::BOX_V) . $row . $this->c($color, self::BOX_V) . "\n";
}
echo $this->c($color, self::BOX_BL . str_repeat(self::BOX_H, $inner) . self::BOX_BR) . "\n\n";
}
/**
* Print a standardised pass/fail summary.
*
* @param int $passed Checks that passed.
* @param int $failed Checks that failed.
* @param float $elapsed Elapsed seconds (omit row when 0).
*/
protected function printSummary(int $passed, int $failed, float $elapsed = 0.0): void
{
$total = $passed + $failed;
$score = ($total > 0) ? (int) round(100 * $passed / $total) : 0;
$ok = $failed === 0;
$rows = [
'Checks' => $total,
'Passed' => $passed . ' ' . $this->c(self::C_GREEN, self::ICON_OK),
'Failed' => $failed . ($failed > 0 ? ' ' . $this->c(self::C_RED, self::ICON_FAIL) : ''),
'Score' => "{$score}%",
];
if ($elapsed > 0.0) {
$rows['Elapsed'] = sprintf('%.2fs', $elapsed);
}
$this->printSummaryBox($rows, $ok);
}
// =========================================================================
// Console graphics — step indicator
// =========================================================================
/**
* Print a numbered step header.
*
* Output example: Step 2/5 → Running security checks
*/
protected function step(int $current, int $total, string $title): void
{
if ($this->quiet) {
return;
}
$this->clearProgress();
$badge = $this->c(self::C_BOLD . self::C_MAGENTA, "Step {$current}/{$total}");
$arrow = $this->c(self::C_DIM, self::ICON_INFO);
echo "\n{$badge} {$arrow} {$title}\n";
}
// =========================================================================
// Colour helpers
// =========================================================================
/**
* Wrap $text in ANSI codes; returns plain $text when colour is disabled.
*
* When called with only $codes (no $text), returns the raw ANSI string
* for use in string concatenation.
*
* @param string $codes Concatenated ANSI escape sequences.
* @param string $text Text to wrap (optional).
*/
protected function c(string $codes, string $text = ''): string
{
if (!$this->isColorEnabled()) {
return $text;
}
if ($text === '') {
return $codes;
}
return $codes . $text . self::C_RESET;
}
/**
* Return whether ANSI colour output is enabled.
*
* Disabled when --no-color is passed, NO_COLOR env var is set
* (https://no-color.org), or stdout is not an interactive terminal.
*/
protected function isColorEnabled(): bool
{
if ($this->colorEnabled !== null) {
return $this->colorEnabled;
}
if ($this->hasRawArg('--no-color') || getenv('NO_COLOR') !== false) {
return $this->colorEnabled = false;
}
return $this->colorEnabled = stream_isatty(STDOUT);
}
/**
* Return the terminal width (defaults to 80 when not detectable).
*/
protected function termWidth(): int
{
$cols = (int) getenv('COLUMNS');
return ($cols > 40) ? $cols : 80;
}
/**
* Return elapsed seconds since the script started.
*/
protected function elapsed(): float
{
return microtime(true) - $this->startTime;
}
// =========================================================================
// Internal helpers
// =========================================================================
/**
* Return [icon, ANSI colour code] for a given log level.
*
* @return array{0: string, 1: string}
*/
private function levelStyle(string $level): array
{
return match ($level) {
'SUCCESS' => [self::ICON_OK, self::C_GREEN],
'ERROR' => [self::ICON_FAIL, self::C_RED],
'WARNING' => [self::ICON_WARN, self::C_YELLOW],
'DEBUG' => ['.', self::C_DIM],
default => [self::ICON_INFO, self::C_CYAN],
};
}
/**
* Right-pad a string to the given visible width, ignoring ANSI codes.
*
* @param string $text Text to pad (may contain ANSI codes).
* @param int $width Target visible width.
* @param int $visibleLength Override auto-detected visible length.
*/
private function padRight(string $text, int $width, int $visibleLength = -1): string
{
if ($visibleLength < 0) {
$stripped = (string) preg_replace('/\033\[[0-9;]*m/', '', $text);
$visibleLength = strlen($stripped);
}
return $text . str_repeat(' ', max(0, $width - $visibleLength));
}
}