aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
495 lines (433 loc) • 15.8 kB
JavaScript
/**
* Gate Evaluator - Story 0.6
*
* Epic: Epic 0 - ADE Master Orchestrator
*
* Evaluates quality gates between epics to ensure bad outputs don't propagate.
*
* Features:
* - AC1: Gate check after each epic completes
* - AC2: Gate verdicts: APPROVED, NEEDS_REVISION, BLOCKED
* - AC3: BLOCKED halts pipeline and escalates
* - AC4: NEEDS_REVISION returns to previous epic
* - AC5: Gates configurable in core-config.yaml
* - AC6: Gate results saved in state
* - AC7: Strict mode: any gate fail = halt
*
* @module core/orchestration/gate-evaluator
* @version 1.0.0
*/
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
// ═══════════════════════════════════════════════════════════════════════════════════
// GATE VERDICTS (AC2)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Gate verdict enum
*/
const GateVerdict = {
/** Output meets all quality criteria */
APPROVED: 'approved',
/** Output needs revision, return to previous epic */
NEEDS_REVISION: 'needs_revision',
/** Critical issue, halt pipeline */
BLOCKED: 'blocked',
};
/**
* Default gate configurations
*/
const DEFAULT_GATE_CONFIG = {
// Epic 3 -> Epic 4 gate
epic3_to_epic4: {
blocking: true,
minScore: 3.0,
requireApproval: false,
checks: ['spec_exists', 'complexity_assessed', 'requirements_defined'],
},
// Epic 4 -> Epic 6 gate
epic4_to_epic6: {
blocking: true,
requireTests: true,
minTestCoverage: 0,
checks: ['plan_complete', 'implementation_exists', 'no_critical_errors'],
},
};
// ═══════════════════════════════════════════════════════════════════════════════════
// GATE EVALUATOR CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* GateEvaluator - Evaluates quality gates between epics (AC1)
*/
class GateEvaluator {
/**
* @param {Object} options - Configuration options
* @param {string} options.projectRoot - Project root path
* @param {boolean} [options.strictMode=false] - Strict mode: any fail = halt (AC7)
* @param {Object} [options.gateConfig] - Custom gate configurations (AC5)
*/
constructor(options = {}) {
this.projectRoot = options.projectRoot || process.cwd();
this.strictMode = options.strictMode ?? false;
this.gateConfig = options.gateConfig || null;
// Results storage (AC6)
this.results = [];
this.logs = [];
}
/**
* Load gate configuration from core-config.yaml (AC5)
* @private
*/
async _loadConfig() {
if (this.gateConfig) {
return this.gateConfig;
}
try {
const configPath = path.join(this.projectRoot, '.aios-core', 'core-config.yaml');
if (await fs.pathExists(configPath)) {
const content = await fs.readFile(configPath, 'utf8');
const config = yaml.load(content);
if (config?.autoClaude?.orchestrator?.gates) {
return { ...DEFAULT_GATE_CONFIG, ...config.autoClaude.orchestrator.gates };
}
}
} catch (error) {
this._log(`Failed to load gate config: ${error.message}`, 'warn');
}
return DEFAULT_GATE_CONFIG;
}
/**
* Get gate key for transition
* @private
*/
_getGateKey(fromEpic, toEpic) {
return `epic${fromEpic}_to_epic${toEpic}`;
}
/**
* Evaluate gate between epics (AC1)
*
* @param {number} fromEpic - Source epic number
* @param {number} toEpic - Target epic number
* @param {Object} epicResult - Result from the source epic
* @returns {Promise<Object>} Gate evaluation result
*/
async evaluate(fromEpic, toEpic, epicResult) {
const gateKey = this._getGateKey(fromEpic, toEpic);
this._log(`Evaluating gate: ${gateKey}`, 'info');
const config = await this._loadConfig();
const gateConfig = config[gateKey] || { blocking: false };
const result = {
gate: gateKey,
fromEpic,
toEpic,
timestamp: new Date().toISOString(),
verdict: GateVerdict.APPROVED,
score: 0,
checks: [],
issues: [],
config: gateConfig,
};
try {
// Run gate checks
const checks = await this._runGateChecks(fromEpic, toEpic, epicResult, gateConfig);
result.checks = checks;
// Calculate score
const passedChecks = checks.filter((c) => c.passed).length;
result.score = checks.length > 0 ? (passedChecks / checks.length) * 5 : 5;
// Collect issues
result.issues = checks
.filter((c) => !c.passed)
.map((c) => ({
check: c.name,
message: c.message,
severity: c.severity || 'medium',
}));
// Determine verdict (AC2)
result.verdict = this._determineVerdict(result, gateConfig);
this._log(`Gate ${gateKey}: ${result.verdict} (score: ${result.score.toFixed(1)})`, 'info');
} catch (error) {
result.verdict = GateVerdict.BLOCKED;
result.issues.push({
check: 'gate_evaluation',
message: error.message,
severity: 'critical',
});
this._log(`Gate evaluation failed: ${error.message}`, 'error');
}
// Store result (AC6)
this.results.push(result);
return result;
}
/**
* Run individual gate checks
* @private
*/
async _runGateChecks(fromEpic, toEpic, epicResult, gateConfig) {
const checks = [];
// Get check list for this gate
const checkNames = gateConfig.checks || this._getDefaultChecks(fromEpic, toEpic);
for (const checkName of checkNames) {
const checkResult = await this._runCheck(checkName, fromEpic, epicResult, gateConfig);
checks.push(checkResult);
}
// Additional config-based checks
// Only check minScore if epic actually provides a score
if (gateConfig.minScore !== undefined && epicResult.score !== undefined) {
checks.push({
name: 'min_score',
passed: epicResult.score >= gateConfig.minScore,
message: `Epic score ${epicResult.score} ${epicResult.score >= gateConfig.minScore ? '>=' : '<'} min ${gateConfig.minScore}`,
severity: 'high',
});
}
// Only check requireTests for epics that actually run tests (epic 4, 6)
// Skip check if testResults is undefined or marked as skipped/not-ran
if (gateConfig.requireTests && epicResult.testResults !== undefined) {
// Handle both array format (actual test results) and object format (status)
const isArray = Array.isArray(epicResult.testResults);
const testsSkipped =
!isArray && (epicResult.testResults?.skipped || epicResult.testResults?.ran === false);
const hasTests = isArray ? epicResult.testResults.length > 0 : epicResult.testsRun > 0;
// Only fail if tests were expected AND not skipped AND didn't run
if (!testsSkipped) {
checks.push({
name: 'require_tests',
passed: hasTests,
message: hasTests ? 'Tests were executed' : 'No tests were run',
severity: 'high',
});
}
}
if (gateConfig.minTestCoverage !== undefined && gateConfig.minTestCoverage > 0) {
const coverage = epicResult.testCoverage || 0;
checks.push({
name: 'min_coverage',
passed: coverage >= gateConfig.minTestCoverage,
message: `Test coverage ${coverage}% ${coverage >= gateConfig.minTestCoverage ? '>=' : '<'} min ${gateConfig.minTestCoverage}%`,
severity: 'medium',
});
}
return checks;
}
/**
* Run a single check
* @private
*/
async _runCheck(checkName, fromEpic, epicResult, _gateConfig) {
const result = {
name: checkName,
passed: false,
message: '',
severity: 'medium',
};
switch (checkName) {
// Epic 3 checks
case 'spec_exists':
result.passed =
!!epicResult.specPath || !!epicResult.artifacts?.find((a) => a.type === 'spec');
result.message = result.passed ? 'Spec file exists' : 'No spec file generated';
result.severity = 'critical';
break;
case 'complexity_assessed':
result.passed = !!epicResult.complexity;
result.message = result.passed
? `Complexity: ${epicResult.complexity}`
: 'Complexity not assessed';
result.severity = 'medium';
break;
case 'requirements_defined':
result.passed =
Array.isArray(epicResult.requirements) && epicResult.requirements.length > 0;
result.message = result.passed
? `${epicResult.requirements?.length || 0} requirements defined`
: 'No requirements defined';
result.severity = 'medium'; // Medium severity - requirements can be implicit
break;
// Epic 4 checks
case 'plan_complete':
result.passed = !!epicResult.planPath || epicResult.planComplete === true;
result.message = result.passed ? 'Implementation plan complete' : 'Plan not complete';
result.severity = 'high';
break;
case 'implementation_exists':
result.passed = !!epicResult.implementationPath || epicResult.codeChanges?.length > 0;
result.message = result.passed ? 'Implementation exists' : 'No implementation found';
result.severity = 'critical';
break;
case 'no_critical_errors': {
const criticalErrors = (epicResult.errors || []).filter((e) => e.severity === 'critical');
result.passed = criticalErrors.length === 0;
result.message = result.passed
? 'No critical errors'
: `${criticalErrors.length} critical errors found`;
result.severity = 'critical';
break;
}
// Epic 6 checks
case 'qa_report_exists':
result.passed = !!epicResult.reportPath || !!epicResult.qaReport;
result.message = result.passed ? 'QA report generated' : 'No QA report';
result.severity = 'medium';
break;
case 'verdict_generated':
result.passed = !!epicResult.verdict;
result.message = result.passed ? `QA verdict: ${epicResult.verdict}` : 'No QA verdict';
result.severity = 'medium';
break;
case 'tests_pass': {
const hasResults = Array.isArray(epicResult.testResults) && epicResult.testResults.length > 0;
const allPass = hasResults && epicResult.testResults.every((t) => t.passed);
result.passed = allPass;
result.message = !hasResults ? 'No test results' : (allPass ? 'All tests pass' : 'Some tests failed');
result.severity = 'high';
break;
}
default:
// Unknown check - pass by default
result.passed = true;
result.message = `Unknown check: ${checkName}`;
result.severity = 'low';
}
return result;
}
/**
* Get default checks for a gate
* @private
*/
_getDefaultChecks(fromEpic, _toEpic) {
switch (fromEpic) {
case 3:
return ['spec_exists', 'complexity_assessed'];
case 4:
return ['plan_complete', 'no_critical_errors'];
case 6:
return ['qa_report_exists', 'verdict_generated'];
default:
return [];
}
}
/**
* Determine verdict based on checks and config (AC2)
* @private
*/
_determineVerdict(result, gateConfig) {
// Strict mode: any failure = blocked (AC7)
if (this.strictMode && result.issues.length > 0) {
return GateVerdict.BLOCKED;
}
// Check for critical issues
const criticalIssues = result.issues.filter((i) => i.severity === 'critical');
if (criticalIssues.length > 0) {
return GateVerdict.BLOCKED;
}
// Check minimum score if configured
if (gateConfig.minScore !== undefined && result.score < gateConfig.minScore) {
return gateConfig.blocking ? GateVerdict.BLOCKED : GateVerdict.NEEDS_REVISION;
}
// Check for high severity issues
const highIssues = result.issues.filter((i) => i.severity === 'high');
if (highIssues.length > 0) {
// If gate is blocking, block; otherwise needs revision
return gateConfig.blocking ? GateVerdict.BLOCKED : GateVerdict.NEEDS_REVISION;
}
// Allow minor issues if configured
if (
gateConfig.allowMinorIssues &&
result.issues.every((i) => i.severity === 'low' || i.severity === 'medium')
) {
return GateVerdict.APPROVED;
}
// Any remaining issues - needs revision
if (result.issues.length > 0) {
return GateVerdict.NEEDS_REVISION;
}
return GateVerdict.APPROVED;
}
/**
* Check if verdict blocks pipeline (AC3)
*
* @param {string} verdict - Gate verdict
* @returns {boolean} True if should block
*/
shouldBlock(verdict) {
return verdict === GateVerdict.BLOCKED;
}
/**
* Check if verdict requires revision (AC4)
*
* @param {string} verdict - Gate verdict
* @returns {boolean} True if needs revision
*/
needsRevision(verdict) {
return verdict === GateVerdict.NEEDS_REVISION;
}
/**
* Get all gate results (AC6)
*
* @returns {Object[]} All gate evaluation results
*/
getResults() {
return [...this.results];
}
/**
* Get result for specific gate (AC6)
*
* @param {string} gateKey - Gate key (e.g., 'epic3_to_epic4')
* @returns {Object|null} Gate result or null
*/
getResult(gateKey) {
return this.results.find((r) => r.gate === gateKey) || null;
}
/**
* Get results summary
*
* @returns {Object} Summary of all gate evaluations
*/
getSummary() {
const approved = this.results.filter((r) => r.verdict === GateVerdict.APPROVED).length;
const needsRevision = this.results.filter(
(r) => r.verdict === GateVerdict.NEEDS_REVISION,
).length;
const blocked = this.results.filter((r) => r.verdict === GateVerdict.BLOCKED).length;
return {
total: this.results.length,
approved,
needsRevision,
blocked,
allPassed: blocked === 0 && needsRevision === 0,
averageScore:
this.results.length > 0
? this.results.reduce((sum, r) => sum + r.score, 0) / this.results.length
: 0,
};
}
/**
* Clear all results
*/
clear() {
this.results = [];
this.logs = [];
}
/**
* Log message
* @private
*/
_log(message, level = 'info') {
const timestamp = new Date().toISOString();
this.logs.push({ timestamp, level, message });
}
/**
* Get logs
*/
getLogs() {
return [...this.logs];
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
GateEvaluator,
GateVerdict,
DEFAULT_GATE_CONFIG,
};