UNPKG

aios-core

Version:

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

600 lines (527 loc) 17.3 kB
/** * Quality Metrics Collector * * Collects and manages quality gate metrics across all layers. * Provides APIs for recording runs, calculating aggregates, and cleanup. * * @module quality/metrics-collector * @version 1.0.0 * @story 3.11a - Quality Gates Metrics Collector */ const fs = require('fs').promises; const path = require('path'); const Ajv = require('ajv'); const addFormats = require('ajv-formats'); // Default configuration const DEFAULT_DATA_FILE = '.aios/data/quality-metrics.json'; const DEFAULT_RETENTION_DAYS = 30; /** * Create empty metrics structure * @returns {Object} Empty metrics object */ function createEmptyMetrics() { return { version: '1.0', lastUpdated: new Date().toISOString(), retentionDays: DEFAULT_RETENTION_DAYS, layers: { layer1: { passRate: 0, avgTimeMs: 0, totalRuns: 0, lastRun: null, }, layer2: { passRate: 0, avgTimeMs: 0, totalRuns: 0, lastRun: null, autoCatchRate: 0, coderabbit: { active: false, findingsCount: 0, severityBreakdown: { critical: 0, high: 0, medium: 0, low: 0, }, }, quinn: { findingsCount: 0, topCategories: [], }, }, layer3: { passRate: 0, avgTimeMs: 0, totalRuns: 0, lastRun: null, }, }, trends: { autoCatchRate: [], passRates: [], }, history: [], }; } /** * MetricsCollector class * Manages quality gate metrics collection, storage, and aggregation */ class MetricsCollector { /** * Create a new MetricsCollector instance * @param {Object} options - Configuration options * @param {string} [options.dataFile] - Path to data file * @param {number} [options.retentionDays] - Days to retain history * @param {string} [options.projectRoot] - Project root directory */ constructor(options = {}) { this.projectRoot = options.projectRoot || process.cwd(); this.dataFile = options.dataFile || path.join(this.projectRoot, DEFAULT_DATA_FILE); this.retentionDays = options.retentionDays || DEFAULT_RETENTION_DAYS; this._metrics = null; this._validator = null; this._lockFile = `${this.dataFile}.lock`; } /** * Initialize the JSON schema validator * @private */ async _initValidator() { if (this._validator) return; const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); const schemaPath = path.join( this.projectRoot, '.aios-core/quality/schemas/quality-metrics.schema.json', ); try { const schemaContent = await fs.readFile(schemaPath, 'utf8'); const schema = JSON.parse(schemaContent); this._validator = ajv.compile(schema); } catch (error) { // Schema file not found - validation will be skipped console.warn(`Warning: Schema file not found at ${schemaPath}`); this._validator = null; } } /** * Validate metrics against schema * @param {Object} metrics - Metrics object to validate * @returns {Object} Validation result { valid, errors } */ async validate(metrics) { await this._initValidator(); if (!this._validator) { return { valid: true, errors: null }; } const valid = this._validator(metrics); return { valid, errors: valid ? null : this._validator.errors, }; } /** * Acquire file lock for thread-safe operations * @private * @param {number} [timeout=5000] - Lock timeout in ms * @returns {Promise<boolean>} Whether lock was acquired */ async _acquireLock(timeout = 5000) { const startTime = Date.now(); const retryDelay = 50; while (Date.now() - startTime < timeout) { try { // Try to create lock file exclusively await fs.writeFile(this._lockFile, String(process.pid), { flag: 'wx' }); return true; } catch (error) { if (error.code === 'EEXIST') { // Lock exists, check if stale (> 30s old) try { const stat = await fs.stat(this._lockFile); if (Date.now() - stat.mtimeMs > 30000) { await fs.unlink(this._lockFile); continue; } } catch { // Lock file gone, retry continue; } await new Promise((resolve) => setTimeout(resolve, retryDelay)); } else if (error.code === 'ENOENT') { // Directory doesn't exist, create it await fs.mkdir(path.dirname(this._lockFile), { recursive: true }); } else { throw error; } } } return false; } /** * Release file lock * @private */ async _releaseLock() { try { await fs.unlink(this._lockFile); } catch { // Ignore errors on unlock } } /** * Ensure data directory exists * @private */ async _ensureDataDir() { const dir = path.dirname(this.dataFile); await fs.mkdir(dir, { recursive: true }); } /** * Load metrics from file * @returns {Promise<Object>} Metrics object */ async load() { if (this._metrics) return this._metrics; try { const content = await fs.readFile(this.dataFile, 'utf8'); this._metrics = JSON.parse(content); // Validate loaded metrics const { valid, errors } = await this.validate(this._metrics); if (!valid) { console.warn('Warning: Loaded metrics failed validation:', errors); } } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist, create empty metrics this._metrics = createEmptyMetrics(); this._metrics.retentionDays = this.retentionDays; } else { throw new Error(`Failed to load metrics: ${error.message}`); } } return this._metrics; } /** * Save metrics to file (thread-safe) * @param {Object} [metrics] - Metrics to save (uses internal if not provided) * @returns {Promise<void>} */ async save(metrics = null) { const metricsToSave = metrics || this._metrics; if (!metricsToSave) { throw new Error('No metrics to save'); } // Validate before saving const { valid, errors } = await this.validate(metricsToSave); if (!valid) { throw new Error(`Invalid metrics: ${JSON.stringify(errors)}`); } const locked = await this._acquireLock(); if (!locked) { throw new Error('Could not acquire lock for saving metrics'); } try { await this._ensureDataDir(); metricsToSave.lastUpdated = new Date().toISOString(); await fs.writeFile( this.dataFile, JSON.stringify(metricsToSave, null, 2), 'utf8', ); this._metrics = metricsToSave; } finally { await this._releaseLock(); } } /** * Record a run from any layer * @param {number} layer - Layer number (1, 2, or 3) * @param {Object} result - Run result * @param {boolean} result.passed - Whether the run passed * @param {number} [result.durationMs] - Duration in milliseconds * @param {number} [result.findingsCount] - Number of findings * @param {Object} [result.metadata] - Additional metadata * @returns {Promise<Object>} The recorded run */ async recordRun(layer, result) { if (![1, 2, 3].includes(layer)) { throw new Error('Layer must be 1, 2, or 3'); } const metrics = await this.load(); // Enforce retention policy before adding new entries (Story SQS-10 nitpick) // This prevents unbounded growth of history array await this._enforceRetentionPolicy(metrics); const runRecord = { timestamp: new Date().toISOString(), layer, passed: Boolean(result.passed), durationMs: result.durationMs || 0, findingsCount: result.findingsCount || 0, metadata: result.metadata || {}, }; // Add to history metrics.history.push(runRecord); // Recalculate aggregates for this layer await this._recalculateLayer(layer); // Update trends await this._updateTrends(); await this.save(); return runRecord; } /** * Record Layer 1 pre-commit run * @param {Object} result - Run result * @returns {Promise<Object>} The recorded run */ async recordPreCommit(result) { return this.recordRun(1, result); } /** * Record Layer 2 PR review run * @param {Object} result - Run result with optional coderabbit/quinn data * @returns {Promise<Object>} The recorded run */ async recordPRReview(result) { const metrics = await this.load(); // Update CodeRabbit metrics if provided if (result.coderabbit) { metrics.layers.layer2.coderabbit = { active: true, findingsCount: (metrics.layers.layer2.coderabbit?.findingsCount || 0) + (result.coderabbit.findingsCount || 0), severityBreakdown: { critical: (metrics.layers.layer2.coderabbit?.severityBreakdown?.critical || 0) + (result.coderabbit.severityBreakdown?.critical || 0), high: (metrics.layers.layer2.coderabbit?.severityBreakdown?.high || 0) + (result.coderabbit.severityBreakdown?.high || 0), medium: (metrics.layers.layer2.coderabbit?.severityBreakdown?.medium || 0) + (result.coderabbit.severityBreakdown?.medium || 0), low: (metrics.layers.layer2.coderabbit?.severityBreakdown?.low || 0) + (result.coderabbit.severityBreakdown?.low || 0), }, }; } // Update Quinn metrics if provided if (result.quinn) { const existingCategories = metrics.layers.layer2.quinn?.topCategories || []; const newCategories = result.quinn.topCategories || []; const allCategories = [...existingCategories, ...newCategories]; // Keep top 5 most common categories const categoryCount = {}; allCategories.forEach((cat) => { categoryCount[cat] = (categoryCount[cat] || 0) + 1; }); const topCategories = Object.entries(categoryCount) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([cat]) => cat); metrics.layers.layer2.quinn = { findingsCount: (metrics.layers.layer2.quinn?.findingsCount || 0) + (result.quinn.findingsCount || 0), topCategories, }; } // Calculate auto-catch rate const layer2History = metrics.history.filter((r) => r.layer === 2); if (layer2History.length > 0) { const totalFindings = layer2History.reduce( (sum, r) => sum + (r.findingsCount || 0), 0, ); // Auto-catch rate = findings caught automatically / total potential issues // Estimate: if passed with 0 findings, assume 1 potential issue caught const totalRuns = layer2History.length; metrics.layers.layer2.autoCatchRate = totalFindings / (totalRuns || 1); } this._metrics = metrics; return this.recordRun(2, result); } /** * Record Layer 3 human review run * @param {Object} result - Run result * @returns {Promise<Object>} The recorded run */ async recordHumanReview(result) { return this.recordRun(3, result); } /** * Get current metrics summary * @returns {Promise<Object>} Current metrics */ async getMetrics() { return this.load(); } /** * Recalculate aggregates for a specific layer * @private * @param {number} layer - Layer number */ async _recalculateLayer(layer) { const metrics = await this.load(); const layerKey = `layer${layer}`; const layerHistory = metrics.history.filter((r) => r.layer === layer); if (layerHistory.length === 0) { return; } // Calculate pass rate const passedRuns = layerHistory.filter((r) => r.passed).length; metrics.layers[layerKey].passRate = passedRuns / layerHistory.length; // Calculate average time const totalTime = layerHistory.reduce((sum, r) => sum + (r.durationMs || 0), 0); metrics.layers[layerKey].avgTimeMs = Math.round(totalTime / layerHistory.length); // Update total runs and last run metrics.layers[layerKey].totalRuns = layerHistory.length; metrics.layers[layerKey].lastRun = layerHistory[layerHistory.length - 1].timestamp; this._metrics = metrics; } /** * Recalculate all aggregates from history * @returns {Promise<void>} */ async recalculate() { await this._recalculateLayer(1); await this._recalculateLayer(2); await this._recalculateLayer(3); await this._updateTrends(); await this.save(); } /** * Update trend data * @private */ async _updateTrends() { const metrics = await this.load(); const today = new Date().toISOString().split('T')[0]; // Calculate daily pass rate const todayRuns = metrics.history.filter((r) => r.timestamp.startsWith(today), ); if (todayRuns.length > 0) { const passedToday = todayRuns.filter((r) => r.passed).length; const passRate = passedToday / todayRuns.length; // Update or add today's pass rate trend const existingIndex = metrics.trends.passRates.findIndex( (t) => t.date === today, ); if (existingIndex >= 0) { metrics.trends.passRates[existingIndex].value = passRate; } else { metrics.trends.passRates.push({ date: today, value: passRate }); } } // Update auto-catch rate trend (Layer 2) if (metrics.layers.layer2.autoCatchRate > 0) { const existingIndex = metrics.trends.autoCatchRate.findIndex( (t) => t.date === today, ); if (existingIndex >= 0) { metrics.trends.autoCatchRate[existingIndex].value = metrics.layers.layer2.autoCatchRate; } else { metrics.trends.autoCatchRate.push({ date: today, value: metrics.layers.layer2.autoCatchRate, }); } } // Keep only last 30 days of trends const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays); const cutoffStr = cutoffDate.toISOString().split('T')[0]; metrics.trends.passRates = metrics.trends.passRates.filter( (t) => t.date >= cutoffStr, ); metrics.trends.autoCatchRate = metrics.trends.autoCatchRate.filter( (t) => t.date >= cutoffStr, ); this._metrics = metrics; } /** * Cleanup old records beyond retention period * @returns {Promise<number>} Number of records removed */ async cleanup() { const metrics = await this.load(); const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000; const originalCount = metrics.history.length; metrics.history = metrics.history.filter( (r) => new Date(r.timestamp).getTime() > cutoff, ); const removedCount = originalCount - metrics.history.length; if (removedCount > 0) { // Recalculate aggregates after cleanup await this.recalculate(); } return removedCount; } /** * Enforce retention policy inline (called before adding new entries) * This prevents unbounded growth of the history array. * @private * @param {Object} metrics - Metrics object to clean * @returns {Promise<void>} * @see CodeRabbit nitpick: Enforce retention policy to prevent unbounded growth */ async _enforceRetentionPolicy(metrics) { const retentionMs = (metrics.retentionDays || this.retentionDays) * 24 * 60 * 60 * 1000; const cutoffTimestamp = Date.now() - retentionMs; const originalCount = metrics.history.length; metrics.history = metrics.history.filter( (entry) => new Date(entry.timestamp).getTime() > cutoffTimestamp, ); const removedCount = originalCount - metrics.history.length; if (removedCount > 0) { console.log(`[metrics] Retention policy: removed ${removedCount} old entries (> ${metrics.retentionDays || this.retentionDays} days)`); } } /** * Get history for a specific layer * @param {number} layer - Layer number * @param {number} [limit] - Maximum records to return * @returns {Promise<Array>} History records */ async getLayerHistory(layer, limit = 100) { const metrics = await this.load(); const layerHistory = metrics.history .filter((r) => r.layer === layer) .slice(-limit); return layerHistory; } /** * Export metrics to various formats * @param {string} format - Export format ('json' or 'csv') * @returns {Promise<string>} Exported data */ async export(format = 'json') { const metrics = await this.load(); if (format === 'csv') { // Export history as CSV const headers = ['timestamp', 'layer', 'passed', 'durationMs', 'findingsCount']; const rows = metrics.history.map((r) => headers.map((h) => r[h] ?? '').join(','), ); return [headers.join(','), ...rows].join('\n'); } return JSON.stringify(metrics, null, 2); } /** * Reset all metrics (use with caution) * @returns {Promise<void>} */ async reset() { this._metrics = createEmptyMetrics(); this._metrics.retentionDays = this.retentionDays; await this.save(); } } module.exports = { MetricsCollector, createEmptyMetrics, DEFAULT_DATA_FILE, DEFAULT_RETENTION_DAYS, };