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
JavaScript
/**
* 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