aiwg
Version:
Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.
459 lines (406 loc) • 13.3 kB
text/typescript
/**
* 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';
/**
* Registry entry structure
*/
export interface RegistryEntry {
id: string;
type: 'framework' | 'add-on' | 'extension';
name: string;
version: string;
path: string;
installedAt: string;
parentFramework?: string;
projects?: string[];
health?: {
status: 'healthy' | 'warning' | 'error';
lastCheck: string;
issues?: string[];
};
}
/**
* Registry structure
*/
export interface Registry {
version: string;
lastModified: string;
plugins: RegistryEntry[];
}
/**
* Validation issue
*/
export interface ValidationIssue {
type: 'error' | 'warning';
category: 'orphaned' | 'missing' | 'mismatch' | 'invalid-ref' | 'stale-health';
pluginId?: string;
path?: string;
message: string;
suggestion?: string;
}
/**
* Validation result
*/
export interface RegistryValidationResult {
valid: boolean;
issues: ValidationIssue[];
stats: {
totalPlugins: number;
healthyPlugins: number;
orphanedPlugins: number;
missingPlugins: number;
invalidRefs: number;
};
}
/**
* Validation options
*/
export interface RegistryValidationOptions {
/** Check that filesystem directories match registry entries */
checkFilesystem?: boolean;
/** Check parent framework references are valid */
checkFrameworkRefs?: boolean;
/** Check health status is not stale (older than threshold) */
checkHealthStaleness?: boolean;
/** Health staleness threshold in hours (default: 24) */
healthStaleThresholdHours?: number;
/** Auto-fix issues where possible */
autoFix?: boolean;
}
/**
* RegistryValidator validates plugin registry consistency
*/
export class RegistryValidator {
private registryPath: string;
private aiwgRoot: string;
private options: RegistryValidationOptions;
constructor(aiwgRoot: string, options: RegistryValidationOptions = {}) {
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(): Promise<RegistryValidationResult> {
const result: RegistryValidationResult = {
valid: true,
issues: [],
stats: {
totalPlugins: 0,
healthyPlugins: 0,
orphanedPlugins: 0,
missingPlugins: 0,
invalidRefs: 0
}
};
// Load registry
let registry: Registry;
try {
registry = await this.loadRegistry();
} catch (error: any) {
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
*/
private async validatePlugin(plugin: RegistryEntry): Promise<ValidationIssue[]> {
const issues: ValidationIssue[] = [];
// 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: any) {
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: any) {
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
*/
private async findOrphanedDirectories(registry: Registry): Promise<ValidationIssue[]> {
const issues: ValidationIssue[] = [];
// 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: any) {
// 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
*/
private validateFrameworkReferences(registry: Registry): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// 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(): Promise<boolean> {
const result = await this.validate();
return result.valid;
}
/**
* Get orphaned plugins (in registry but not filesystem)
*
* @returns Array of orphaned plugin IDs
*/
async getOrphanedPlugins(): Promise<string[]> {
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(): Promise<string[]> {
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' | 'json' = 'text'): Promise<string> {
const result = await this.validate();
if (format === 'json') {
return JSON.stringify(result, null, 2);
}
return this.generateTextReport(result);
}
/**
* Load and parse registry file
*/
private async loadRegistry(): Promise<Registry> {
const content = await fs.readFile(this.registryPath, 'utf-8');
return JSON.parse(content);
}
/**
* Generate text format report
*/
private generateTextReport(result: RegistryValidationResult): string {
const lines: string[] = [];
// 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');
}
}