Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/analysis/scoring/project-risk-scorer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Finding, Severity, AnalysisResult } from '@engine/core';
import { ProjectRiskScorer } from './project-risk-scorer';

// ─── Helpers ─────────────────────────────────────────────────────────────────

function makeFinding(
overrides: Partial<Finding> & { 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);
});
});
226 changes: 226 additions & 0 deletions src/analysis/scoring/project-risk-scorer.ts
Original file line number Diff line number Diff line change
@@ -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<Severity, number>;
/** 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<ScoringConfig>;
/** 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, number> = {
[Severity.CRITICAL]: 0,
[Severity.HIGH]: 0,
[Severity.MEDIUM]: 0,
[Severity.LOW]: 0,
[Severity.INFO]: 0,
};
const impactBreakdown: Record<string, number> = {
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<Severity, number>,
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;
}
}
Loading