UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

389 lines (388 loc) • 12.4 kB
/** * Metrics History Tracker for pi-lens * * Persists complexity metrics per commit to track trends over time. * Captures snapshots passively (session start) and explicitly (/lens-metrics). * * Storage: <project-data>/metrics-history.json */ import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { getProjectDataDir } from "./file-utils.js"; // --- Constants --- const HISTORY_FILE = "metrics-history.json"; const MAX_HISTORY_PER_FILE = 20; // --- Git Helpers --- /** * Check whether cwd is inside a Git worktree. */ function isInsideGitRepo(startDir) { let dir = path.resolve(startDir); while (true) { if (fs.existsSync(path.join(dir, ".git"))) { return true; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return false; } /** * Get current git commit hash (short) */ function getCurrentCommit() { if (!isInsideGitRepo(process.cwd())) { return "unknown"; } try { return execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, }).trim(); } catch { return "unknown"; } } // --- History Management --- /** * Load history from disk (or return empty) */ export function loadHistory() { const historyPath = path.join(getProjectDataDir(process.cwd()), HISTORY_FILE); if (!fs.existsSync(historyPath)) { return { version: 1, files: {}, capturedAt: new Date().toISOString(), }; } try { const content = fs.readFileSync(historyPath, "utf-8"); return JSON.parse(content); } catch { return { version: 1, files: {}, capturedAt: new Date().toISOString(), }; } } /** * Save history to disk */ export function saveHistory(history) { const historyDir = getProjectDataDir(process.cwd()); if (!fs.existsSync(historyDir)) { fs.mkdirSync(historyDir, { recursive: true }); } history.capturedAt = new Date().toISOString(); const historyPath = path.join(historyDir, "metrics-history.json"); fs.writeFileSync(historyPath, JSON.stringify(history, null, 2)); } // In-memory cache to avoid loading/saving on every capture let pendingHistory = null; let saveTimer = null; const SAVE_DEBOUNCE_MS = 5000; // Save at most every 5 seconds /** * Capture a snapshot for a file's current metrics * Auto-saves to disk (debounced) for passive tracking */ export function captureSnapshot(filePath, metrics) { // Use in-memory cache if available, otherwise load from disk if (!pendingHistory) { pendingHistory = loadHistory(); } const relativePath = path.relative(process.cwd(), filePath); const commit = getCurrentCommit(); const snapshot = { commit, timestamp: new Date().toISOString(), mi: Math.round(metrics.maintainabilityIndex * 10) / 10, cognitive: metrics.cognitiveComplexity, nesting: metrics.maxNestingDepth, lines: metrics.linesOfCode, maxCyclomatic: metrics.maxCyclomatic, entropy: Math.round(metrics.entropy * 100) / 100, }; const existing = pendingHistory.files[relativePath]; if (existing) { // Skip if same commit + same MI (no change worth recording) const latest = existing.latest; if (latest.commit === commit && latest.mi === snapshot.mi) { return; } // Append to history (cap at MAX_HISTORY_PER_FILE) existing.history.push(snapshot); if (existing.history.length > MAX_HISTORY_PER_FILE) { existing.history = existing.history.slice(-MAX_HISTORY_PER_FILE); } existing.latest = snapshot; existing.trend = computeTrend(existing.history); } else { // New file pendingHistory.files[relativePath] = { latest: snapshot, history: [snapshot], trend: "stable", }; } // Debounced save to disk if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { if (pendingHistory) { saveHistory(pendingHistory); pendingHistory = null; } }, SAVE_DEBOUNCE_MS); saveTimer.unref?.(); } /** * Capture snapshots for multiple files (explicit, immediate save) * Used by /lens-metrics for batch capture */ export function captureSnapshots(files) { const history = loadHistory(); for (const file of files) { const relativePath = path.relative(process.cwd(), file.filePath); const commit = getCurrentCommit(); const snapshot = { commit, timestamp: new Date().toISOString(), mi: Math.round(file.metrics.maintainabilityIndex * 10) / 10, cognitive: file.metrics.cognitiveComplexity, nesting: file.metrics.maxNestingDepth, lines: file.metrics.linesOfCode, maxCyclomatic: file.metrics.maxCyclomatic, entropy: Math.round(file.metrics.entropy * 100) / 100, }; const existing = history.files[relativePath]; if (existing) { existing.history.push(snapshot); if (existing.history.length > MAX_HISTORY_PER_FILE) { existing.history = existing.history.slice(-MAX_HISTORY_PER_FILE); } existing.latest = snapshot; existing.trend = computeTrend(existing.history); } else { history.files[relativePath] = { latest: snapshot, history: [snapshot], trend: "stable", }; } } saveHistory(history); return history; } // --- Trend Analysis --- /** * Compute trend direction from history snapshots * Uses last 3 snapshots for stability (or 2 if only 2 available) */ export function computeTrend(history) { if (history.length < 2) return "stable"; const recent = history.slice(-3); const first = recent[0]; const last = recent[recent.length - 1]; // Use MI as primary indicator, cognitive as secondary const miDelta = last.mi - first.mi; const cogDelta = last.cognitive - first.cognitive; // Thresholds (MI changes < 2 are noise) if (miDelta > 2) return "improving"; if (miDelta < -2) return "regressing"; // If MI is stable, check cognitive if (cogDelta < -10) return "improving"; if (cogDelta > 10) return "regressing"; return "stable"; } /** * Get delta between current snapshot and previous */ export function getDelta(history) { if (!history || history.history.length < 2) return null; const current = history.history[history.history.length - 1]; const previous = history.history[history.history.length - 2]; return { mi: Math.round((current.mi - previous.mi) * 10) / 10, cognitive: current.cognitive - previous.cognitive, trend: history.trend, }; } /** * Get trend emoji for display */ export function getTrendEmoji(trend) { switch (trend) { case "improving": return "📈"; case "regressing": return "📉"; default: return "➡️"; } } /** * Get trend summary across all files */ export function getTrendSummary(history) { let improving = 0; let regressing = 0; let stable = 0; const regressions = []; for (const [file, fileHistory] of Object.entries(history.files)) { switch (fileHistory.trend) { case "improving": improving++; break; case "regressing": { regressing++; const delta = getDelta(fileHistory); if (delta) { regressions.push({ file, miDelta: delta.mi }); } break; } default: stable++; } } // Sort regressions by MI delta (worst first) regressions.sort((a, b) => a.miDelta - b.miDelta); return { improving, regressing, stable, worstRegressions: regressions.slice(0, 5), }; } /** * Format trend for metrics table */ export function formatTrendCell(filePath, history) { const relativePath = path.relative(process.cwd(), filePath); const fileHistory = history.files[relativePath]; if (!fileHistory || fileHistory.history.length < 2) { return "—"; // No history } const delta = getDelta(fileHistory); if (!delta) return "—"; const emoji = getTrendEmoji(delta.trend); const miSign = delta.mi > 0 ? "+" : ""; const miColor = delta.mi > 0 ? "🟢" : delta.mi < 0 ? "🔴" : "⚪"; return `${emoji} ${miColor}${miSign}${delta.mi}`; } /** * Calculate Technical Debt Index for the project. * Score: 0 = perfect, 100 = maximum debt. */ export function computeTDI(history) { const files = Object.values(history.files); if (files.length === 0) { return { score: 0, grade: "N/A", avgMI: 100, totalCognitive: 0, filesAnalyzed: 0, filesWithDebt: 0, byCategory: { maintainability: 0, cognitive: 0, nesting: 0, maxCyclomatic: 0, entropy: 0, }, }; } let totalMI = 0; let totalCognitive = 0; let _totalNesting = 0; let filesWithDebt = 0; let debtFromMI = 0; let debtFromCognitive = 0; let debtFromNesting = 0; let debtFromMaxCyclomatic = 0; // NEW let debtFromEntropy = 0; // NEW for (const file of files) { const snap = file.latest; totalMI += snap.mi; totalCognitive += snap.cognitive; _totalNesting += snap.nesting; // Accumulate debt points let fileDebt = 0; // MI debt: 0 at MI=100, max at MI=0 const miDebt = Math.max(0, (100 - snap.mi) / 100); debtFromMI += miDebt; // Cognitive debt: 0 at 0, max at 500+ const cogDebt = Math.min(1, snap.cognitive / 200); debtFromCognitive += cogDebt; // Nesting debt: 0 at 1-3, max at 10+ const nestDebt = Math.min(1, Math.max(0, snap.nesting - 3) / 7); debtFromNesting += nestDebt; // Max Cyclomatic debt: 0 at max<=10, 1 at max>=30 const maxCycDebt = Math.min(1, Math.max(0, snap.maxCyclomatic - 10) / 20); debtFromMaxCyclomatic += maxCycDebt; // Entropy debt: 0 at entropy<=4.0, 1 at entropy>=7.0 const entropyDebt = Math.min(1, Math.max(0, snap.entropy - 4.0) / 3.0); debtFromEntropy += entropyDebt; fileDebt = miDebt + cogDebt + nestDebt + maxCycDebt + entropyDebt; if (fileDebt > 0.5) filesWithDebt++; // Lowered threshold since we have more factors } const avgMI = totalMI / files.length; // Normalize to 0-100 scale const avgMIDebt = debtFromMI / files.length; // 0-1 const avgCogDebt = debtFromCognitive / files.length; // 0-1 const avgNestDebt = debtFromNesting / files.length; // 0-1 const avgMaxCycDebt = debtFromMaxCyclomatic / files.length; // NEW const avgEntropyDebt = debtFromEntropy / files.length; // NEW // Weighted: MI (45%), cognitive (30%), nesting (10%), maxCyc (10%), entropy (5%) const rawScore = avgMIDebt * 45 + avgCogDebt * 30 + avgNestDebt * 10 + avgMaxCycDebt * 10 + avgEntropyDebt * 5; const score = Math.round(rawScore * 100) / 100; // Grade let grade; if (score <= 15) grade = "A"; else if (score <= 30) grade = "B"; else if (score <= 50) grade = "C"; else if (score <= 70) grade = "D"; else grade = "F"; return { score, grade, avgMI: Math.round(avgMI * 10) / 10, totalCognitive, filesAnalyzed: files.length, filesWithDebt, byCategory: { maintainability: Math.round(avgMIDebt * 100), cognitive: Math.round(avgCogDebt * 100), nesting: Math.round(avgNestDebt * 100), maxCyclomatic: Math.round(avgMaxCycDebt * 100), entropy: Math.round(avgEntropyDebt * 100), }, }; }