diff --git a/packages/analyzers/soroban/suggestions/__tests__/suggestion-engine.spec.ts b/packages/analyzers/soroban/suggestions/__tests__/suggestion-engine.spec.ts new file mode 100644 index 00000000..8ba77a57 --- /dev/null +++ b/packages/analyzers/soroban/suggestions/__tests__/suggestion-engine.spec.ts @@ -0,0 +1,35 @@ +import { + SorobanSuggestionEngine, + getSorobanSuggestionForFinding, +} from '../index'; + +describe('SorobanSuggestionEngine', () => { + it('maps a finding to an actionable recommendation with rationale and estimated impact', () => { + const result = getSorobanSuggestionForFinding({ + ruleId: 'soroban-call-frequency', + severity: 'high', + message: "Function 'transfer' invokes helper 'is_authorized' 9 times in a hot path.", + suggestion: "Consider inlining, memoizing, or batching repeated calls to 'is_authorized'.", + }); + + expect(result.ruleId).toBe('soroban-call-frequency'); + expect(result.recommendation).toContain('cache'); + expect(result.rationale).toContain('hot path'); + expect(result.expectedImpact).toMatch(/20%|30%|40%/); + }); + + it('accepts rule-specific suggestion templates for storage rent findings', () => { + const engine = new SorobanSuggestionEngine(); + const suggestions = engine.suggest([ + { + ruleId: 'soroban-storage-rent', + severity: 'medium', + message: 'Persistent storage is used for a short-lived nonce.', + }, + ]); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].recommendation).toContain('temporary'); + expect(suggestions[0].expectedImpact).toContain('rent'); + }); +}); diff --git a/packages/analyzers/soroban/suggestions/index.ts b/packages/analyzers/soroban/suggestions/index.ts new file mode 100644 index 00000000..102cdda2 --- /dev/null +++ b/packages/analyzers/soroban/suggestions/index.ts @@ -0,0 +1,103 @@ +export type SorobanSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +export interface SorobanFindingLike { + ruleId: string; + severity?: SorobanSeverity; + message?: string; + suggestion?: string; +} + +export interface SorobanSuggestion { + ruleId: string; + severity: SorobanSeverity; + recommendation: string; + rationale: string; + expectedImpact: string; +} + +export class SorobanSuggestionEngine { + public suggest(findings: SorobanFindingLike[]): SorobanSuggestion[] { + return findings.map((finding) => getSorobanSuggestionForFinding(finding)); + } +} + +export function getSorobanSuggestionForFinding( + finding: SorobanFindingLike, +): SorobanSuggestion { + const severity = finding.severity ?? 'medium'; + const ruleSpecific = getRuleSpecificSuggestion(finding, severity); + + return { + ruleId: finding.ruleId, + severity, + recommendation: ruleSpecific.recommendation, + rationale: ruleSpecific.rationale, + expectedImpact: ruleSpecific.expectedImpact, + }; +} + +function getRuleSpecificSuggestion( + finding: SorobanFindingLike, + severity: SorobanSeverity, +): Omit { + const message = finding.message ?? ''; + + switch (finding.ruleId) { + case 'soroban-call-frequency': { + return { + recommendation: + finding.suggestion?.includes('cache') || message.includes('helper') + ? "cache the repeated helper result or batch the equivalent calls so the hot path avoids redundant host invocations." + : "inline or memoize repeated helper calls and batch equivalent work in the hot path.", + rationale: + 'Repeated helper calls in a hot path amplify CPU and fee usage; eliminating redundant invocations reduces execution cost without changing behavior.', + expectedImpact: 'Expected impact: ~20-30% reduction in per-call CPU and fee overhead.', + }; + } + + case 'soroban-storage-rent': { + return { + recommendation: + 'Use temporary storage for short-lived keys and reserve instance storage for durable state, with TTL extension only where required.', + rationale: + 'Short-lived state stored persistently increases ledger rent and storage churn; moving ephemeral data to temporary storage reduces ongoing rent pressure.', + expectedImpact: 'Expected impact: ~30-40% reduction in rent and ledger storage growth.', + }; + } + + case 'soroban-redundant-call': { + return { + recommendation: + 'Memoize repeated call results in local variables or precompute shared values before entering the loop.', + rationale: + 'Repeated external or intra-contract calls add repeated serialization and host overhead; caching removes duplication in the critical path.', + expectedImpact: 'Expected impact: ~15-25% reduction in CPU cost for the affected function.', + }; + } + + case 'soroban-unbounded-loop': { + return { + recommendation: + 'Bound the loop, hoist stable state reads, and reduce storage access inside the iteration body.', + rationale: + 'Loops with repeated storage or host interactions can quickly dominate execution time and fees; limiting work inside the loop improves throughput and predictability.', + expectedImpact: 'Expected impact: ~25-40% reduction in fee pressure for large iterations.', + }; + } + + default: { + const genericImpact = + severity === 'high' || severity === 'critical' + ? 'Expected impact: ~20-40% lower runtime and fee cost in the affected path.' + : 'Expected impact: ~10-20% lower runtime and fee cost in the affected path.'; + + return { + recommendation: + 'Refactor the hot path to reduce repeated storage reads, host calls, or expensive conversions while preserving semantics.', + rationale: + 'The finding points to a repeated cost pattern; the most reliable remediation is to remove redundant work and keep the computation closer to local state.', + expectedImpact: genericImpact, + }; + } + } +} diff --git a/packages/autofix/soroban/optimization-preview.ts b/packages/autofix/soroban/optimization-preview.ts index c96c071c..2e0924f1 100644 --- a/packages/autofix/soroban/optimization-preview.ts +++ b/packages/autofix/soroban/optimization-preview.ts @@ -21,6 +21,14 @@ export interface ProposedDiff { endLine: number; } +export interface ResourceImpactSummary { + cpu: number; + memory: number; + ledger: number; + fees: number; + summary: string; +} + export interface OptimizationProposal { id: string; ruleId: string; @@ -29,14 +37,16 @@ export interface OptimizationProposal { description: string; /** 0–1 confidence that the fix is safe and beneficial */ confidence: number; + /** Alias kept for reporting/UI compatibility */ + confidenceScore: number; + /** Original code being considered for change */ + originalCode: string; + /** Proposed transformed code for preview */ + proposedCode: string; /** Estimated impact breakdown */ - estimatedImpact: { - cpu: number; - memory: number; - ledger: number; - fees: number; - summary: string; - }; + estimatedImpact: ResourceImpactSummary; + /** Expected resource impact shown to developers */ + expectedResourceImpact: ResourceImpactSummary; /** Proposed source diff (preview) */ diff: ProposedDiff; line: number; @@ -80,6 +90,19 @@ export function previewOptimizations( for (const f of freq.findings) { const confidence = f.edge.count >= 8 ? 0.9 : f.edge.count >= 5 ? 0.75 : 0.6; + const impact: ResourceImpactSummary = { + cpu: Math.min(80, f.edge.count * 8), + memory: 5, + ledger: 10, + fees: Math.min(60, f.edge.count * 5), + summary: `Reducing ${f.edge.count} repeated calls may cut relative CPU by ~${Math.min(80, f.edge.count * 8)}%.`, + }; + const previewCode = buildPreviewCode( + source, + f.line, + `cache result of ${f.edge.callee}(...) across repeated calls`, + 'cache', + ); proposals.push({ id: `opt-freq-${f.line}-${f.edge.callee}`, ruleId: f.ruleId, @@ -87,13 +110,11 @@ export function previewOptimizations( title: `Cache / batch repeated call to '${f.edge.callee}'`, description: f.message, confidence, - estimatedImpact: { - cpu: Math.min(80, f.edge.count * 8), - memory: 5, - ledger: 10, - fees: Math.min(60, f.edge.count * 5), - summary: `Reducing ${f.edge.count} repeated calls may cut relative CPU by ~${Math.min(80, f.edge.count * 8)}%.`, - }, + confidenceScore: confidence, + originalCode: previewCode.originalCode, + proposedCode: previewCode.proposedCode, + estimatedImpact: impact, + expectedResourceImpact: impact, diff: buildCacheDiff(source, f.line, f.edge.callee, filePath), line: f.line, }); @@ -105,6 +126,19 @@ export function previewOptimizations( if (f.severity === 'low' || f.severity === 'info') continue; const confidence = f.severity === 'critical' ? 0.85 : f.severity === 'high' ? 0.7 : 0.55; + const impact: ResourceImpactSummary = { + cpu: f.estimatedCpuCost, + memory: f.patternId === 'serialization' ? 20 : 5, + ledger: f.patternId === 'storage-in-loop' ? 70 : 5, + fees: Math.round(f.estimatedCpuCost * 0.6), + summary: f.suggestion, + }; + const previewCode = buildPreviewCode( + source, + f.line, + f.suggestion, + 'generic', + ); proposals.push({ id: `opt-cpu-${f.patternId}-${f.line}`, ruleId: f.ruleId, @@ -112,13 +146,11 @@ export function previewOptimizations( title: `Reduce CPU: ${f.patternId}`, description: f.message, confidence, - estimatedImpact: { - cpu: f.estimatedCpuCost, - memory: f.patternId === 'serialization' ? 20 : 5, - ledger: f.patternId === 'storage-in-loop' ? 70 : 5, - fees: Math.round(f.estimatedCpuCost * 0.6), - summary: f.suggestion, - }, + confidenceScore: confidence, + originalCode: previewCode.originalCode, + proposedCode: previewCode.proposedCode, + estimatedImpact: impact, + expectedResourceImpact: impact, diff: buildGenericDiff(source, f.line, f.suggestion, filePath), line: f.line, }); @@ -156,6 +188,27 @@ function applyFilter( ); } +function buildPreviewCode( + source: string, + line: number, + suggestion: string, + mode: 'cache' | 'generic', +): { originalCode: string; proposedCode: string } { + const lines = source.split('\n'); + const originalCode = lines[line - 1] ?? ''; + const indent = originalCode.match(/^\s*/)?.[0] ?? ''; + + const proposedCode = + mode === 'cache' + ? `${indent}// OPTIMIZE: ${suggestion}\n${originalCode}` + : `${indent}// TODO(optimization): ${suggestion}\n${originalCode}`; + + return { + originalCode, + proposedCode, + }; +} + function buildCacheDiff( source: string, line: number, @@ -165,7 +218,6 @@ function buildCacheDiff( const lines = source.split('\n'); const original = lines[line - 1] ?? ''; const indent = original.match(/^\s*/)?.[0] ?? ''; - const proposed = `${indent}// OPTIMIZE: cache result of ${callee}(...) across repeated calls\n${original}`; return { filePath, diff --git a/packages/autofix/workflow/__tests__/approval-workflow.spec.ts b/packages/autofix/workflow/__tests__/approval-workflow.spec.ts new file mode 100644 index 00000000..66922241 --- /dev/null +++ b/packages/autofix/workflow/__tests__/approval-workflow.spec.ts @@ -0,0 +1,45 @@ +import { + approveFix, + applyOnlyApprovedFixes, + createPendingFix, + rejectFix, +} from '../approval-workflow'; + +describe('Soroban autofix approval workflow', () => { + it('keeps fixes pending until explicitly approved', () => { + const fix = createPendingFix('contract.rs', 'pub fn transfer() {}', { + startLine: 1, + endLine: 1, + replacement: ['pub fn transfer() { require_auth!(); }'], + description: 'Add auth check', + }); + + expect(fix.status).toBe('pending'); + expect(fix.auditTrail).toHaveLength(1); + }); + + it('records reviewer decisions and only applies approved fixes', () => { + const pending = createPendingFix('contract.rs', 'pub fn transfer() {}', { + startLine: 1, + endLine: 1, + replacement: ['pub fn transfer() { require_auth!(); }'], + description: 'Add auth check', + }); + + const approved = approveFix(pending, 'reviewer-a', 'Looks correct'); + expect(approved.status).toBe('approved'); + expect(approved.decisionReason).toBe('Looks correct'); + expect(approved.reviewer).toBe('reviewer-a'); + expect(approved.auditTrail.at(-1)?.decision).toBe('approved'); + + const rejected = rejectFix({ ...approved }, 'reviewer-b', 'Unsafe change'); + expect(rejected.status).toBe('rejected'); + expect(rejected.auditTrail.at(-1)?.reason).toBe('Unsafe change'); + + const result = applyOnlyApprovedFixes('pub fn transfer() {}', [approved, rejected]); + expect(result.applied).toHaveLength(1); + expect(result.source).toContain('require_auth'); + expect(result.skipped).toHaveLength(1); + expect(result.applied[0].status).toBe('approved'); + }); +}); diff --git a/packages/autofix/workflow/approval-workflow.ts b/packages/autofix/workflow/approval-workflow.ts new file mode 100644 index 00000000..2c5c960c --- /dev/null +++ b/packages/autofix/workflow/approval-workflow.ts @@ -0,0 +1,115 @@ +export type FixApprovalStatus = 'pending' | 'approved' | 'rejected'; + +export interface FixAuditEntry { + timestamp: string; + actor: string; + decision: 'created' | 'approved' | 'rejected'; + reason?: string; +} + +export interface PendingFix { + id: string; + filePath: string; + originalSource: string; + status: FixApprovalStatus; + fix: { + startLine: number; + endLine: number; + replacement: string[]; + description: string; + }; + reviewer?: string; + decisionReason?: string; + auditTrail: FixAuditEntry[]; +} + +export function createPendingFix( + filePath: string, + originalSource: string, + fix: PendingFix['fix'], +): PendingFix { + const timestamp = new Date().toISOString(); + return { + id: `fix-${timestamp}-${Math.random().toString(16).slice(2, 8)}`, + filePath, + originalSource, + status: 'pending', + fix, + auditTrail: [ + { + timestamp, + actor: 'system', + decision: 'created', + }, + ], + }; +} + +export function approveFix( + fix: PendingFix, + reviewer: string, + reason = 'Approved by reviewer', +): PendingFix { + return { + ...fix, + status: 'approved', + reviewer, + decisionReason: reason, + auditTrail: [ + ...fix.auditTrail, + { + timestamp: new Date().toISOString(), + actor: reviewer, + decision: 'approved', + reason, + }, + ], + }; +} + +export function rejectFix( + fix: PendingFix, + reviewer: string, + reason = 'Rejected by reviewer', +): PendingFix { + return { + ...fix, + status: 'rejected', + reviewer, + decisionReason: reason, + auditTrail: [ + ...fix.auditTrail, + { + timestamp: new Date().toISOString(), + actor: reviewer, + decision: 'rejected', + reason, + }, + ], + }; +} + +export function applyOnlyApprovedFixes( + source: string, + fixes: PendingFix[], +): { source: string; applied: PendingFix[]; skipped: PendingFix[] } { + const approved = fixes.filter((fix) => fix.status === 'approved'); + const skipped = fixes.filter((fix) => fix.status !== 'approved'); + + const lines = source.split('\n'); + const applied: PendingFix[] = []; + const sorted = [...approved].sort((a, b) => b.fix.startLine - a.fix.startLine); + + for (const fix of sorted) { + const start = Math.max(0, fix.fix.startLine - 1); + const deleteCount = fix.fix.endLine - fix.fix.startLine + 1; + lines.splice(start, deleteCount, ...fix.fix.replacement); + applied.push(fix); + } + + return { + source: lines.join('\n'), + applied, + skipped, + }; +} diff --git a/packages/rules/soroban/src/index.ts b/packages/rules/soroban/src/index.ts index d069a936..45947520 100644 --- a/packages/rules/soroban/src/index.ts +++ b/packages/rules/soroban/src/index.ts @@ -13,3 +13,4 @@ export * from './prioritization'; export * from './functions'; export * from './resources'; export * from './tokens'; +export * from './suggestions'; diff --git a/packages/rules/soroban/src/suggestions/index.ts b/packages/rules/soroban/src/suggestions/index.ts new file mode 100644 index 00000000..457b2918 --- /dev/null +++ b/packages/rules/soroban/src/suggestions/index.ts @@ -0,0 +1 @@ +export * from '../../../../analyzers/soroban/suggestions'; diff --git a/test/api/optimization/optimization-preview.spec.ts b/test/api/optimization/optimization-preview.spec.ts index 60c71117..f8c02c79 100644 --- a/test/api/optimization/optimization-preview.spec.ts +++ b/test/api/optimization/optimization-preview.spec.ts @@ -28,9 +28,12 @@ describe('Optimization Preview API (#807)', () => { for (const p of result.proposals) { expect(p.confidence).toBeGreaterThanOrEqual(0); expect(p.confidence).toBeLessThanOrEqual(1); + expect(p.confidenceScore).toBe(p.confidence); + expect(p.originalCode).toBeTruthy(); + expect(p.proposedCode).toBeTruthy(); expect(p.diff.patch).toContain('---'); expect(p.diff.filePath).toBeTruthy(); - expect(p.estimatedImpact).toEqual( + expect(p.expectedResourceImpact).toEqual( expect.objectContaining({ cpu: expect.any(Number), memory: expect.any(Number), @@ -39,6 +42,7 @@ describe('Optimization Preview API (#807)', () => { summary: expect.any(String), }), ); + expect(p.estimatedImpact).toEqual(p.expectedResourceImpact); } });