UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

344 lines 13.1 kB
/** * Plugin Registry Validator * * Validates registry consistency with filesystem, detects orphaned/missing plugins, * and validates cross-framework references. * * @module src/plugin/registry-validator * @implements @.aiwg/requirements/use-cases/UC-011-validate-plugin-security.md * @architecture @.aiwg/architecture/software-architecture-doc.md - Section 5.1 PluginManager * @adr @.aiwg/architecture/decisions/ADR-002-plugin-isolation-strategy.md * @tests @test/unit/plugin/registry-validator.test.ts * @depends @src/plugin/metadata-validator.ts */ import * as fs from 'fs/promises'; import * as path from 'path'; /** * RegistryValidator validates plugin registry consistency */ export class RegistryValidator { registryPath; aiwgRoot; options; constructor(aiwgRoot, options = {}) { this.aiwgRoot = aiwgRoot; this.registryPath = path.join(aiwgRoot, 'registry.json'); this.options = { checkFilesystem: true, checkFrameworkRefs: true, checkHealthStaleness: true, healthStaleThresholdHours: 24, autoFix: false, ...options }; } /** * Validate the entire registry * * @returns Validation result with issues and stats */ async validate() { const result = { valid: true, issues: [], stats: { totalPlugins: 0, healthyPlugins: 0, orphanedPlugins: 0, missingPlugins: 0, invalidRefs: 0 } }; // Load registry let registry; try { registry = await this.loadRegistry(); } catch (error) { result.valid = false; result.issues.push({ type: 'error', category: 'missing', path: this.registryPath, message: `Failed to load registry: ${error.message}`, suggestion: 'Run "aiwg -init-registry" to create a new registry' }); return result; } result.stats.totalPlugins = registry.plugins.length; // Validate each plugin entry for (const plugin of registry.plugins) { const pluginIssues = await this.validatePlugin(plugin); result.issues.push(...pluginIssues); // Track stats if (plugin.health?.status === 'healthy') { result.stats.healthyPlugins++; } } // Check for orphaned directories (in filesystem but not in registry) if (this.options.checkFilesystem) { const orphaned = await this.findOrphanedDirectories(registry); result.stats.orphanedPlugins = orphaned.length; result.issues.push(...orphaned); } // Check framework references if (this.options.checkFrameworkRefs) { const invalidRefs = this.validateFrameworkReferences(registry); result.stats.invalidRefs = invalidRefs.length; result.issues.push(...invalidRefs); } // Determine overall validity result.valid = result.issues.filter(i => i.type === 'error').length === 0; return result; } /** * Validate a single plugin entry * * @param plugin - Plugin registry entry * @returns Array of validation issues */ async validatePlugin(plugin) { const issues = []; // Check plugin directory exists if (this.options.checkFilesystem) { const pluginPath = path.join(this.aiwgRoot, plugin.path); try { const stats = await fs.stat(pluginPath); if (!stats.isDirectory()) { issues.push({ type: 'error', category: 'mismatch', pluginId: plugin.id, path: pluginPath, message: `Plugin path exists but is not a directory: ${plugin.path}`, suggestion: 'Remove the file and reinstall the plugin' }); } } catch (error) { if (error.code === 'ENOENT') { issues.push({ type: 'error', category: 'missing', pluginId: plugin.id, path: pluginPath, message: `Plugin directory not found: ${plugin.path}`, suggestion: `Run "aiwg -uninstall ${plugin.id}" to remove from registry, or reinstall the plugin` }); } } // Check manifest exists const manifestPath = path.join(pluginPath, 'manifest.json'); try { await fs.stat(manifestPath); } catch (error) { if (error.code === 'ENOENT') { issues.push({ type: 'warning', category: 'missing', pluginId: plugin.id, path: manifestPath, message: `Plugin manifest not found: ${path.join(plugin.path, 'manifest.json')}`, suggestion: 'Plugin may be incomplete or corrupted' }); } } } // Check health staleness if (this.options.checkHealthStaleness && plugin.health?.lastCheck) { const lastCheck = new Date(plugin.health.lastCheck); const now = new Date(); const hoursSinceCheck = (now.getTime() - lastCheck.getTime()) / (1000 * 60 * 60); if (hoursSinceCheck > (this.options.healthStaleThresholdHours || 24)) { issues.push({ type: 'warning', category: 'stale-health', pluginId: plugin.id, message: `Plugin health check is stale (${Math.floor(hoursSinceCheck)} hours old)`, suggestion: `Run "aiwg -health-check ${plugin.id}" to update health status` }); } } return issues; } /** * Find directories in the plugins folder that aren't in the registry * * @param registry - Current registry * @returns Array of validation issues for orphaned directories */ async findOrphanedDirectories(registry) { const issues = []; // Get registered paths const registeredPaths = new Set(registry.plugins.map(p => p.path)); // Check each plugin type directory const pluginDirs = ['frameworks', 'add-ons', 'extensions']; for (const dir of pluginDirs) { const dirPath = path.join(this.aiwgRoot, dir); try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const relativePath = path.join(dir, entry.name); if (!registeredPaths.has(relativePath)) { issues.push({ type: 'warning', category: 'orphaned', path: path.join(dirPath, entry.name), message: `Directory exists but not in registry: ${relativePath}`, suggestion: `Run "aiwg -sync-registry" to add missing entries, or delete the directory` }); } } } } catch (error) { // Directory may not exist, which is fine if (error.code !== 'ENOENT') { issues.push({ type: 'warning', category: 'mismatch', path: dirPath, message: `Failed to scan directory: ${error.message}` }); } } } return issues; } /** * Validate parent framework references * * @param registry - Current registry * @returns Array of validation issues for invalid references */ validateFrameworkReferences(registry) { const issues = []; // Get all framework IDs const frameworkIds = new Set(registry.plugins .filter(p => p.type === 'framework') .map(p => p.id)); // Check add-ons and extensions reference valid frameworks for (const plugin of registry.plugins) { if (plugin.parentFramework && !frameworkIds.has(plugin.parentFramework)) { issues.push({ type: 'error', category: 'invalid-ref', pluginId: plugin.id, message: `Plugin "${plugin.id}" references non-existent framework: ${plugin.parentFramework}`, suggestion: `Install the parent framework or update the plugin's parentFramework field` }); } } return issues; } /** * Validate registry against filesystem and return consistency status * * @returns True if registry and filesystem are consistent */ async isConsistent() { const result = await this.validate(); return result.valid; } /** * Get orphaned plugins (in registry but not filesystem) * * @returns Array of orphaned plugin IDs */ async getOrphanedPlugins() { const result = await this.validate(); return result.issues .filter(i => i.category === 'missing' && i.pluginId) .map(i => i.pluginId); } /** * Get missing plugins (in filesystem but not registry) * * @returns Array of directory paths not in registry */ async getMissingPlugins() { const result = await this.validate(); return result.issues .filter(i => i.category === 'orphaned' && i.path) .map(i => i.path); } /** * Generate a validation report * * @param format - Report format (text or json) * @returns Formatted report string */ async generateReport(format = 'text') { const result = await this.validate(); if (format === 'json') { return JSON.stringify(result, null, 2); } return this.generateTextReport(result); } /** * Load and parse registry file */ async loadRegistry() { const content = await fs.readFile(this.registryPath, 'utf-8'); return JSON.parse(content); } /** * Generate text format report */ generateTextReport(result) { const lines = []; // Header lines.push('Registry Validation Report'); lines.push('='.repeat(50)); lines.push(''); // Summary const statusIcon = result.valid ? '✓' : '✗'; const statusText = result.valid ? 'VALID' : 'INVALID'; lines.push(`Status: ${statusIcon} ${statusText}`); lines.push(''); // Stats lines.push('Statistics:'); lines.push(` Total Plugins: ${result.stats.totalPlugins}`); lines.push(` Healthy: ${result.stats.healthyPlugins}`); lines.push(` Orphaned: ${result.stats.orphanedPlugins}`); lines.push(` Missing: ${result.stats.missingPlugins}`); lines.push(` Invalid Refs: ${result.stats.invalidRefs}`); lines.push(''); // Issues if (result.issues.length > 0) { lines.push('Issues:'); lines.push('-'.repeat(50)); const errors = result.issues.filter(i => i.type === 'error'); const warnings = result.issues.filter(i => i.type === 'warning'); if (errors.length > 0) { lines.push('\nErrors:'); for (const issue of errors) { const plugin = issue.pluginId ? `[${issue.pluginId}] ` : ''; lines.push(` ✗ ${plugin}${issue.message}`); if (issue.suggestion) { lines.push(` → ${issue.suggestion}`); } } } if (warnings.length > 0) { lines.push('\nWarnings:'); for (const issue of warnings) { const plugin = issue.pluginId ? `[${issue.pluginId}] ` : ''; lines.push(` ⚠ ${plugin}${issue.message}`); if (issue.suggestion) { lines.push(` → ${issue.suggestion}`); } } } } else { lines.push('No issues found.'); } lines.push(''); lines.push('='.repeat(50)); return lines.join('\n'); } } //# sourceMappingURL=registry-validator.js.map