Files
breadcrumb-the-shire/lib/Console/Commands/Module/ValidateCommand.php

337 lines
12 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleManifestLoader;
use MintyPHP\Console\Command;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
/**
* Validates a module's manifest, class contracts, and structure.
*
* Catches errors that would otherwise only surface at boot time.
*/
final class ValidateCommand extends Command
{
public function name(): string
{
return 'module:validate';
}
public function description(): string
{
return 'Validate a module\'s manifest, classes, and structure';
}
public function usage(): string
{
return <<<'USAGE'
Usage: php bin/console module:validate <module-id>
php bin/console module:validate --all
Validates:
- Manifest validates against .agents/contracts/module-manifest.schema.json
- Manifest parses without error
- All declared classes exist and implement correct interfaces
- PHP classes use MintyPHP\Module\* namespace
- Permission keys are well-formed
- Required directories exist
- Route paths don't contain suspicious patterns
USAGE;
}
public function execute(array $args, array $options): int
{
$this->bootstrapApp('cli', 'bin/console module:validate');
$modulesDir = $this->projectRoot() . '/modules';
$validateAll = $options['all'] ?? false;
$moduleId = trim($args[0] ?? '');
if (!$validateAll && $moduleId === '') {
fwrite(STDERR, $this->usage() . "\n");
return 1;
}
$moduleIds = $validateAll
? $this->discoverModuleIds($modulesDir)
: [$moduleId];
if ($moduleIds === []) {
fwrite(STDERR, "No modules found in modules/\n");
return 1;
}
$totalErrors = 0;
$totalWarnings = 0;
foreach ($moduleIds as $id) {
[$errors, $warnings] = $this->validateModule($modulesDir, $id);
$totalErrors += count($errors);
$totalWarnings += count($warnings);
if ($errors === [] && $warnings === []) {
fwrite(STDOUT, "[OK] {$id}\n");
continue;
}
foreach ($errors as $error) {
fwrite(STDOUT, "[FAIL] {$id}: {$error}\n");
}
foreach ($warnings as $warning) {
fwrite(STDOUT, "[WARN] {$id}: {$warning}\n");
}
}
fwrite(STDOUT, sprintf(
"\nSummary: %d module(s), %d error(s), %d warning(s)\n",
count($moduleIds),
$totalErrors,
$totalWarnings
));
return $totalErrors > 0 ? 1 : 0;
}
/**
* @return list<string>
*/
private function discoverModuleIds(string $modulesDir): array
{
if (!is_dir($modulesDir)) {
return [];
}
$ids = [];
$entries = scandir($modulesDir) ?: [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
continue;
}
if (is_file($modulesDir . '/' . $entry . '/module.php')) {
$ids[] = $entry;
}
}
sort($ids);
return $ids;
}
/**
* @return array{list<string>, list<string>} [errors, warnings]
*/
private function validateModule(string $modulesDir, string $moduleId): array
{
$errors = [];
$warnings = [];
$moduleDir = $modulesDir . '/' . $moduleId;
$manifestFile = $moduleDir . '/module.php';
// 1. Directory exists
if (!is_dir($moduleDir)) {
return [["Module directory not found: modules/{$moduleId}/"], []];
}
// 2. Manifest exists
if (!is_file($manifestFile)) {
return [["Manifest not found: modules/{$moduleId}/module.php"], []];
}
// 3+4. Load and validate manifest
try {
$manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $moduleId);
} catch (\Throwable $e) {
return [['Manifest load/validation error: ' . $e->getMessage()], []];
}
// Register module lib/ with autoloader so we can resolve classes
// even if the module is not in the enabled list
$libDir = $moduleDir . '/lib';
if (is_dir($libDir)) {
$composerLoader = null;
foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) {
$composerLoader = $autoloader[0];
break;
}
}
$composerLoader?->addPsr4('MintyPHP\\', [$libDir]);
}
// 5. ID matches directory
if ($manifest->id !== $moduleId) {
$errors[] = "Manifest id '{$manifest->id}' does not match directory '{$moduleId}'";
}
// 6. Version is semver
if (!preg_match('/^\d+\.\d+\.\d+$/', $manifest->version)) {
$warnings[] = "Version '{$manifest->version}' is not semver (expected x.y.z)";
}
// 7. lib/ directory exists
if (!is_dir($moduleDir . '/lib')) {
$warnings[] = 'No lib/ directory (module has no PHP classes)';
}
// 8. Container registrar classes exist and implement interface
foreach ($manifest->containerRegistrars as $class) {
if (!class_exists($class)) {
$errors[] = "Container registrar class not found: {$class}";
} elseif (!is_subclass_of($class, ContainerRegistrar::class)) {
$errors[] = "Container registrar does not implement ContainerRegistrar: {$class}";
}
}
// 9. Authorization policy classes
foreach ($manifest->authorizationPolicies as $class) {
if (!class_exists($class)) {
$errors[] = "Authorization policy class not found: {$class}";
} elseif (!in_array(AuthorizationPolicyInterface::class, class_implements($class) ?: [], true)) {
$errors[] = "Authorization policy does not implement AuthorizationPolicyInterface: {$class}";
}
}
// 10. Session provider classes
foreach ($manifest->sessionProviders as $class) {
if (!class_exists($class)) {
$errors[] = "Session provider class not found: {$class}";
} elseif (!in_array(SessionProvider::class, class_implements($class) ?: [], true)) {
$errors[] = "Session provider does not implement SessionProvider: {$class}";
}
}
// 11. Layout context provider classes
foreach ($manifest->layoutContextProviders as $class) {
if (!class_exists($class)) {
$errors[] = "Layout context provider class not found: {$class}";
} elseif (!in_array(LayoutContextProvider::class, class_implements($class) ?: [], true)) {
$errors[] = "Layout context provider does not implement LayoutContextProvider: {$class}";
}
}
// 12. Search resource provider classes
foreach ($manifest->searchResources as $class) {
if (!class_exists($class)) {
$errors[] = "Search resource provider class not found: {$class}";
} elseif (!in_array(SearchResourceProvider::class, class_implements($class) ?: [], true)) {
$errors[] = "Search resource provider does not implement SearchResourceProvider: {$class}";
}
}
// 13. Event listener classes
foreach ($manifest->eventListeners as $eventName => $handlers) {
foreach ($handlers as $i => $handler) {
$class = $handler['class'];
if (!class_exists($class)) {
$errors[] = "Event listener class not found: {$class} (event: {$eventName})";
} elseif (!in_array(EventListener::class, class_implements($class) ?: [], true)) {
$errors[] = "Event listener does not implement EventListener: {$class} (event: {$eventName})";
}
}
}
// 14. Deactivation handler class
if ($manifest->deactivationHandler !== null) {
$class = $manifest->deactivationHandler;
if (!class_exists($class)) {
$errors[] = "Deactivation handler class not found: {$class}";
} elseif (!in_array(ModuleDeactivationHandler::class, class_implements($class) ?: [], true)) {
$errors[] = "Deactivation handler does not implement ModuleDeactivationHandler: {$class}";
}
}
// 15. Permission keys are well-formed
foreach ($manifest->permissions as $i => $perm) {
$key = $perm['key'];
if (!preg_match('/^[a-z][a-z0-9._-]+$/', $key)) {
$errors[] = "Permission key has invalid format: '{$key}'";
}
}
// 16. Namespace compliance — PHP files in lib/ must use MintyPHP\Module\*
if (is_dir($moduleDir . '/lib')) {
$this->checkNamespaceCompliance($moduleDir . '/lib', $moduleId, $errors);
}
// 17. Routes have pages directory
if ($manifest->routes !== [] && !is_dir($moduleDir . '/pages')) {
$errors[] = 'Module declares routes but has no pages/ directory';
}
// 18. UI slot templates exist
foreach ($manifest->uiSlots as $slotName => $contributions) {
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $idx => $contribution) {
if (!is_array($contribution)) {
continue;
}
foreach (['panel_template', 'template'] as $templateKey) {
$template = $contribution[$templateKey] ?? null;
if ($template === null) {
continue;
}
$templatePath = (string) $template;
if (!str_starts_with($templatePath, '/')) {
$templatePath = $moduleDir . '/' . $templatePath;
}
if (!is_file($templatePath)) {
$errors[] = "UI slot '{$slotName}' {$templateKey} not found: {$template}";
}
}
}
}
// 19. Required modules exist on disk
foreach ($manifest->requires as $requiredId) {
if (!is_dir($modulesDir . '/' . $requiredId)) {
$errors[] = "Required module '{$requiredId}' not found on disk";
}
}
return [$errors, $warnings];
}
/**
* @param list<string> &$errors
*/
private function checkNamespaceCompliance(string $libDir, string $moduleId, array &$errors): void
{
$coreNamespacePrefixes = [
'MintyPHP\\Service\\',
'MintyPHP\\Repository\\',
'MintyPHP\\Support\\',
'MintyPHP\\Http\\',
'MintyPHP\\App\\Container\\',
];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content === false) {
continue;
}
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
$namespace = $match[1];
foreach ($coreNamespacePrefixes as $corePrefix) {
if (str_starts_with($namespace . '\\', $corePrefix)) {
$errors[] = "{$file->getFilename()} uses core namespace '{$namespace}' — must use MintyPHP\\Module\\*";
}
}
}
}
}
}