Skip to content
Merged

Fixes #911

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
Original file line number Diff line number Diff line change
@@ -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');
});
});
103 changes: 103 additions & 0 deletions packages/analyzers/soroban/suggestions/index.ts
Original file line number Diff line number Diff line change
@@ -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<SorobanSuggestion, 'ruleId' | 'severity'> {
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,
};
}
}
}
96 changes: 74 additions & 22 deletions packages/autofix/soroban/optimization-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -80,20 +90,31 @@ 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,
severity: f.severity,
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,
});
Expand All @@ -105,20 +126,31 @@ 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,
severity: f.severity,
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,
});
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions packages/autofix/workflow/__tests__/approval-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading