UNPKG

aios-core

Version:

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

856 lines (756 loc) 25.2 kB
/** * Squad Validator Utility * * Validates squads against: * 1. JSON Schema (squad.yaml/config.yaml) * 2. Directory structure (task-first architecture) * 3. Task format (TASK-FORMAT-SPECIFICATION-V1) * 4. Agent definitions * * Used by: squad-creator agent (*validate-squad task) * * @module squad-validator * @version 1.0.0 * @see Story SQS-3: Squad Validator + JSON Schema */ const Ajv = require('ajv'); const fs = require('fs').promises; const path = require('path'); const yaml = require('js-yaml'); /** * Load schema - handle both require and dynamic loading * @returns {Object} JSON Schema */ function loadSchema() { try { return require('../../../schemas/squad-schema.json'); } catch { // Fallback for test environments return null; } } /** * Supported manifest file names in order of preference * @constant {string[]} */ const MANIFEST_FILES = ['squad.yaml', 'config.yaml']; /** * Required fields in tasks (TASK-FORMAT-SPECIFICATION-V1) * @constant {string[]} */ const TASK_REQUIRED_FIELDS = [ 'task', 'responsavel', 'responsavel_type', 'atomic_layer', 'Entrada', 'Saida', 'Checklist', ]; /** * Error codes for SquadValidatorError * @enum {string} */ const ValidationErrorCodes = { MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND', YAML_PARSE_ERROR: 'YAML_PARSE_ERROR', SCHEMA_ERROR: 'SCHEMA_ERROR', DEPRECATED_MANIFEST: 'DEPRECATED_MANIFEST', MISSING_DIRECTORY: 'MISSING_DIRECTORY', NO_TASKS: 'NO_TASKS', TASK_MISSING_FIELD: 'TASK_MISSING_FIELD', TASK_READ_ERROR: 'TASK_READ_ERROR', AGENT_INVALID_FORMAT: 'AGENT_INVALID_FORMAT', FILE_NOT_FOUND: 'FILE_NOT_FOUND', INVALID_NAMING: 'INVALID_NAMING', }; /** * Validation result structure * @typedef {Object} ValidationResult * @property {boolean} valid - Whether validation passed (no errors) * @property {Array<ValidationError>} errors - Critical errors that fail validation * @property {Array<ValidationWarning>} warnings - Non-critical issues * @property {Array<string>} suggestions - Helpful suggestions */ /** * Validation error structure * @typedef {Object} ValidationError * @property {string} code - Error code from ValidationErrorCodes * @property {string} message - Human-readable error message * @property {string} [suggestion] - Suggested fix * @property {string} [path] - JSON path where error occurred * @property {string} [file] - File where error occurred */ /** * Squad Validator class for validating squad structure and content */ class SquadValidator { /** * Create a SquadValidator instance * @param {Object} [options={}] - Configuration options * @param {boolean} [options.verbose=false] - Enable verbose logging * @param {boolean} [options.strict=false] - Treat warnings as errors * @param {Object} [options.schema] - Custom schema (for testing) */ constructor(options = {}) { this.verbose = options.verbose || false; this.strict = options.strict || false; // Initialize AJV with schema this.ajv = new Ajv({ allErrors: true, verbose: true }); const schema = options.schema || loadSchema(); if (schema) { this.validateSchema = this.ajv.compile(schema); } else { this.validateSchema = null; } } /** * Log message if verbose mode is enabled * @private * @param {string} message - Message to log */ _log(message) { if (this.verbose) { console.log(`[SquadValidator] ${message}`); } } /** * Validate entire squad * * Runs all validation checks: * 1. Manifest validation (schema) * 2. Directory structure * 3. Task format * 4. Agent definitions * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result * * @example * const validator = new SquadValidator(); * const result = await validator.validate('./squads/my-squad'); * if (result.valid) { * console.log('Squad is valid!'); * } else { * console.log('Errors:', result.errors); * } */ async validate(squadPath) { this._log(`Validating squad: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; // 1. Validate manifest const manifestResult = await this.validateManifest(squadPath); this._mergeResults(result, manifestResult); // 2. Validate directory structure const structureResult = await this.validateStructure(squadPath); this._mergeResults(result, structureResult); // 3. Validate tasks (task-first!) const tasksResult = await this.validateTasks(squadPath); this._mergeResults(result, tasksResult); // 4. Validate agents const agentsResult = await this.validateAgents(squadPath); this._mergeResults(result, agentsResult); // 5. Validate config references (SQS-10) const configResult = await this.validateConfigReferences(squadPath); this._mergeResults(result, configResult); // 6. Validate workflows (GAP-2) const workflowsResult = await this.validateWorkflows(squadPath); this._mergeResults(result, workflowsResult); // In strict mode, warnings become errors if (this.strict && result.warnings.length > 0) { result.errors.push(...result.warnings); result.warnings = []; result.valid = false; } this._log(`Validation complete: ${result.valid ? 'VALID' : 'INVALID'}`); return result; } /** * Validate manifest against JSON Schema * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for manifest */ async validateManifest(squadPath) { this._log(`Validating manifest in: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; // Find manifest file const manifestPath = await this._findManifest(squadPath); if (!manifestPath) { result.valid = false; result.errors.push({ code: ValidationErrorCodes.MANIFEST_NOT_FOUND, message: `No manifest found in ${squadPath}/ (expected squad.yaml or config.yaml)`, suggestion: 'Create squad.yaml with required fields: name, version', }); return result; } // Deprecation warning for config.yaml const manifestFilename = path.basename(manifestPath); if (manifestFilename === 'config.yaml') { result.warnings.push({ code: ValidationErrorCodes.DEPRECATED_MANIFEST, message: 'config.yaml is deprecated, rename to squad.yaml', suggestion: 'mv config.yaml squad.yaml', file: manifestFilename, }); } // Parse YAML let manifest; try { const content = await fs.readFile(manifestPath, 'utf-8'); manifest = yaml.load(content); } catch (error) { result.valid = false; result.errors.push({ code: ValidationErrorCodes.YAML_PARSE_ERROR, message: `Failed to parse manifest: ${error.message}`, suggestion: 'Check YAML syntax - use a YAML linter', file: manifestFilename, }); return result; } // Validate against schema if (this.validateSchema && manifest) { const schemaValid = this.validateSchema(manifest); if (!schemaValid) { result.valid = false; for (const err of this.validateSchema.errors) { result.errors.push({ code: ValidationErrorCodes.SCHEMA_ERROR, path: err.instancePath || '/', message: err.message, suggestion: this._getSchemaSuggestion(err), }); } } } this._log(`Manifest validation: ${result.valid ? 'PASS' : 'FAIL'}`); return result; } /** * Validate directory structure * * Checks for expected directories in task-first architecture. * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for structure */ async validateStructure(squadPath) { this._log(`Validating structure of: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; // Expected directories (task-first: tasks and agents are primary) const expectedDirs = ['tasks', 'agents']; const _optionalDirs = [ 'workflows', 'checklists', 'templates', 'tools', 'scripts', 'data', 'config', ]; // Check expected directories (warn if missing) for (const dir of expectedDirs) { const dirPath = path.join(squadPath, dir); if (!(await this._pathExists(dirPath))) { result.warnings.push({ code: ValidationErrorCodes.MISSING_DIRECTORY, message: `Expected directory not found: ${dir}/`, suggestion: `mkdir ${dir} (task-first architecture recommends tasks/ and agents/)`, }); } } // Validate files referenced in manifest exist const manifestPath = await this._findManifest(squadPath); if (manifestPath) { try { const content = await fs.readFile(manifestPath, 'utf-8'); const manifest = yaml.load(content); if (manifest && manifest.components) { await this._validateReferencedFiles( squadPath, manifest.components, result, ); } } catch { // Already handled in manifest validation } } this._log(`Structure validation: ${result.errors.length} errors, ${result.warnings.length} warnings`); return result; } /** * Validate task files against TASK-FORMAT-SPECIFICATION-V1 * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for tasks */ async validateTasks(squadPath) { this._log(`Validating tasks in: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; const tasksDir = path.join(squadPath, 'tasks'); // Check if tasks directory exists if (!(await this._pathExists(tasksDir))) { return result; // Already warned in structure validation } // Get task files let files; try { files = await fs.readdir(tasksDir); } catch (error) { result.errors.push({ code: ValidationErrorCodes.TASK_READ_ERROR, message: `Failed to read tasks directory: ${error.message}`, }); result.valid = false; return result; } const taskFiles = files.filter((f) => f.endsWith('.md')); if (taskFiles.length === 0) { result.warnings.push({ code: ValidationErrorCodes.NO_TASKS, message: 'No task files found in tasks/', suggestion: 'Task-first architecture: Create at least one task file', }); return result; } // Validate each task file for (const taskFile of taskFiles) { const taskPath = path.join(tasksDir, taskFile); const taskResult = await this._validateTaskFile(taskPath); this._mergeResults(result, taskResult); } this._log(`Task validation: ${taskFiles.length} files checked`); return result; } /** * Validate config references in squad.yaml * Implements AC10.4: Validates that referenced config files actually exist * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for config references * @see Story SQS-10: Project Config Reference for Squads */ async validateConfigReferences(squadPath) { this._log(`Validating config references in: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; // Find and parse manifest const manifestPath = await this._findManifest(squadPath); if (!manifestPath) { return result; // Already handled in manifest validation } let manifest; try { const content = await fs.readFile(manifestPath, 'utf-8'); manifest = yaml.load(content); } catch { return result; // Already handled in manifest validation } // Check config section if (!manifest || !manifest.config) { return result; // No config section to validate } const configFields = ['coding-standards', 'tech-stack', 'source-tree']; for (const field of configFields) { const configPath = manifest.config[field]; if (!configPath) continue; const resolvedPath = await this._resolveConfigPath(squadPath, configPath); if (!resolvedPath) { // Check if this is a project-level reference that doesn't exist if (configPath.includes('..') || configPath.includes('docs/framework')) { result.warnings.push({ code: ValidationErrorCodes.FILE_NOT_FOUND, message: `Config reference not found: ${configPath}`, suggestion: `Create the file at ${configPath} or update squad.yaml to use local config (config/${field}.md)`, }); } else { // Local config file missing - this is an error result.errors.push({ code: ValidationErrorCodes.FILE_NOT_FOUND, message: `Local config file not found: ${configPath}`, suggestion: `Create ${path.join(squadPath, configPath)} or remove from config section`, }); result.valid = false; } } } this._log(`Config validation: ${result.errors.length} errors, ${result.warnings.length} warnings`); return result; } /** * Validate agent definitions * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for agents */ async validateAgents(squadPath) { this._log(`Validating agents in: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; const agentsDir = path.join(squadPath, 'agents'); // Check if agents directory exists if (!(await this._pathExists(agentsDir))) { return result; // Already warned in structure validation } // Get agent files let files; try { files = await fs.readdir(agentsDir); } catch (_error) { return result; } const agentFiles = files.filter((f) => f.endsWith('.md')); // Validate each agent file for (const agentFile of agentFiles) { const agentPath = path.join(agentsDir, agentFile); try { const content = await fs.readFile(agentPath, 'utf-8'); // Check for basic agent structure (YAML frontmatter or markdown structure) const hasYamlFrontmatter = content.includes('agent:'); const hasMarkdownHeading = content.match(/^#\s+.+/m); if (!hasYamlFrontmatter && !hasMarkdownHeading) { result.warnings.push({ code: ValidationErrorCodes.AGENT_INVALID_FORMAT, file: agentFile, message: 'Agent file may not follow AIOS agent definition format', suggestion: 'Use agent: YAML frontmatter or markdown heading structure', }); } // Check naming convention (kebab-case) if (!this._isKebabCase(path.basename(agentFile, '.md'))) { result.warnings.push({ code: ValidationErrorCodes.INVALID_NAMING, file: agentFile, message: 'Agent filename should be kebab-case', suggestion: 'Rename to use lowercase letters and hyphens only', }); } } catch (error) { result.errors.push({ code: ValidationErrorCodes.TASK_READ_ERROR, file: agentFile, message: `Failed to read agent file: ${error.message}`, }); result.valid = false; } } this._log(`Agent validation: ${agentFiles.length} files checked`); return result; } /** * Format validation result for display * * @param {ValidationResult} result - Validation result * @param {string} squadPath - Path to squad * @returns {string} Formatted output */ formatResult(result, squadPath) { const lines = []; lines.push(`Validating squad: ${squadPath}/`); lines.push(''); // Errors if (result.errors.length > 0) { lines.push(`Errors: ${result.errors.length}`); for (const err of result.errors) { const filePart = err.file ? ` (${err.file})` : ''; const pathPart = err.path ? ` at ${err.path}` : ''; lines.push(` - [${err.code}]${pathPart}${filePart}: ${err.message}`); if (err.suggestion) { lines.push(` Suggestion: ${err.suggestion}`); } } lines.push(''); } // Warnings if (result.warnings.length > 0) { lines.push(`Warnings: ${result.warnings.length}`); for (const warn of result.warnings) { const filePart = warn.file ? ` (${warn.file})` : ''; lines.push(` - [${warn.code}]${filePart}: ${warn.message}`); if (warn.suggestion) { lines.push(` Suggestion: ${warn.suggestion}`); } } lines.push(''); } // Result if (result.valid) { if (result.warnings.length > 0) { lines.push('Result: VALID (with warnings)'); } else { lines.push('Result: VALID'); } } else { lines.push('Result: INVALID'); } return lines.join('\n'); } /** * Validate workflow files in squad using WorkflowValidator * * @param {string} squadPath - Path to squad directory * @returns {Promise<ValidationResult>} Validation result for workflows */ async validateWorkflows(squadPath) { this._log(`Validating workflows in: ${squadPath}`); const result = { valid: true, errors: [], warnings: [], suggestions: [], }; const workflowsDir = path.join(squadPath, 'workflows'); if (!(await this._pathExists(workflowsDir))) { return result; // No workflows dir is fine } let files; try { files = await fs.readdir(workflowsDir); } catch { return result; } const yamlFiles = files.filter( (f) => f.endsWith('.yaml') || f.endsWith('.yml'), ); if (yamlFiles.length === 0) { return result; // No workflow files to validate } // Import WorkflowValidator let WorkflowValidator; try { ({ WorkflowValidator } = require('../workflow-validator')); } catch { result.warnings.push({ code: 'WORKFLOW_VALIDATOR_UNAVAILABLE', message: 'WorkflowValidator module not found, skipping workflow content validation', suggestion: 'Ensure workflow-validator.js exists in .aios-core/development/scripts/', }); return result; } const coreAgentsPath = path.join(process.cwd(), '.aios-core', 'development', 'agents'); const validator = new WorkflowValidator({ verbose: this.verbose, strict: this.strict, agentsPath: coreAgentsPath, squadAgentsPath: path.join(squadPath, 'agents'), }); for (const yamlFile of yamlFiles) { const workflowPath = path.join(workflowsDir, yamlFile); const workflowResult = await validator.validate(workflowPath); this._mergeResults(result, workflowResult); } this._log(`Workflow validation: ${yamlFiles.length} files checked`); return result; } // ============ Private Helper Methods ============ /** * Find manifest file in squad directory * @private */ async _findManifest(squadPath) { for (const filename of MANIFEST_FILES) { const manifestPath = path.join(squadPath, filename); if (await this._pathExists(manifestPath)) { return manifestPath; } } return null; } /** * Check if path exists * @private */ async _pathExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } /** * Resolve config path - check both local and project-level paths * Implements AC10.4: Validator Path Resolution * * @param {string} squadPath - Squad directory * @param {string} configPath - Path from squad.yaml config section * @returns {Promise<string|null>} Resolved absolute path or null if not found * @see Story SQS-10: Project Config Reference for Squads * @private */ async _resolveConfigPath(squadPath, configPath) { if (!configPath) return null; // Resolve path relative to squad directory // path.resolve handles both local paths (config/file.md) and relative paths (../../docs/framework/...) // Simplified from redundant path.resolve + path.join (CodeRabbit nitpick) const resolvedPath = path.resolve(squadPath, configPath); if (await this._pathExists(resolvedPath)) { this._log(`Resolved config path: ${configPath} -> ${resolvedPath}`); return resolvedPath; } this._log(`Config path not found: ${configPath}`); return null; } /** * Validate a single task file * @private */ async _validateTaskFile(taskPath) { const result = { valid: true, errors: [], warnings: [], suggestions: [], }; const filename = path.basename(taskPath); try { const content = await fs.readFile(taskPath, 'utf-8'); // Check for required fields (case-insensitive, handle accents) for (const field of TASK_REQUIRED_FIELDS) { // Create patterns that handle Portuguese accents const patterns = [ new RegExp(`^[#*-]*\\s*${field}\\s*:`, 'im'), new RegExp( `^[#*-]*\\s*${field.replace(/a/g, '[aá]').replace(/i/g, '[ií]')}\\s*:`, 'im', ), // Also check for markdown headers with the field new RegExp(`^#+\\s*${field}`, 'im'), ]; const found = patterns.some((p) => p.test(content)); if (!found) { result.warnings.push({ code: ValidationErrorCodes.TASK_MISSING_FIELD, file: filename, message: `Task missing recommended field: ${field}`, suggestion: `Add "${field}:" to ${filename} (TASK-FORMAT-SPECIFICATION-V1)`, }); } } // Check naming convention if (!this._isKebabCase(path.basename(filename, '.md'))) { result.warnings.push({ code: ValidationErrorCodes.INVALID_NAMING, file: filename, message: 'Task filename should be kebab-case', suggestion: 'Rename to use lowercase letters and hyphens only', }); } } catch (error) { result.errors.push({ code: ValidationErrorCodes.TASK_READ_ERROR, file: filename, message: `Failed to read task: ${error.message}`, }); result.valid = false; } return result; } /** * Validate files referenced in manifest components * @private */ async _validateReferencedFiles(squadPath, components, result) { const componentDirs = { tasks: 'tasks', agents: 'agents', workflows: 'workflows', checklists: 'checklists', templates: 'templates', tools: 'tools', scripts: 'scripts', }; for (const [component, dir] of Object.entries(componentDirs)) { if (components[component] && Array.isArray(components[component])) { for (const file of components[component]) { const filePath = path.join(squadPath, dir, file); if (!(await this._pathExists(filePath))) { result.errors.push({ code: ValidationErrorCodes.FILE_NOT_FOUND, message: `Referenced file not found: ${dir}/${file}`, suggestion: `Create ${filePath} or remove from components.${component}`, }); result.valid = false; } } } } } /** * Check if string is kebab-case * @private */ _isKebabCase(str) { return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(str); } /** * Get suggestion for schema error * @private */ _getSchemaSuggestion(error) { const suggestions = { 'must match pattern': 'Use correct format (kebab-case for names, semver for versions)', 'must be string': 'Wrap value in quotes', 'must be array': 'Use YAML array syntax: [item1, item2] or - item', 'must have required property': 'Add the missing required property', 'must be equal to one of the allowed values': 'Use one of the allowed values', }; for (const [key, suggestion] of Object.entries(suggestions)) { if (error.message && error.message.includes(key)) { return suggestion; } } return 'Check squad.yaml syntax against the schema'; } /** * Merge validation results * @private */ _mergeResults(target, source) { target.errors.push(...(source.errors || [])); target.warnings.push(...(source.warnings || [])); target.suggestions.push(...(source.suggestions || [])); if (source.errors && source.errors.length > 0) { target.valid = false; } } } module.exports = { SquadValidator, ValidationErrorCodes, TASK_REQUIRED_FIELDS, MANIFEST_FILES, };