4cc3f5bee4
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 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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- Convert tabs to spaces (3,413 violations) - Fix line endings, trailing whitespace, brace placement - Break lines exceeding 150-char absolute limit - Replace heredoc tab closers with spaces - Fix empty elseif, forbidden function calls - Update phpcs.xml: exclude rules inappropriate for CLI scripts (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder, empty catch blocks) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
628 lines
19 KiB
PHP
628 lines
19 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.Plugins
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /lib/Enterprise/Plugins/DocumentationPlugin.php
|
|
* BRIEF: Enterprise plugin for documentation projects
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MokoEnterprise\Plugins;
|
|
|
|
use MokoEnterprise\AbstractProjectPlugin;
|
|
|
|
/**
|
|
* Documentation Project Plugin
|
|
*
|
|
* Provides validation, metrics, and management capabilities for
|
|
* documentation-focused projects (Sphinx, MkDocs, Docusaurus, etc.).
|
|
*/
|
|
class DocumentationPlugin extends AbstractProjectPlugin
|
|
{
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getProjectType(): string
|
|
{
|
|
return 'documentation';
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getPluginName(): string
|
|
{
|
|
return 'Documentation Project Plugin';
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function validateProject(array $config, string $projectPath): array
|
|
{
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
$docType = $this->detectDocumentationType($projectPath);
|
|
|
|
// Validate based on documentation type
|
|
switch ($docType) {
|
|
case 'sphinx':
|
|
if (!$this->fileExists($projectPath, 'conf.py')) {
|
|
$errors[] = 'Sphinx project missing conf.py';
|
|
}
|
|
if (!$this->fileExists($projectPath, 'index.rst')) {
|
|
$errors[] = 'Sphinx project missing index.rst';
|
|
}
|
|
break;
|
|
|
|
case 'mkdocs':
|
|
if (!$this->fileExists($projectPath, 'mkdocs.yml')) {
|
|
$errors[] = 'MkDocs project missing mkdocs.yml';
|
|
}
|
|
if (!$this->fileExists($projectPath, 'docs/index.md')) {
|
|
$warnings[] = 'MkDocs project missing docs/index.md';
|
|
}
|
|
break;
|
|
|
|
case 'docusaurus':
|
|
if (!$this->fileExists($projectPath, 'docusaurus.config.js')) {
|
|
$errors[] = 'Docusaurus project missing docusaurus.config.js';
|
|
}
|
|
if (!$this->fileExists($projectPath, 'package.json')) {
|
|
$errors[] = 'Docusaurus project missing package.json';
|
|
}
|
|
break;
|
|
|
|
case 'jekyll':
|
|
if (!$this->fileExists($projectPath, '_config.yml')) {
|
|
$errors[] = 'Jekyll project missing _config.yml';
|
|
}
|
|
break;
|
|
|
|
default:
|
|
if (!$this->fileExists($projectPath, 'README.md')) {
|
|
$warnings[] = 'No README.md found';
|
|
}
|
|
}
|
|
|
|
// Check for table of contents
|
|
if (!$this->hasTableOfContents($projectPath, $docType)) {
|
|
$warnings[] = 'No clear table of contents structure found';
|
|
}
|
|
|
|
// Check for images directory
|
|
if (
|
|
!$this->fileExists($projectPath, 'images') &&
|
|
!$this->fileExists($projectPath, 'assets') &&
|
|
!$this->fileExists($projectPath, 'static')
|
|
) {
|
|
$warnings[] = 'No images/assets directory found';
|
|
}
|
|
|
|
// Check for broken links (basic check)
|
|
$brokenLinks = $this->checkForBrokenLinks($projectPath);
|
|
if ($brokenLinks > 0) {
|
|
$warnings[] = "Found {$brokenLinks} potential broken internal links";
|
|
}
|
|
|
|
$this->log(
|
|
'Documentation project validation completed',
|
|
'info',
|
|
['errors' => count($errors), 'warnings' => count($warnings), 'type' => $docType]
|
|
);
|
|
|
|
return [
|
|
'valid' => empty($errors),
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function collectMetrics(string $projectPath, array $config): array
|
|
{
|
|
$docType = $this->detectDocumentationType($projectPath);
|
|
|
|
$metrics = [
|
|
'documentation_type' => $docType,
|
|
'markdown_files' => $this->countFiles($projectPath, '**/*.md'),
|
|
'rst_files' => $this->countFiles($projectPath, '**/*.rst'),
|
|
'html_files' => $this->countFiles($projectPath, '**/*.html'),
|
|
'image_files' => $this->countImageFiles($projectPath),
|
|
'total_pages' => $this->countTotalPages($projectPath, $docType),
|
|
'total_words' => $this->countTotalWords($projectPath, $docType),
|
|
'has_search' => $this->hasSearch($projectPath, $docType),
|
|
'has_versioning' => $this->hasVersioning($projectPath, $docType),
|
|
'has_i18n' => $this->hasInternationalization($projectPath, $docType),
|
|
];
|
|
|
|
// Check for code examples
|
|
$metrics['code_examples'] = $this->countCodeExamples($projectPath);
|
|
|
|
// Check structure depth
|
|
$metrics['max_depth'] = $this->getDocumentationDepth($projectPath);
|
|
|
|
// Record metrics
|
|
$this->recordMetric('documentation', 'markdown_files', $metrics['markdown_files']);
|
|
$this->recordMetric('documentation', 'total_pages', $metrics['total_pages']);
|
|
$this->recordMetric('documentation', 'total_words', $metrics['total_words']);
|
|
|
|
$this->log('Collected documentation metrics', 'info', $metrics);
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function healthCheck(string $projectPath, array $config): array
|
|
{
|
|
$issues = [];
|
|
$score = 100;
|
|
|
|
$docType = $this->detectDocumentationType($projectPath);
|
|
|
|
// Check for index/home page
|
|
if (!$this->hasIndexPage($projectPath, $docType)) {
|
|
$issues[] = [
|
|
'severity' => 'critical',
|
|
'message' => 'Missing index/home page',
|
|
];
|
|
$score -= 20;
|
|
}
|
|
|
|
// Check for configuration
|
|
if (!$this->hasConfiguration($projectPath, $docType)) {
|
|
$issues[] = [
|
|
'severity' => 'critical',
|
|
'message' => 'Missing documentation configuration file',
|
|
];
|
|
$score -= 20;
|
|
}
|
|
|
|
// Check for broken links
|
|
$brokenLinks = $this->checkForBrokenLinks($projectPath);
|
|
if ($brokenLinks > 0) {
|
|
$issues[] = [
|
|
'severity' => 'warning',
|
|
'message' => "Found {$brokenLinks} potential broken internal links",
|
|
];
|
|
$score -= min(20, $brokenLinks * 2);
|
|
}
|
|
|
|
// Check for table of contents
|
|
if (!$this->hasTableOfContents($projectPath, $docType)) {
|
|
$issues[] = [
|
|
'severity' => 'warning',
|
|
'message' => 'No clear navigation/table of contents',
|
|
];
|
|
$score -= 10;
|
|
}
|
|
|
|
// Check page count
|
|
$pageCount = $this->countTotalPages($projectPath, $docType);
|
|
if ($pageCount < 3) {
|
|
$issues[] = [
|
|
'severity' => 'warning',
|
|
'message' => 'Very few documentation pages found',
|
|
];
|
|
$score -= 10;
|
|
}
|
|
|
|
// Check for search functionality
|
|
if (!$this->hasSearch($projectPath, $docType)) {
|
|
$issues[] = [
|
|
'severity' => 'info',
|
|
'message' => 'No search functionality configured',
|
|
];
|
|
$score -= 5;
|
|
}
|
|
|
|
// Check for build output in repository
|
|
if ($this->hasBuildOutput($projectPath, $docType)) {
|
|
$issues[] = [
|
|
'severity' => 'warning',
|
|
'message' => 'Build output detected in repository (should be in .gitignore)',
|
|
];
|
|
$score -= 5;
|
|
}
|
|
|
|
$score = max(0, $score);
|
|
|
|
$this->log('Documentation health check completed', 'info', [
|
|
'score' => $score,
|
|
'issues_count' => count($issues),
|
|
'doc_type' => $docType,
|
|
]);
|
|
|
|
return [
|
|
'healthy' => $score >= 70,
|
|
'score' => $score,
|
|
'issues' => $issues,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getRequiredFiles(): array
|
|
{
|
|
return [
|
|
'README.md or index.md or index.rst',
|
|
'Configuration file (conf.py, mkdocs.yml, docusaurus.config.js, _config.yml)',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getRecommendedFiles(): array
|
|
{
|
|
return [
|
|
'Table of contents or navigation configuration',
|
|
'images/ or assets/ directory',
|
|
'CONTRIBUTING.md',
|
|
'.gitignore',
|
|
'requirements.txt or package.json',
|
|
'build/ or site/ in .gitignore',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getConfigSchema(): array
|
|
{
|
|
return [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'documentation_type' => [
|
|
'type' => 'string',
|
|
'enum' => ['sphinx', 'mkdocs', 'docusaurus', 'jekyll', 'hugo', 'gitbook', 'custom'],
|
|
'description' => 'Documentation framework',
|
|
],
|
|
'build_command' => [
|
|
'type' => 'string',
|
|
'description' => 'Command to build documentation',
|
|
],
|
|
'output_directory' => [
|
|
'type' => 'string',
|
|
'description' => 'Build output directory',
|
|
],
|
|
'enable_search' => [
|
|
'type' => 'boolean',
|
|
'description' => 'Enable search functionality',
|
|
],
|
|
'enable_versioning' => [
|
|
'type' => 'boolean',
|
|
'description' => 'Enable version management',
|
|
],
|
|
],
|
|
'required' => ['documentation_type'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* {@inheritdoc}
|
|
*/
|
|
public function getBestPractices(): array
|
|
{
|
|
return [
|
|
'Use clear hierarchical structure with logical organization',
|
|
'Include comprehensive table of contents or navigation',
|
|
'Write in clear, concise language appropriate for audience',
|
|
'Add code examples with proper syntax highlighting',
|
|
'Include screenshots and diagrams where helpful',
|
|
'Maintain consistent formatting and style',
|
|
'Use cross-references and internal links effectively',
|
|
'Enable search functionality for easy navigation',
|
|
'Version documentation alongside code releases',
|
|
'Keep documentation up to date with code changes',
|
|
'Include getting started and installation guides',
|
|
'Add troubleshooting and FAQ sections',
|
|
'Use admonitions (notes, warnings) appropriately',
|
|
'Implement responsive design for mobile viewing',
|
|
'Exclude build output from version control',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Detect documentation type
|
|
*/
|
|
private function detectDocumentationType(string $projectPath): string
|
|
{
|
|
if ($this->fileExists($projectPath, 'conf.py')) {
|
|
return 'sphinx';
|
|
}
|
|
if ($this->fileExists($projectPath, 'mkdocs.yml')) {
|
|
return 'mkdocs';
|
|
}
|
|
if ($this->fileExists($projectPath, 'docusaurus.config.js')) {
|
|
return 'docusaurus';
|
|
}
|
|
if ($this->fileExists($projectPath, '_config.yml')) {
|
|
return 'jekyll';
|
|
}
|
|
if ($this->fileExists($projectPath, 'config.toml') || $this->fileExists($projectPath, 'config.yaml')) {
|
|
return 'hugo';
|
|
}
|
|
if ($this->fileExists($projectPath, 'book.json')) {
|
|
return 'gitbook';
|
|
}
|
|
|
|
return 'custom';
|
|
}
|
|
|
|
/**
|
|
* Check for index page
|
|
*/
|
|
private function hasIndexPage(string $projectPath, string $docType): bool
|
|
{
|
|
$indexFiles = ['index.md', 'index.rst', 'index.html', 'README.md', 'docs/index.md'];
|
|
|
|
foreach ($indexFiles as $file) {
|
|
if ($this->fileExists($projectPath, $file)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check for configuration
|
|
*/
|
|
private function hasConfiguration(string $projectPath, string $docType): bool
|
|
{
|
|
$configFiles = [
|
|
'conf.py',
|
|
'mkdocs.yml',
|
|
'docusaurus.config.js',
|
|
'_config.yml',
|
|
'config.toml',
|
|
'book.json',
|
|
];
|
|
|
|
foreach ($configFiles as $file) {
|
|
if ($this->fileExists($projectPath, $file)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check for table of contents
|
|
*/
|
|
private function hasTableOfContents(string $projectPath, string $docType): bool
|
|
{
|
|
// Check for TOC files
|
|
$tocFiles = ['SUMMARY.md', 'toc.yml', 'toc.rst', 'sidebar.js', 'sidebars.js'];
|
|
|
|
foreach ($tocFiles as $file) {
|
|
if ($this->fileExists($projectPath, $file)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check configuration files
|
|
if ($docType === 'mkdocs' && $this->fileExists($projectPath, 'mkdocs.yml')) {
|
|
$content = $this->readFile($projectPath, 'mkdocs.yml');
|
|
if ($content && strpos($content, 'nav:') !== false) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Count image files
|
|
*/
|
|
private function countImageFiles(string $projectPath): int
|
|
{
|
|
$count = 0;
|
|
$extensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
|
|
|
|
foreach ($extensions as $ext) {
|
|
$count += $this->countFiles($projectPath, "**/*.{$ext}");
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Count total pages
|
|
*/
|
|
private function countTotalPages(string $projectPath, string $docType): int
|
|
{
|
|
if (in_array($docType, ['sphinx', 'rst'])) {
|
|
return $this->countFiles($projectPath, '**/*.rst');
|
|
}
|
|
|
|
return $this->countFiles($projectPath, '**/*.md');
|
|
}
|
|
|
|
/**
|
|
* Count total words
|
|
*/
|
|
private function countTotalWords(string $projectPath, string $docType): int
|
|
{
|
|
$pattern = in_array($docType, ['sphinx', 'rst']) ? '**/*.rst' : '**/*.md';
|
|
$files = $this->findFiles($projectPath, $pattern);
|
|
|
|
$totalWords = 0;
|
|
foreach ($files as $file) {
|
|
if (is_file($file)) {
|
|
$content = @file_get_contents($file);
|
|
if ($content) {
|
|
$totalWords += str_word_count(strip_tags($content));
|
|
}
|
|
}
|
|
}
|
|
|
|
return $totalWords;
|
|
}
|
|
|
|
/**
|
|
* Check for search
|
|
*/
|
|
private function hasSearch(string $projectPath, string $docType): bool
|
|
{
|
|
switch ($docType) {
|
|
case 'mkdocs':
|
|
$config = $this->readFile($projectPath, 'mkdocs.yml');
|
|
return $config && strpos($config, 'search') !== false;
|
|
|
|
case 'docusaurus':
|
|
$config = $this->readFile($projectPath, 'docusaurus.config.js');
|
|
return $config && strpos($config, 'algolia') !== false;
|
|
|
|
case 'sphinx':
|
|
return true; // Sphinx has built-in search
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for versioning
|
|
*/
|
|
private function hasVersioning(string $projectPath, string $docType): bool
|
|
{
|
|
return $this->fileExists($projectPath, 'versions') ||
|
|
$this->fileExists($projectPath, 'versioned_docs');
|
|
}
|
|
|
|
/**
|
|
* Check for internationalization
|
|
*/
|
|
private function hasInternationalization(string $projectPath, string $docType): bool
|
|
{
|
|
return $this->fileExists($projectPath, 'i18n') ||
|
|
$this->fileExists($projectPath, 'locales') ||
|
|
$this->fileExists($projectPath, 'locale');
|
|
}
|
|
|
|
/**
|
|
* Count code examples
|
|
*/
|
|
private function countCodeExamples(string $projectPath): int
|
|
{
|
|
$files = $this->findFiles($projectPath, '**/*.md');
|
|
$count = 0;
|
|
|
|
foreach ($files as $file) {
|
|
if (is_file($file)) {
|
|
$content = @file_get_contents($file);
|
|
if ($content) {
|
|
$count += preg_match_all('/```/', $content) / 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (int)$count;
|
|
}
|
|
|
|
/**
|
|
* Get documentation depth
|
|
*/
|
|
private function getDocumentationDepth(string $projectPath): int
|
|
{
|
|
$maxDepth = 0;
|
|
$docsDirs = ['docs', 'source', 'content', '.'];
|
|
|
|
foreach ($docsDirs as $dir) {
|
|
$fullPath = $projectPath . '/' . $dir;
|
|
if (!is_dir($fullPath)) {
|
|
continue;
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($fullPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
$depth = $iterator->getDepth();
|
|
if ($depth > $maxDepth) {
|
|
$maxDepth = $depth;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $maxDepth;
|
|
}
|
|
|
|
/**
|
|
* Check for broken links
|
|
*/
|
|
private function checkForBrokenLinks(string $projectPath): int
|
|
{
|
|
$files = array_merge(
|
|
$this->findFiles($projectPath, '**/*.md'),
|
|
$this->findFiles($projectPath, '**/*.rst')
|
|
);
|
|
|
|
$brokenCount = 0;
|
|
$linkedFiles = [];
|
|
|
|
foreach ($files as $file) {
|
|
if (!is_file($file)) {
|
|
continue;
|
|
}
|
|
|
|
$content = @file_get_contents($file);
|
|
if (!$content) {
|
|
continue;
|
|
}
|
|
|
|
// Extract markdown links
|
|
preg_match_all('/\[([^\]]+)\]\(([^)]+)\)/', $content, $matches);
|
|
foreach ($matches[2] as $link) {
|
|
if (strpos($link, 'http') === 0 || strpos($link, '#') === 0) {
|
|
continue; // Skip external and anchor links
|
|
}
|
|
|
|
$linkedPath = dirname($file) . '/' . $link;
|
|
if (!file_exists($linkedPath)) {
|
|
$brokenCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $brokenCount;
|
|
}
|
|
|
|
/**
|
|
* Check for build output
|
|
*/
|
|
private function hasBuildOutput(string $projectPath, string $docType): bool
|
|
{
|
|
$buildDirs = ['_build', 'build', 'site', '.docusaurus', '_site'];
|
|
|
|
foreach ($buildDirs as $dir) {
|
|
if ($this->fileExists($projectPath, $dir)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|