From 26da7d2c696508987bc90e98626a2c2db8f80434 Mon Sep 17 00:00:00 2001 From: SharifIbrahimDev Date: Thu, 28 May 2026 19:49:27 +0100 Subject: [PATCH 1/2] feat: Implement Project Risk Scoring System (Issue #273) --- .../scoring/project-risk-scorer.spec.ts | 166 +++++++++++++ src/analysis/scoring/project-risk-scorer.ts | 226 ++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/analysis/scoring/project-risk-scorer.spec.ts create mode 100644 src/analysis/scoring/project-risk-scorer.ts diff --git a/src/analysis/scoring/project-risk-scorer.spec.ts b/src/analysis/scoring/project-risk-scorer.spec.ts new file mode 100644 index 0000000..ab34728 --- /dev/null +++ b/src/analysis/scoring/project-risk-scorer.spec.ts @@ -0,0 +1,166 @@ +import { Finding, Severity, AnalysisResult } from '@engine/core'; +import { ProjectRiskScorer } from './project-risk-scorer'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeFinding( + overrides: Partial & { severity: Severity; message: string; ruleId: string }, +): Finding { + return { + ruleId: overrides.ruleId, + message: overrides.message, + severity: overrides.severity, + location: { file: 'Contract.sol', startLine: 1, endLine: 1 }, + estimatedGasSavings: overrides.estimatedGasSavings, + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProjectRiskScorer', () => { + let scorer: ProjectRiskScorer; + + beforeEach(() => { + scorer = new ProjectRiskScorer(); + }); + + // ── Empty input ───────────────────────────────────────────────────────────── + + it('should return a zero-risk score when there are no findings', () => { + const result = scorer.score([]); + + expect(result.overallScore).toBe(0); + expect(result.grade).toBe('A'); + expect(result.riskLevel).toBe('low'); + expect(result.meta.totalFindings).toBe(0); + expect(result.recommendations).toContain('No issues found. Project looks clean!'); + }); + + // ── Severity breakdown ────────────────────────────────────────────────────── + + it('should correctly count findings by severity', () => { + const findings: Finding[] = [ + makeFinding({ severity: Severity.CRITICAL, ruleId: 'r1', message: 'reentrancy vulnerability' }), + makeFinding({ severity: Severity.HIGH, ruleId: 'r2', message: 'access control issue' }), + makeFinding({ severity: Severity.MEDIUM, ruleId: 'r3', message: 'gas optimization' }), + makeFinding({ severity: Severity.LOW, ruleId: 'r4', message: 'maintainability issue' }), + makeFinding({ severity: Severity.INFO, ruleId: 'r5', message: 'info note' }), + ]; + + const result = scorer.score(findings); + + expect(result.severityBreakdown[Severity.CRITICAL]).toBe(1); + expect(result.severityBreakdown[Severity.HIGH]).toBe(1); + expect(result.severityBreakdown[Severity.MEDIUM]).toBe(1); + expect(result.severityBreakdown[Severity.LOW]).toBe(1); + expect(result.severityBreakdown[Severity.INFO]).toBe(1); + expect(result.meta.totalFindings).toBe(5); + }); + + // ── Risk level / grade ────────────────────────────────────────────────────── + + it('should produce a critical risk level and F grade for many severe findings', () => { + // Feed enough critical findings to push score >= 80 + const findings: Finding[] = Array.from({ length: 10 }, (_, i) => + makeFinding({ + severity: Severity.CRITICAL, + ruleId: `critical-${i}`, + message: 'reentrancy vulnerability attack exploit', + }), + ); + + const result = scorer.score(findings); + + expect(result.overallScore).toBeGreaterThanOrEqual(80); + expect(result.grade).toBe('F'); + expect(result.riskLevel).toBe('critical'); + }); + + it('should produce a low risk level and A grade for only info findings', () => { + const findings: Finding[] = Array.from({ length: 3 }, (_, i) => + makeFinding({ severity: Severity.INFO, ruleId: `info-${i}`, message: 'minor note' }), + ); + + const result = scorer.score(findings); + + expect(result.grade).toBe('A'); + expect(result.riskLevel).toBe('low'); + }); + + // ── Gas savings ───────────────────────────────────────────────────────────── + + it('should aggregate total gas savings across all findings', () => { + const findings: Finding[] = [ + makeFinding({ severity: Severity.MEDIUM, ruleId: 'g1', message: 'gas optimization', estimatedGasSavings: 500 }), + makeFinding({ severity: Severity.LOW, ruleId: 'g2', message: 'gas cost', estimatedGasSavings: 750 }), + ]; + + const result = scorer.score(findings); + + expect(result.totalGasSavings).toBe(1250); + expect(result.recommendations.some(r => r.includes('gas'))).toBe(true); + }); + + // ── Top findings ──────────────────────────────────────────────────────────── + + it('should return at most 5 top findings sorted by highest score', () => { + const findings: Finding[] = Array.from({ length: 8 }, (_, i) => + makeFinding({ + severity: i < 3 ? Severity.CRITICAL : Severity.INFO, + ruleId: `rule-${i}`, + message: i < 3 ? 'reentrancy vulnerability' : 'minor info', + }), + ); + + const result = scorer.score(findings); + + expect(result.topFindings.length).toBeLessThanOrEqual(5); + // Critical findings should appear first + expect(result.topFindings[0].severity).toBe(Severity.CRITICAL); + }); + + // ── AnalysisResult integration ────────────────────────────────────────────── + + it('should correctly score from an AnalysisResult object', () => { + const findings: Finding[] = [ + makeFinding({ severity: Severity.HIGH, ruleId: 'h1', message: 'access control vulnerability' }), + ]; + + const analysisResult: AnalysisResult = { + findings, + filesAnalyzed: 5, + analysisTime: 120, + analyzerVersion: '1.0.0', + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0 }, + }; + + const result = scorer.scoreFromAnalysisResult(analysisResult); + + expect(result.overallScore).toBeGreaterThan(0); + expect(result.meta.filesAnalyzed).toBe(5); + expect(result.meta.analysisTimeMs).toBe(120); + }); + + // ── Recommendations ───────────────────────────────────────────────────────── + + it('should recommend fixing critical issues when present', () => { + const findings: Finding[] = [ + makeFinding({ severity: Severity.CRITICAL, ruleId: 'c1', message: 'exploit vulnerability' }), + ]; + + const result = scorer.score(findings); + + expect(result.recommendations.some(r => r.toLowerCase().includes('critical'))).toBe(true); + }); + + it('should not duplicate recommendations for clean projects', () => { + const findings: Finding[] = [ + makeFinding({ severity: Severity.INFO, ruleId: 'i1', message: 'style note' }), + ]; + + const result = scorer.score(findings); + + expect(result.recommendations.length).toBeGreaterThan(0); + }); +}); diff --git a/src/analysis/scoring/project-risk-scorer.ts b/src/analysis/scoring/project-risk-scorer.ts new file mode 100644 index 0000000..7e61ba1 --- /dev/null +++ b/src/analysis/scoring/project-risk-scorer.ts @@ -0,0 +1,226 @@ +import { Finding, Severity, AnalysisResult } from '@engine/core'; +import { SeverityScoringSystem, ScoringConfig } from './severity-scorer'; + +export interface ProjectRiskScore { + /** Normalized overall risk score from 0 (safe) to 100 (critical) */ + overallScore: number; + /** Letter grade summarizing project health */ + grade: 'A' | 'B' | 'C' | 'D' | 'F'; + /** Human-readable risk label */ + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + /** Count of findings per severity level */ + severityBreakdown: Record; + /** Count of findings per impact category */ + impactBreakdown: Record<'security' | 'gas' | 'performance' | 'maintainability', number>; + /** Total potential gas savings across all findings */ + totalGasSavings: number; + /** Top 5 highest-risk findings */ + topFindings: Finding[]; + /** Actionable recommendations */ + recommendations: string[]; + /** Metadata */ + meta: { + totalFindings: number; + filesAnalyzed?: number; + analysisTimeMs?: number; + scoredAt: string; + }; +} + +export interface ProjectRiskScorerOptions { + scoringConfig?: Partial; + /** Max raw score expected; used to normalize overallScore to 0–100 */ + normalizationCeiling?: number; +} + +/** + * Aggregates findings from an analysis run into a single project-level risk score. + * Builds on top of SeverityScoringSystem to provide overall project health insight. + */ +export class ProjectRiskScorer { + private readonly scorer: SeverityScoringSystem; + private readonly normalizationCeiling: number; + + constructor(options: ProjectRiskScorerOptions = {}) { + this.scorer = new SeverityScoringSystem(options.scoringConfig); + this.normalizationCeiling = options.normalizationCeiling ?? 500; + } + + /** + * Score a project from an AnalysisResult (the standard output of an Analyzer). + */ + scoreFromAnalysisResult(result: AnalysisResult): ProjectRiskScore { + return this.score(result.findings, { + filesAnalyzed: result.filesAnalyzed, + analysisTimeMs: result.analysisTime, + }); + } + + /** + * Score a project from a raw list of findings. + */ + score( + findings: Finding[], + meta: { filesAnalyzed?: number; analysisTimeMs?: number } = {}, + ): ProjectRiskScore { + if (findings.length === 0) { + return this.emptyScore(meta); + } + + // Score all findings individually + const scoredFindings = findings.map((f) => ({ + finding: f, + score: this.scorer.scoreFinding(f), + })); + + // Raw aggregate score + const rawTotal = scoredFindings.reduce((sum, sf) => sum + sf.score.score, 0); + + // Normalize to 0–100 + const overallScore = Math.min(100, Math.round((rawTotal / this.normalizationCeiling) * 100)); + + // Severity breakdown + const severityBreakdown: Record = { + [Severity.CRITICAL]: 0, + [Severity.HIGH]: 0, + [Severity.MEDIUM]: 0, + [Severity.LOW]: 0, + [Severity.INFO]: 0, + }; + const impactBreakdown: Record = { + security: 0, + gas: 0, + performance: 0, + maintainability: 0, + }; + + let totalGasSavings = 0; + + for (const { finding, score } of scoredFindings) { + severityBreakdown[finding.severity]++; + impactBreakdown[score.impact]++; + totalGasSavings += finding.estimatedGasSavings ?? 0; + } + + // Top 5 findings sorted by score descending + const topFindings = scoredFindings + .sort((a, b) => b.score.score - a.score.score) + .slice(0, 5) + .map((sf) => sf.finding); + + const grade = this.calculateGrade(overallScore); + const riskLevel = this.calculateRiskLevel(overallScore); + const recommendations = this.generateRecommendations( + severityBreakdown, + impactBreakdown as Record<'security' | 'gas' | 'performance' | 'maintainability', number>, + totalGasSavings, + ); + + return { + overallScore, + grade, + riskLevel, + severityBreakdown, + impactBreakdown: impactBreakdown as Record< + 'security' | 'gas' | 'performance' | 'maintainability', + number + >, + totalGasSavings, + topFindings, + recommendations, + meta: { + totalFindings: findings.length, + filesAnalyzed: meta.filesAnalyzed, + analysisTimeMs: meta.analysisTimeMs, + scoredAt: new Date().toISOString(), + }, + }; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private emptyScore( + meta: { filesAnalyzed?: number; analysisTimeMs?: number }, + ): ProjectRiskScore { + return { + overallScore: 0, + grade: 'A', + riskLevel: 'low', + severityBreakdown: { + [Severity.CRITICAL]: 0, + [Severity.HIGH]: 0, + [Severity.MEDIUM]: 0, + [Severity.LOW]: 0, + [Severity.INFO]: 0, + }, + impactBreakdown: { security: 0, gas: 0, performance: 0, maintainability: 0 }, + totalGasSavings: 0, + topFindings: [], + recommendations: ['No issues found. Project looks clean!'], + meta: { + totalFindings: 0, + filesAnalyzed: meta.filesAnalyzed, + analysisTimeMs: meta.analysisTimeMs, + scoredAt: new Date().toISOString(), + }, + }; + } + + /** + * Maps normalized score (0–100) to a letter grade. + * A = excellent (0–19), B = good (20–39), C = moderate (40–59), D = poor (60–79), F = failing (80+) + */ + private calculateGrade(score: number): 'A' | 'B' | 'C' | 'D' | 'F' { + if (score < 20) return 'A'; + if (score < 40) return 'B'; + if (score < 60) return 'C'; + if (score < 80) return 'D'; + return 'F'; + } + + private calculateRiskLevel(score: number): 'low' | 'medium' | 'high' | 'critical' { + if (score < 20) return 'low'; + if (score < 50) return 'medium'; + if (score < 80) return 'high'; + return 'critical'; + } + + private generateRecommendations( + severityBreakdown: Record, + impactBreakdown: Record<'security' | 'gas' | 'performance' | 'maintainability', number>, + totalGasSavings: number, + ): string[] { + const recs: string[] = []; + + if (severityBreakdown[Severity.CRITICAL] > 0) { + recs.push( + `🚨 Fix ${severityBreakdown[Severity.CRITICAL]} critical issue(s) immediately — these pose severe security or financial risk.`, + ); + } + if (severityBreakdown[Severity.HIGH] > 0) { + recs.push( + `⚠️ Address ${severityBreakdown[Severity.HIGH]} high-severity issue(s) before the next deployment.`, + ); + } + if (impactBreakdown.security > 0) { + recs.push( + `🔒 ${impactBreakdown.security} security finding(s) detected — conduct a thorough security review.`, + ); + } + if (totalGasSavings > 1000) { + recs.push( + `⛽ Optimizing flagged patterns could save ~${totalGasSavings.toLocaleString()} gas units.`, + ); + } + if (impactBreakdown.maintainability > 3) { + recs.push( + `🛠 ${impactBreakdown.maintainability} maintainability issues found — consider a refactoring pass.`, + ); + } + if (recs.length === 0) { + recs.push('✅ Project is in good shape. Continue maintaining code quality standards.'); + } + + return recs; + } +} From 030b3b63a5875486783d82020568e3498e9f741a Mon Sep 17 00:00:00 2001 From: SharifIbrahimDev Date: Fri, 29 May 2026 06:29:10 +0100 Subject: [PATCH 2/2] chore: link issue #273 Resolves #273