UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

430 lines (375 loc) 11.3 kB
/** * Manifest Validator * * Validates manifest CSV files for integrity, schema compliance, * and file existence checks. * * @module manifest-validator * @version 1.0.0 * @story 2.13 - Manifest System */ const fs = require('fs').promises; const path = require('path'); /** * Parse CSV content into rows * Handles quoted values that may contain newlines * @param {string} content - CSV content * @returns {object} Parsed CSV with header and rows */ function parseCSV(content) { const records = parseCSVContent(content); if (records.length === 0) { return { header: [], rows: [] }; } const header = records[0]; const rows = records.slice(1).map((values, index) => { const row = {}; header.forEach((col, i) => { row[col] = values[i] || ''; }); row._lineNumber = index + 2; // 1-based, accounting for header return row; }); return { header, rows }; } /** * Parse entire CSV content handling multi-line quoted values * @param {string} content - CSV content * @returns {string[][]} Array of records (each record is array of values) */ function parseCSVContent(content) { const records = []; let currentRecord = []; let currentValue = ''; let inQuotes = false; for (let i = 0; i < content.length; i++) { const char = content[i]; const nextChar = content[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { // Escaped quote currentValue += '"'; i++; } else { // Toggle quote state inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { // End of field currentRecord.push(currentValue); currentValue = ''; } else if (char === '\n' && !inQuotes) { // End of record currentRecord.push(currentValue); if (currentRecord.some(v => v !== '')) { records.push(currentRecord); } currentRecord = []; currentValue = ''; } else if (char === '\r') { // Skip carriage return continue; } else { currentValue += char; } } // Don't forget the last field and record if (currentValue !== '' || currentRecord.length > 0) { currentRecord.push(currentValue); if (currentRecord.some(v => v !== '')) { records.push(currentRecord); } } return records; } /** * Parse a single CSV line handling quoted values * @param {string} line - CSV line * @returns {string[]} Array of values */ function parseCSVLine(line) { const values = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; const nextChar = line[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { values.push(current); current = ''; } else { current += char; } } values.push(current); return values; } /** * Manifest Validator class */ class ManifestValidator { constructor(options = {}) { this.basePath = options.basePath || process.cwd(); this.aiosCoreDir = path.join(this.basePath, '.aios-core'); this.manifestDir = path.join(this.aiosCoreDir, 'manifests'); this.verbose = options.verbose || false; } /** * Validate all manifests * @returns {Promise<object>} Validation results */ async validateAll() { const results = { agents: null, workers: null, tasks: null, summary: { totalManifests: 3, valid: 0, invalid: 0, missing: [], orphan: [], }, }; try { // Validate all manifests in parallel const [agents, workers, tasks] = await Promise.all([ this.validateManifest('agents.csv', this.getAgentsSchema()), this.validateManifest('workers.csv', this.getWorkersSchema()), this.validateManifest('tasks.csv', this.getTasksSchema()), ]); results.agents = agents; results.workers = workers; results.tasks = tasks; // Build summary [agents, workers, tasks].forEach(r => { if (r.valid) results.summary.valid++; else results.summary.invalid++; results.summary.missing.push(...r.missingFiles); results.summary.orphan.push(...r.orphanFiles); }); } catch (error) { results.error = error.message; } return results; } /** * Validate a single manifest file * @param {string} filename - Manifest filename * @param {object} schema - Schema definition * @returns {Promise<object>} Validation result */ async validateManifest(filename, schema) { const filePath = path.join(this.manifestDir, filename); const result = { filename, path: filePath, valid: true, errors: [], warnings: [], rowCount: 0, missingFiles: [], orphanFiles: [], }; try { // Check file exists try { await fs.access(filePath); } catch { result.valid = false; result.errors.push(`Manifest file not found: ${filename}`); return result; } // Read and parse CSV const content = await fs.readFile(filePath, 'utf8'); const { header, rows } = parseCSV(content); result.rowCount = rows.length; // Validate header const headerErrors = this.validateHeader(header, schema); if (headerErrors.length > 0) { result.valid = false; result.errors.push(...headerErrors); return result; } // Validate rows const idsSeen = new Set(); for (const row of rows) { // Check for duplicate IDs if (row.id) { if (idsSeen.has(row.id)) { result.errors.push(`Duplicate ID '${row.id}' at line ${row._lineNumber}`); result.valid = false; } idsSeen.add(row.id); } // Validate required fields for (const col of schema.required) { if (!row[col] || row[col].trim() === '') { result.errors.push(`Missing required field '${col}' at line ${row._lineNumber}`); result.valid = false; } } // Validate status if (row.status && !['active', 'deprecated', 'experimental'].includes(row.status)) { result.warnings.push(`Invalid status '${row.status}' at line ${row._lineNumber}`); } // Check file existence if (row.file_path) { const fullPath = path.join(this.basePath, row.file_path); try { await fs.access(fullPath); } catch { result.missingFiles.push({ id: row.id, path: row.file_path, line: row._lineNumber, }); result.valid = false; // Missing files invalidate the manifest } } } // Check for orphan files (files on disk not in manifest) await this.checkOrphanFiles(result, schema); } catch (error) { result.valid = false; result.errors.push(`Error validating ${filename}: ${error.message}`); } return result; } /** * Validate CSV header against schema * @param {string[]} header - CSV header columns * @param {object} schema - Schema definition * @returns {string[]} Array of error messages */ validateHeader(header, schema) { const errors = []; // Check all required columns exist for (const col of schema.required) { if (!header.includes(col)) { errors.push(`Missing required column: ${col}`); } } return errors; } /** * Check for orphan files not in manifest * @param {object} result - Validation result to update * @param {object} schema - Schema with sourceDir */ async checkOrphanFiles(result, schema) { if (!schema.sourceDir) return; const sourceDir = path.join(this.basePath, schema.sourceDir); try { const files = await fs.readdir(sourceDir); const csvContent = await fs.readFile(result.path, 'utf8'); for (const file of files) { if (!file.endsWith('.md')) continue; const relPath = `${schema.sourceDir}/${file}`; if (!csvContent.includes(relPath)) { result.orphanFiles.push({ path: relPath, file, }); } } } catch (_error) { // Directory doesn't exist or other error - skip orphan check } } /** * Get agents.csv schema * @returns {object} Schema definition */ getAgentsSchema() { return { required: ['id', 'name', 'version', 'status', 'file_path'], optional: ['archetype', 'icon', 'when_to_use'], sourceDir: '.aios-core/development/agents', }; } /** * Get workers.csv schema * @returns {object} Schema definition */ getWorkersSchema() { return { required: ['id', 'name', 'category', 'file_path', 'status'], optional: ['subcategory', 'executor_types', 'tags'], sourceDir: null, // Workers come from registry }; } /** * Get tasks.csv schema * @returns {object} Schema definition */ getTasksSchema() { return { required: ['id', 'name', 'category', 'file_path', 'status'], optional: ['format', 'has_elicitation'], sourceDir: '.aios-core/development/tasks', }; } /** * Format validation results for CLI output * @param {object} results - Validation results * @returns {string} Formatted output */ formatResults(results) { const lines = []; for (const [type, result] of Object.entries(results)) { if (type === 'summary' || type === 'error') continue; const status = result.valid ? '✓' : '✗'; const errorInfo = result.errors.length > 0 ? `, ${result.errors.length} errors` : ''; lines.push(`${status} ${result.filename}: ${result.rowCount} entries${errorInfo}`); if (this.verbose) { for (const error of result.errors) { lines.push(` ✗ ${error}`); } for (const warning of result.warnings) { lines.push(` ⚠ ${warning}`); } for (const missing of result.missingFiles) { lines.push(` ✗ Missing file: ${missing.path} (ID: ${missing.id})`); } for (const orphan of result.orphanFiles) { lines.push(` ⚠ Orphan file: ${orphan.path}`); } } } const _totalValid = results.summary.valid; const totalInvalid = results.summary.invalid; const allValid = totalInvalid === 0; lines.push(''); if (allValid) { lines.push('✅ All manifests valid!'); } else { lines.push(`❌ Validation failed: ${totalInvalid} manifest(s) with errors`); } if (results.summary.missing.length > 0) { lines.push(`⚠ ${results.summary.missing.length} missing file(s) detected`); } if (results.summary.orphan.length > 0) { lines.push(`⚠ ${results.summary.orphan.length} orphan file(s) detected`); } return lines.join('\n'); } } // Factory function function createManifestValidator(options = {}) { return new ManifestValidator(options); } module.exports = { ManifestValidator, createManifestValidator, parseCSV, parseCSVLine, parseCSVContent, };