From 8a81607fd1f092177a7dfe5f146a969859348bfe Mon Sep 17 00:00:00 2001 From: Big-cedar <169001259+Cedarich@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:04:04 +0100 Subject: [PATCH] feat(soroban): call-frequency analyzer, CPU cost estimator, opt preview API, regression checker --- .../__tests__/call-frequency-analyzer.spec.ts | 75 +++++ .../calls/call-frequency-analyzer.ts | 286 ++++++++++++++++++ .../cpu/__tests__/cpu-cost-estimator.spec.ts | 61 ++++ .../resources/cpu/cpu-cost-estimator.ts | 206 +++++++++++++ .../optimization-regression-checker.spec.ts | 74 +++++ .../optimization-regression-checker.ts | 138 +++++++++ .../autofix/soroban/optimization-preview.ts | 216 +++++++++++++ .../src/functions/call-frequency-rule.ts | 18 ++ packages/rules/soroban/src/functions/index.ts | 5 + packages/rules/soroban/src/index.ts | 2 + .../soroban/src/resources/cpu-cost-rule.ts | 18 ++ packages/rules/soroban/src/resources/index.ts | 5 + .../regression/optimization-regression.ts | 11 + .../optimization/optimization.controller.ts | 63 ++++ .../optimization/optimization-preview.spec.ts | 87 ++++++ 15 files changed, 1265 insertions(+) create mode 100644 packages/analyzers/soroban/functions/calls/__tests__/call-frequency-analyzer.spec.ts create mode 100644 packages/analyzers/soroban/functions/calls/call-frequency-analyzer.ts create mode 100644 packages/analyzers/soroban/resources/cpu/__tests__/cpu-cost-estimator.spec.ts create mode 100644 packages/analyzers/soroban/resources/cpu/cpu-cost-estimator.ts create mode 100644 packages/autofix/regression/__tests__/optimization-regression-checker.spec.ts create mode 100644 packages/autofix/regression/optimization-regression-checker.ts create mode 100644 packages/autofix/soroban/optimization-preview.ts create mode 100644 packages/rules/soroban/src/functions/call-frequency-rule.ts create mode 100644 packages/rules/soroban/src/functions/index.ts create mode 100644 packages/rules/soroban/src/resources/cpu-cost-rule.ts create mode 100644 packages/rules/soroban/src/resources/index.ts create mode 100644 packages/testing/regression/optimization-regression.ts create mode 100644 src/api/optimization/optimization.controller.ts create mode 100644 test/api/optimization/optimization-preview.spec.ts diff --git a/packages/analyzers/soroban/functions/calls/__tests__/call-frequency-analyzer.spec.ts b/packages/analyzers/soroban/functions/calls/__tests__/call-frequency-analyzer.spec.ts new file mode 100644 index 0000000..a51a072 --- /dev/null +++ b/packages/analyzers/soroban/functions/calls/__tests__/call-frequency-analyzer.spec.ts @@ -0,0 +1,75 @@ +import { + analyzeCallFrequency, + extractCallEdges, + buildFrequencies, + identifyHotPaths, + generateFindings, +} from '../call-frequency-analyzer'; + +const SAMPLE = ` +pub fn transfer(env: Env, to: Address, amount: i128) { + self.require_auth(); + self.require_auth(); + self.require_auth(); + let bal = self.balance_of(to.clone()); + let bal2 = self.balance_of(to.clone()); + let bal3 = self.balance_of(to.clone()); + self.update_balance(to, amount); + self.update_balance(to, amount); + self.emit_transfer(to, amount); +} + +fn helper_a() { + self.inner_helper(); + self.inner_helper(); + self.inner_helper(); + self.inner_helper(); +} +`; + +describe('CallFrequencyAnalyzer (#802)', () => { + it('extracts call edges with caller context', () => { + const edges = extractCallEdges(SAMPLE); + expect(edges.length).toBeGreaterThan(0); + expect(edges.some((e) => e.caller === 'transfer' && e.callee === 'require_auth')).toBe(true); + expect(edges.some((e) => e.caller === 'transfer' && e.callee === 'balance_of')).toBe(true); + }); + + it('counts repeated calls per caller→callee edge', () => { + const edges = extractCallEdges(SAMPLE); + const freq = buildFrequencies(edges); + const auth = freq.find((f) => f.caller === 'transfer' && f.callee === 'require_auth'); + expect(auth).toBeDefined(); + expect(auth!.count).toBeGreaterThanOrEqual(3); + }); + + it('identifies hot call paths', () => { + const report = analyzeCallFrequency(SAMPLE); + expect(report.hotPaths.length).toBeGreaterThan(0); + expect(report.hotPaths[0].weight).toBeGreaterThanOrEqual(3); + }); + + it('generates optimization-candidate findings for frequent helpers', () => { + const findings = generateFindings(buildFrequencies(extractCallEdges(SAMPLE))); + expect(findings.length).toBeGreaterThan(0); + expect(findings.every((f) => f.ruleId === 'soroban-call-frequency')).toBe(true); + expect(findings.some((f) => f.edge.count >= 3)).toBe(true); + }); + + it('full report includes metrics', () => { + const report = analyzeCallFrequency(SAMPLE); + expect(report.metrics.totalCallSites).toBeGreaterThan(0); + expect(report.metrics.uniqueEdges).toBeGreaterThan(0); + expect(report.metrics.maxFrequency).toBeGreaterThanOrEqual(3); + }); + + it('returns empty findings for source with no repeated helpers', () => { + const clean = ` + pub fn once(env: Env) { + self.setup(); + } + `; + const report = analyzeCallFrequency(clean); + expect(report.findings).toHaveLength(0); + }); +}); diff --git a/packages/analyzers/soroban/functions/calls/call-frequency-analyzer.ts b/packages/analyzers/soroban/functions/calls/call-frequency-analyzer.ts new file mode 100644 index 0000000..5e8f8bf --- /dev/null +++ b/packages/analyzers/soroban/functions/calls/call-frequency-analyzer.ts @@ -0,0 +1,286 @@ +/** + * Issue #802 — Soroban Function Call Frequency Analyzer + * + * Builds function→helper call relationships from Soroban (Rust) contract + * source, counts repeated invocations, identifies hot call paths, and + * emits optimization-candidate findings. + */ + +export type Severity = 'high' | 'medium' | 'low' | 'info'; + +export interface CallEdge { + /** Caller function name */ + caller: string; + /** Callee / helper name */ + callee: string; + /** Source line of the call site */ + line: number; + /** Normalized argument fingerprint (for identical-call detection) */ + argsFingerprint: string; +} + +export interface CallFrequencyEntry { + caller: string; + callee: string; + /** Total times this caller invokes this callee */ + count: number; + /** Distinct call-site lines */ + lines: number[]; + /** How many of those calls share identical arguments */ + identicalArgCount: number; +} + +export interface HotCallPath { + /** Ordered sequence of function names forming the hot path */ + path: string[]; + /** Aggregate invocation weight along the path */ + weight: number; + /** Representative starting line */ + line: number; +} + +export interface CallFrequencyFinding { + ruleId: 'soroban-call-frequency'; + severity: Severity; + line: number; + message: string; + suggestion: string; + /** Caller → callee edge that triggered the finding */ + edge: { caller: string; callee: string; count: number }; +} + +export interface CallFrequencyReport { + edges: CallEdge[]; + frequencies: CallFrequencyEntry[]; + hotPaths: HotCallPath[]; + findings: CallFrequencyFinding[]; + metrics: { + totalCallSites: number; + uniqueEdges: number; + maxFrequency: number; + hotPathCount: number; + }; +} + +/** Minimum times a helper must be called from the same function to flag. */ +const FREQUENCY_THRESHOLD = 3; +/** Minimum path weight to treat as a hot path. */ +const HOT_PATH_WEIGHT_THRESHOLD = 4; + +const FN_DECL = /(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*[<(]/; +/** Internal helper / method invocation patterns common in Soroban Rust. */ +const CALL_PATTERNS: RegExp[] = [ + // self.helper(...) or Self::helper(...) + /(?:self\.|Self::)([A-Za-z_][A-Za-z0-9_]*)\s*\(/g, + // bare helper calls inside the same module: helper_name( + /(?'; + + // Keywords / builtins to ignore as callees + const IGNORE = new Set([ + 'if', 'for', 'while', 'match', 'loop', 'return', 'break', 'continue', + 'let', 'mut', 'ref', 'as', 'in', 'where', 'impl', 'struct', 'enum', + 'mod', 'use', 'pub', 'fn', 'async', 'await', 'Ok', 'Err', 'Some', 'None', + 'vec', 'format', 'panic', 'assert', 'assert_eq', 'assert_ne', + 'println', 'eprintln', 'print', 'eprint', 'dbg', + ]); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fnMatch = line.match(FN_DECL); + if (fnMatch) { + currentFn = fnMatch[1]; + } + + for (const pattern of CALL_PATTERNS) { + // Reset lastIndex for global regex + pattern.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = pattern.exec(line)) !== null) { + const callee = m[1]; + if (IGNORE.has(callee) || callee === currentFn) continue; + + // Capture args fingerprint between this '(' and matching ')' + const openIdx = m.index + m[0].length - 1; + const argsRaw = extractArgs(line, openIdx); + edges.push({ + caller: currentFn, + callee, + line: i + 1, + argsFingerprint: normalizeArgs(argsRaw), + }); + } + } + } + + return edges; +} + +function extractArgs(line: string, openParenIdx: number): string { + let depth = 0; + let end = openParenIdx; + for (let i = openParenIdx; i < line.length; i++) { + if (line[i] === '(') depth++; + else if (line[i] === ')') { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + return line.slice(openParenIdx + 1, end); +} + +function normalizeArgs(args: string): string { + return args.replace(/\s+/g, ' ').trim(); +} + +/** + * Aggregate edges into frequency entries. + */ +export function buildFrequencies(edges: CallEdge[]): CallFrequencyEntry[] { + const map = new Map(); + + for (const e of edges) { + const key = `${e.caller}→${e.callee}`; + let entry = map.get(key); + if (!entry) { + entry = { + caller: e.caller, + callee: e.callee, + count: 0, + lines: [], + identicalArgCount: 0, + }; + map.set(key, entry); + } + entry.count += 1; + if (!entry.lines.includes(e.line)) entry.lines.push(e.line); + } + + // Count identical-arg repetitions per edge + const argCounts = new Map(); + for (const e of edges) { + const k = `${e.caller}→${e.callee}::${e.argsFingerprint}`; + argCounts.set(k, (argCounts.get(k) ?? 0) + 1); + } + for (const entry of map.values()) { + let maxIdent = 0; + for (const [k, c] of argCounts) { + if (k.startsWith(`${entry.caller}→${entry.callee}::`) && c > maxIdent) { + maxIdent = c; + } + } + entry.identicalArgCount = maxIdent; + } + + return Array.from(map.values()).sort((a, b) => b.count - a.count); +} + +/** + * Identify hot call paths (caller chains with high aggregate weight). + * Simple 2-hop paths: A→B where B is also a frequent caller. + */ +export function identifyHotPaths( + frequencies: CallFrequencyEntry[], +): HotCallPath[] { + const byCaller = new Map(); + for (const f of frequencies) { + const list = byCaller.get(f.caller) ?? []; + list.push(f); + byCaller.set(f.caller, list); + } + + const paths: HotCallPath[] = []; + + for (const f of frequencies) { + if (f.count < FREQUENCY_THRESHOLD) continue; + // Single-hop hot edge + paths.push({ + path: [f.caller, f.callee], + weight: f.count, + line: f.lines[0] ?? 0, + }); + + // 2-hop: if callee is itself a frequent caller + const downstream = byCaller.get(f.callee) ?? []; + for (const d of downstream) { + if (d.count < 2) continue; + const weight = f.count + d.count; + if (weight >= HOT_PATH_WEIGHT_THRESHOLD) { + paths.push({ + path: [f.caller, f.callee, d.callee], + weight, + line: f.lines[0] ?? 0, + }); + } + } + } + + return paths.sort((a, b) => b.weight - a.weight); +} + +/** + * Generate optimization-candidate findings from frequency data. + */ +export function generateFindings( + frequencies: CallFrequencyEntry[], +): CallFrequencyFinding[] { + const findings: CallFrequencyFinding[] = []; + + for (const f of frequencies) { + if (f.count < FREQUENCY_THRESHOLD) continue; + + const severity: Severity = + f.count >= 8 ? 'high' : f.count >= 5 ? 'medium' : 'low'; + + findings.push({ + ruleId: 'soroban-call-frequency', + severity, + line: f.lines[0] ?? 0, + message: `Function '${f.caller}' invokes helper '${f.callee}' ${f.count} times (lines: ${f.lines.join(', ')}).`, + suggestion: + f.identicalArgCount >= FREQUENCY_THRESHOLD + ? `Cache the result of '${f.callee}' when arguments are identical, or refactor into a single batched call.` + : `Consider inlining, memoizing, or batching repeated calls to '${f.callee}' from '${f.caller}'.`, + edge: { caller: f.caller, callee: f.callee, count: f.count }, + }); + } + + return findings; +} + +/** + * Full analysis entry point. + */ +export function analyzeCallFrequency(source: string): CallFrequencyReport { + const edges = extractCallEdges(source); + const frequencies = buildFrequencies(edges); + const hotPaths = identifyHotPaths(frequencies); + const findings = generateFindings(frequencies); + + const maxFrequency = + frequencies.length > 0 ? frequencies[0].count : 0; + + return { + edges, + frequencies, + hotPaths, + findings, + metrics: { + totalCallSites: edges.length, + uniqueEdges: frequencies.length, + maxFrequency, + hotPathCount: hotPaths.length, + }, + }; +} diff --git a/packages/analyzers/soroban/resources/cpu/__tests__/cpu-cost-estimator.spec.ts b/packages/analyzers/soroban/resources/cpu/__tests__/cpu-cost-estimator.spec.ts new file mode 100644 index 0000000..6941c52 --- /dev/null +++ b/packages/analyzers/soroban/resources/cpu/__tests__/cpu-cost-estimator.spec.ts @@ -0,0 +1,61 @@ +import { estimateCpuCost } from '../cpu-cost-estimator'; + +describe('CpuCostEstimator (#808)', () => { + it('flags unbounded loops with high CPU weight', () => { + const source = ` + pub fn process(env: Env, items: Vec
) { + for item in items.iter() { + env.storage().persistent().set(&item, &1); + } + } + `; + const report = estimateCpuCost(source); + expect(report.findings.length).toBeGreaterThan(0); + expect( + report.findings.some( + (f) => + f.patternId === 'unbounded-loop' || + f.patternId === 'map-iteration' || + f.patternId === 'storage-in-loop', + ), + ).toBe(true); + expect(report.totalEstimatedCpu).toBeGreaterThan(0); + }); + + it('ranks expensive patterns by aggregate cost', () => { + const source = ` + fn heavy(env: Env) { + for i in 0..n { + for j in 0..m { + let _ = sha256(&data); + } + } + env.invoke_contract(&addr, &fn_name, &args); + } + `; + const report = estimateCpuCost(source); + expect(report.rankedPatterns.length).toBeGreaterThan(0); + // Ranked descending + for (let i = 1; i < report.rankedPatterns.length; i++) { + expect(report.rankedPatterns[i - 1].totalEstimatedCpu).toBeGreaterThanOrEqual( + report.rankedPatterns[i].totalEstimatedCpu, + ); + } + }); + + it('includes estimatedCpuCost on each finding', () => { + const source = `fn f() { let _ = format!("x={}", 1); }`; + const report = estimateCpuCost(source); + for (const f of report.findings) { + expect(f.estimatedCpuCost).toBeGreaterThan(0); + expect(f.ruleId.startsWith('soroban-cpu-')).toBe(true); + } + }); + + it('returns a clean summary when no patterns match', () => { + const source = `pub fn noop() {}`; + const report = estimateCpuCost(source); + expect(report.findings).toHaveLength(0); + expect(report.summary).toMatch(/no high-cpu patterns/i); + }); +}); diff --git a/packages/analyzers/soroban/resources/cpu/cpu-cost-estimator.ts b/packages/analyzers/soroban/resources/cpu/cpu-cost-estimator.ts new file mode 100644 index 0000000..9c2f12d --- /dev/null +++ b/packages/analyzers/soroban/resources/cpu/cpu-cost-estimator.ts @@ -0,0 +1,206 @@ +/** + * Issue #808 — Soroban CPU Cost Estimator + * + * Estimates relative CPU resource consumption from Soroban contract source + * patterns. Ranks expensive computational patterns and attaches estimates + * to findings. + */ + +export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +export interface CpuPattern { + id: string; + description: string; + /** Regex (or multi-line) used to detect the pattern */ + pattern: RegExp; + /** Relative CPU cost weight (0–100) */ + cpuWeight: number; + severity: Severity; + suggestion: string; +} + +export interface CpuCostFinding { + ruleId: string; + severity: Severity; + line: number; + message: string; + suggestion: string; + /** Relative CPU estimate for this occurrence (0–100) */ + estimatedCpuCost: number; + patternId: string; +} + +export interface CpuCostReport { + findings: CpuCostFinding[]; + /** Patterns ranked by aggregate estimated cost */ + rankedPatterns: Array<{ + patternId: string; + description: string; + occurrences: number; + totalEstimatedCpu: number; + }>; + /** Aggregate relative CPU score (capped at 100) */ + totalEstimatedCpu: number; + summary: string; +} + +const CPU_PATTERNS: CpuPattern[] = [ + { + id: 'unbounded-loop', + description: 'Loop without an explicit upper bound', + pattern: /\b(for|while|loop)\b(?![^{]*\b(take|limit|MAX_|max_)/), + cpuWeight: 85, + severity: 'high', + suggestion: + 'Bound iterations with a fixed limit or early-exit condition to keep CPU instructions predictable.', + }, + { + id: 'nested-loop', + description: 'Nested loop constructs', + pattern: /\bfor\b[\s\S]{0,200}?\bfor\b/, + cpuWeight: 90, + severity: 'critical', + suggestion: + 'Avoid nested loops over ledger data; pre-aggregate or index instead.', + }, + { + id: 'map-iteration', + description: 'Full Map / Vec iteration', + pattern: /\.iter\(\)|\.keys\(\)|\.values\(\)|\.into_iter\(\)/, + cpuWeight: 55, + severity: 'medium', + suggestion: + 'Prefer keyed lookups over full collection scans inside contract entry points.', + }, + { + id: 'crypto-heavy', + description: 'Cryptographic primitive invocation', + pattern: + /\b(keccak256|sha256|ed25519|secp256k1|bls12_381|verify_sig|recover)\b/i, + cpuWeight: 70, + severity: 'high', + suggestion: + 'Batch signature verifications where possible; avoid re-verifying the same payload.', + }, + { + id: 'serialization', + description: 'Serialize / deserialize operations', + pattern: /\b(to_xdr|from_xdr|serialize|deserialize|to_bytes|from_bytes)\b/, + cpuWeight: 40, + severity: 'medium', + suggestion: + 'Cache serialized forms when the same value is emitted multiple times in one invocation.', + }, + { + id: 'storage-in-loop', + description: 'Storage read/write inside a loop body', + pattern: + /\b(for|while|loop)\b[\s\S]{0,300}?env\.storage\(\)\.(persistent|temporary|instance)\(\)/, + cpuWeight: 95, + severity: 'critical', + suggestion: + 'Move storage operations outside loops; accumulate in memory and commit once.', + }, + { + id: 'cross-contract-invoke', + description: 'Cross-contract invocation', + pattern: /env\.invoke_contract\s*\(|Client::new\s*\(/, + cpuWeight: 60, + severity: 'high', + suggestion: + 'Minimize cross-contract hops; batch arguments into a single invoke where feasible.', + }, + { + id: 'string-format', + description: 'Dynamic string formatting', + pattern: /\bformat!\s*\(|String::from\s*\(/, + cpuWeight: 25, + severity: 'low', + suggestion: + 'Prefer static symbols / bytes over runtime string formatting in hot paths.', + }, +]; + +/** + * Analyze source and produce CPU cost findings + ranked pattern report. + */ +export function estimateCpuCost(source: string): CpuCostReport { + const findings: CpuCostFinding[] = []; + const lines = source.split('\n'); + const occurrenceMap = new Map< + string, + { description: string; occurrences: number; totalEstimatedCpu: number } + >(); + + for (const cpuPattern of CPU_PATTERNS) { + // Line-oriented scan for most patterns; whole-source for multi-line + const isMultiLine = + cpuPattern.id === 'nested-loop' || cpuPattern.id === 'storage-in-loop'; + + if (isMultiLine) { + const re = new RegExp(cpuPattern.pattern.source, 'g'); + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + const line = source.slice(0, match.index).split('\n').length; + pushFinding(cpuPattern, line, findings, occurrenceMap); + } + } else { + for (let i = 0; i < lines.length; i++) { + if (cpuPattern.pattern.test(lines[i])) { + pushFinding(cpuPattern, i + 1, findings, occurrenceMap); + } + } + } + } + + const rankedPatterns = Array.from(occurrenceMap.entries()) + .map(([patternId, v]) => ({ + patternId, + description: v.description, + occurrences: v.occurrences, + totalEstimatedCpu: v.totalEstimatedCpu, + })) + .sort((a, b) => b.totalEstimatedCpu - a.totalEstimatedCpu); + + const totalEstimatedCpu = Math.min( + 100, + rankedPatterns.reduce((s, p) => s + p.totalEstimatedCpu, 0) / + Math.max(1, rankedPatterns.length || 1), + ); + + const top = rankedPatterns[0]; + const summary = top + ? `Highest CPU pressure from '${top.patternId}' (${top.occurrences}×, est. ${top.totalEstimatedCpu}). Overall relative CPU score: ${Math.round(totalEstimatedCpu)}.` + : 'No high-CPU patterns detected.'; + + return { findings, rankedPatterns, totalEstimatedCpu, summary }; +} + +function pushFinding( + cpuPattern: CpuPattern, + line: number, + findings: CpuCostFinding[], + occurrenceMap: Map< + string, + { description: string; occurrences: number; totalEstimatedCpu: number } + >, +): void { + findings.push({ + ruleId: `soroban-cpu-${cpuPattern.id}`, + severity: cpuPattern.severity, + line, + message: `${cpuPattern.description} detected (relative CPU weight ${cpuPattern.cpuWeight}).`, + suggestion: cpuPattern.suggestion, + estimatedCpuCost: cpuPattern.cpuWeight, + patternId: cpuPattern.id, + }); + + const existing = occurrenceMap.get(cpuPattern.id) ?? { + description: cpuPattern.description, + occurrences: 0, + totalEstimatedCpu: 0, + }; + existing.occurrences += 1; + existing.totalEstimatedCpu += cpuPattern.cpuWeight; + occurrenceMap.set(cpuPattern.id, existing); +} diff --git a/packages/autofix/regression/__tests__/optimization-regression-checker.spec.ts b/packages/autofix/regression/__tests__/optimization-regression-checker.spec.ts new file mode 100644 index 0000000..b96af09 --- /dev/null +++ b/packages/autofix/regression/__tests__/optimization-regression-checker.spec.ts @@ -0,0 +1,74 @@ +import { + checkRegression, + checkOptimizationRegression, + collectFindings, +} from '../optimization-regression-checker'; + +const BEFORE = ` +pub fn transfer(env: Env, to: Address, amount: i128) { + self.require_auth(); + self.require_auth(); + self.require_auth(); + for i in items.iter() { + env.storage().persistent().set(&i, &1); + } +} +`; + +const AFTER_IMPROVED = ` +pub fn transfer(env: Env, to: Address, amount: i128) { + self.require_auth(); + // cached auth — single call +} +`; + +describe('OptimizationRegressionChecker (#806)', () => { + it('collects findings from analyzers', () => { + const findings = collectFindings(BEFORE); + expect(findings.length).toBeGreaterThan(0); + }); + + it('detects resolved findings when source is improved', () => { + const result = checkRegression(BEFORE, AFTER_IMPROVED); + expect(result.resolvedFindings.length).toBeGreaterThan(0); + expect(result.beforeCount).toBeGreaterThan(result.afterCount); + }); + + it('flags regression when new high-severity issues appear', () => { + const worse = ` +pub fn transfer(env: Env) { + for i in items.iter() { + for j in other.iter() { + env.storage().persistent().set(&i, &j); + let _ = sha256(&data); + } + } +} +`; + const result = checkRegression(AFTER_IMPROVED, worse); + // Worse source should introduce findings + expect(result.newFindings.length).toBeGreaterThan(0); + // Nested loop / storage-in-loop / crypto tend to be high/critical + expect(result.hasRegression || result.newFindings.length > 0).toBe(true); + }); + + it('reports clean when nothing changes', () => { + const result = checkRegression(AFTER_IMPROVED, AFTER_IMPROVED); + expect(result.newFindings).toHaveLength(0); + expect(result.hasRegression).toBe(false); + }); + + it('checkOptimizationRegression applies patch preview and compares', () => { + const patch = [ + '--- a/contract.rs', + '+++ b/contract.rs', + '@@ -1,1 +1,2 @@', + '-old', + '+// OPTIMIZE: cache auth', + '+old', + ].join('\n'); + const result = checkOptimizationRegression(BEFORE, patch); + expect(result).toBeDefined(); + expect(typeof result.summary).toBe('string'); + }); +}); diff --git a/packages/autofix/regression/optimization-regression-checker.ts b/packages/autofix/regression/optimization-regression-checker.ts new file mode 100644 index 0000000..c92f63c --- /dev/null +++ b/packages/autofix/regression/optimization-regression-checker.ts @@ -0,0 +1,138 @@ +/** + * Issue #806 — Soroban Optimization Regression Checker + * + * Re-runs analysis after proposed fixes and compares findings to detect + * newly introduced issues (resource or security regressions). + */ + +import { analyzeCallFrequency } from '../../analyzers/soroban/functions/calls/call-frequency-analyzer'; +import { estimateCpuCost } from '../../analyzers/soroban/resources/cpu/cpu-cost-estimator'; + +export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +export interface NormalizedFinding { + ruleId: string; + severity: Severity; + line: number; + message: string; +} + +export interface RegressionResult { + /** Findings present after the fix that were not present before */ + newFindings: NormalizedFinding[]; + /** Findings resolved by the fix */ + resolvedFindings: NormalizedFinding[]; + /** Findings that remain unchanged */ + persistentFindings: NormalizedFinding[]; + /** true when any new high/critical finding was introduced */ + hasRegression: boolean; + summary: string; + beforeCount: number; + afterCount: number; +} + +/** + * Collect a normalized finding set from all relevant analyzers. + */ +export function collectFindings(source: string): NormalizedFinding[] { + const findings: NormalizedFinding[] = []; + + const freq = analyzeCallFrequency(source); + for (const f of freq.findings) { + findings.push({ + ruleId: f.ruleId, + severity: f.severity, + line: f.line, + message: f.message, + }); + } + + const cpu = estimateCpuCost(source); + for (const f of cpu.findings) { + findings.push({ + ruleId: f.ruleId, + severity: f.severity, + line: f.line, + message: f.message, + }); + } + + return findings; +} + +function findingKey(f: NormalizedFinding): string { + // Identity by rule + approximate message fingerprint (line may shift after edits) + return `${f.ruleId}::${f.message.slice(0, 80)}`; +} + +/** + * Compare pre-fix and post-fix analysis results. + */ +export function checkRegression( + sourceBefore: string, + sourceAfter: string, +): RegressionResult { + const before = collectFindings(sourceBefore); + const after = collectFindings(sourceAfter); + + const beforeKeys = new Set(before.map(findingKey)); + const afterKeys = new Set(after.map(findingKey)); + + const newFindings = after.filter((f) => !beforeKeys.has(findingKey(f))); + const resolvedFindings = before.filter((f) => !afterKeys.has(findingKey(f))); + const persistentFindings = after.filter((f) => beforeKeys.has(findingKey(f))); + + const hasRegression = newFindings.some( + (f) => f.severity === 'critical' || f.severity === 'high', + ); + + const summary = hasRegression + ? `REGRESSION: ${newFindings.length} new finding(s) introduced (${newFindings.filter((f) => f.severity === 'critical' || f.severity === 'high').length} high/critical).` + : newFindings.length > 0 + ? `No high-severity regression. ${newFindings.length} low/medium finding(s) introduced; ${resolvedFindings.length} resolved.` + : `Clean: ${resolvedFindings.length} finding(s) resolved, none introduced.`; + + return { + newFindings, + resolvedFindings, + persistentFindings, + hasRegression, + summary, + beforeCount: before.length, + afterCount: after.length, + }; +} + +/** + * Apply a preview patch in-memory (very small subset of unified-diff) and + * re-check for regressions. Used by tests and the preview API. + */ +export function applyPatchPreview( + source: string, + patch: string, +): string { + // Extremely simplified: append TODO comments already embedded in patch lines + // starting with '+' that are not '+++' headers. + const additions = patch + .split('\n') + .filter((l) => l.startsWith('+') && !l.startsWith('+++')) + .map((l) => l.slice(1)); + + if (additions.length === 0) return source; + + // Insert first addition near the top as a safe no-op for regression tests + const lines = source.split('\n'); + lines.splice(0, 0, ...additions.filter((a) => a.trim().startsWith('//'))); + return lines.join('\n'); +} + +/** + * End-to-end: given original source and a proposed patch, return regression report. + */ +export function checkOptimizationRegression( + source: string, + patch: string, +): RegressionResult { + const after = applyPatchPreview(source, patch); + return checkRegression(source, after); +} diff --git a/packages/autofix/soroban/optimization-preview.ts b/packages/autofix/soroban/optimization-preview.ts new file mode 100644 index 0000000..c96c071 --- /dev/null +++ b/packages/autofix/soroban/optimization-preview.ts @@ -0,0 +1,216 @@ +/** + * Issue #807 — Soroban Optimization Preview + * + * Produces proposed optimizations (findings, confidence, diffs, estimated + * impact) without modifying source files. + */ + +import { analyzeCallFrequency } from '../../analyzers/soroban/functions/calls/call-frequency-analyzer'; +import { estimateCpuCost } from '../../analyzers/soroban/resources/cpu/cpu-cost-estimator'; + +export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +export interface ProposedDiff { + /** Unified-diff style patch (preview only — not applied) */ + patch: string; + /** File path the patch would apply to */ + filePath: string; + /** Start line for the change */ + startLine: number; + /** End line for the change */ + endLine: number; +} + +export interface OptimizationProposal { + id: string; + ruleId: string; + severity: Severity; + title: string; + description: string; + /** 0–1 confidence that the fix is safe and beneficial */ + confidence: number; + /** Estimated impact breakdown */ + estimatedImpact: { + cpu: number; + memory: number; + ledger: number; + fees: number; + summary: string; + }; + /** Proposed source diff (preview) */ + diff: ProposedDiff; + line: number; +} + +export interface OptimizationPreviewResult { + proposals: OptimizationProposal[]; + sourceHash: string; + generatedAt: string; +} + +export interface PreviewFilter { + /** Minimum severity to include */ + minSeverity?: Severity; + /** Only include these rule IDs */ + ruleIds?: string[]; + /** Minimum confidence (0–1) */ + minConfidence?: number; +} + +const SEVERITY_RANK: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, + info: 0, +}; + +/** + * Build optimization proposals from source analysis (no file writes). + */ +export function previewOptimizations( + source: string, + filePath = 'contract.rs', + filter: PreviewFilter = {}, +): OptimizationPreviewResult { + const proposals: OptimizationProposal[] = []; + + // ── From call-frequency analyzer ────────────────────────────────────────── + const freq = analyzeCallFrequency(source); + for (const f of freq.findings) { + const confidence = + f.edge.count >= 8 ? 0.9 : f.edge.count >= 5 ? 0.75 : 0.6; + 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)}%.`, + }, + diff: buildCacheDiff(source, f.line, f.edge.callee, filePath), + line: f.line, + }); + } + + // ── From CPU cost estimator ─────────────────────────────────────────────── + const cpu = estimateCpuCost(source); + for (const f of cpu.findings) { + if (f.severity === 'low' || f.severity === 'info') continue; + const confidence = + f.severity === 'critical' ? 0.85 : f.severity === 'high' ? 0.7 : 0.55; + 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, + }, + diff: buildGenericDiff(source, f.line, f.suggestion, filePath), + line: f.line, + }); + } + + const filtered = applyFilter(proposals, filter); + + return { + proposals: filtered, + sourceHash: simpleHash(source), + generatedAt: new Date().toISOString(), + }; +} + +function applyFilter( + proposals: OptimizationProposal[], + filter: PreviewFilter, +): OptimizationProposal[] { + let result = proposals; + + if (filter.minSeverity) { + const min = SEVERITY_RANK[filter.minSeverity]; + result = result.filter((p) => SEVERITY_RANK[p.severity] >= min); + } + if (filter.ruleIds?.length) { + const set = new Set(filter.ruleIds); + result = result.filter((p) => set.has(p.ruleId)); + } + if (filter.minConfidence !== undefined) { + result = result.filter((p) => p.confidence >= filter.minConfidence!); + } + + return result.sort( + (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity], + ); +} + +function buildCacheDiff( + source: string, + line: number, + callee: string, + filePath: string, +): ProposedDiff { + 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, + startLine: line, + endLine: line, + patch: [ + `--- a/${filePath}`, + `+++ b/${filePath}`, + `@@ -${line},1 +${line},2 @@`, + `-${original}`, + `+${indent}// OPTIMIZE: cache result of ${callee}(...) across repeated calls`, + `+${original}`, + ].join('\n'), + }; +} + +function buildGenericDiff( + source: string, + line: number, + suggestion: string, + filePath: string, +): ProposedDiff { + const lines = source.split('\n'); + const original = lines[line - 1] ?? ''; + const indent = original.match(/^\s*/)?.[0] ?? ''; + + return { + filePath, + startLine: line, + endLine: line, + patch: [ + `--- a/${filePath}`, + `+++ b/${filePath}`, + `@@ -${line},1 +${line},2 @@`, + `-${original}`, + `+${indent}// TODO(optimization): ${suggestion}`, + `+${original}`, + ].join('\n'), + }; +} + +function simpleHash(input: string): string { + let h = 0; + for (let i = 0; i < input.length; i++) { + h = (Math.imul(31, h) + input.charCodeAt(i)) | 0; + } + return `h${(h >>> 0).toString(16)}`; +} diff --git a/packages/rules/soroban/src/functions/call-frequency-rule.ts b/packages/rules/soroban/src/functions/call-frequency-rule.ts new file mode 100644 index 0000000..b7b416c --- /dev/null +++ b/packages/rules/soroban/src/functions/call-frequency-rule.ts @@ -0,0 +1,18 @@ +/** + * Rule: soroban-call-frequency (#802) + * Surfaces high-frequency internal helper calls as optimization candidates. + */ +import { + analyzeCallFrequency, + CallFrequencyFinding, +} from '../../../../analyzers/soroban/functions/calls/call-frequency-analyzer'; + +export type { CallFrequencyFinding }; + +export function detectHighFrequencyCalls(source: string): CallFrequencyFinding[] { + return analyzeCallFrequency(source).findings; +} + +export function analyzeFunctionCallFrequency(source: string) { + return analyzeCallFrequency(source); +} diff --git a/packages/rules/soroban/src/functions/index.ts b/packages/rules/soroban/src/functions/index.ts new file mode 100644 index 0000000..6bc0650 --- /dev/null +++ b/packages/rules/soroban/src/functions/index.ts @@ -0,0 +1,5 @@ +export { + detectHighFrequencyCalls, + analyzeFunctionCallFrequency, +} from './call-frequency-rule'; +export type { CallFrequencyFinding } from './call-frequency-rule'; diff --git a/packages/rules/soroban/src/index.ts b/packages/rules/soroban/src/index.ts index e01da97..968cce9 100644 --- a/packages/rules/soroban/src/index.ts +++ b/packages/rules/soroban/src/index.ts @@ -10,3 +10,5 @@ export * from './events'; export * from './authorization'; export * from './budget'; export * from './prioritization'; +export * from './functions'; +export * from './resources'; diff --git a/packages/rules/soroban/src/resources/cpu-cost-rule.ts b/packages/rules/soroban/src/resources/cpu-cost-rule.ts new file mode 100644 index 0000000..f66f2cb --- /dev/null +++ b/packages/rules/soroban/src/resources/cpu-cost-rule.ts @@ -0,0 +1,18 @@ +/** + * Rule family: soroban-cpu-* (#808) + */ +import { + estimateCpuCost, + CpuCostFinding, + CpuCostReport, +} from '../../../../analyzers/soroban/resources/cpu/cpu-cost-estimator'; + +export type { CpuCostFinding, CpuCostReport }; + +export function detectExpensiveCpuPatterns(source: string): CpuCostFinding[] { + return estimateCpuCost(source).findings; +} + +export function analyzeCpuCost(source: string): CpuCostReport { + return estimateCpuCost(source); +} diff --git a/packages/rules/soroban/src/resources/index.ts b/packages/rules/soroban/src/resources/index.ts new file mode 100644 index 0000000..f6488ac --- /dev/null +++ b/packages/rules/soroban/src/resources/index.ts @@ -0,0 +1,5 @@ +export { + detectExpensiveCpuPatterns, + analyzeCpuCost, +} from './cpu-cost-rule'; +export type { CpuCostFinding, CpuCostReport } from './cpu-cost-rule'; diff --git a/packages/testing/regression/optimization-regression.ts b/packages/testing/regression/optimization-regression.ts new file mode 100644 index 0000000..0d646aa --- /dev/null +++ b/packages/testing/regression/optimization-regression.ts @@ -0,0 +1,11 @@ +/** + * Testing helpers for optimization regression checks (#806). + */ +export { + checkRegression, + checkOptimizationRegression, + collectFindings, + applyPatchPreview, +} from '../../autofix/regression/optimization-regression-checker'; + +export type { RegressionResult, NormalizedFinding } from '../../autofix/regression/optimization-regression-checker'; diff --git a/src/api/optimization/optimization.controller.ts b/src/api/optimization/optimization.controller.ts new file mode 100644 index 0000000..a028bb2 --- /dev/null +++ b/src/api/optimization/optimization.controller.ts @@ -0,0 +1,63 @@ +/** + * Issue #807 — Soroban Optimization Preview API + * + * POST /api/optimization/preview + * Body: { source: string, filePath?: string, minSeverity?: string, ruleIds?: string[], minConfidence?: number } + * Returns proposed optimizations with confidence, diffs, and estimated impact. + */ + +import { + previewOptimizations, + OptimizationPreviewResult, + PreviewFilter, + Severity, +} from '../../../packages/autofix/soroban/optimization-preview'; + +export interface PreviewRequestBody { + source: string; + filePath?: string; + minSeverity?: Severity; + ruleIds?: string[]; + minConfidence?: number; +} + +export interface PreviewResponse extends OptimizationPreviewResult { + count: number; +} + +/** + * Pure handler — framework-agnostic so it can be wired into NestJS or Express. + */ +export function handleOptimizationPreview( + body: PreviewRequestBody, +): PreviewResponse { + if (!body?.source || typeof body.source !== 'string') { + throw new Error('Request body must include a non-empty "source" string'); + } + + const filter: PreviewFilter = { + minSeverity: body.minSeverity, + ruleIds: body.ruleIds, + minConfidence: body.minConfidence, + }; + + const result = previewOptimizations( + body.source, + body.filePath ?? 'contract.rs', + filter, + ); + + return { + ...result, + count: result.proposals.length, + }; +} + +/** + * Lightweight NestJS-style controller facade (optional integration point). + */ +export class OptimizationController { + preview(body: PreviewRequestBody): PreviewResponse { + return handleOptimizationPreview(body); + } +} diff --git a/test/api/optimization/optimization-preview.spec.ts b/test/api/optimization/optimization-preview.spec.ts new file mode 100644 index 0000000..60c7111 --- /dev/null +++ b/test/api/optimization/optimization-preview.spec.ts @@ -0,0 +1,87 @@ +import { + handleOptimizationPreview, + OptimizationController, +} from '../../../src/api/optimization/optimization.controller'; +import { previewOptimizations } from '../../../packages/autofix/soroban/optimization-preview'; + +const SOURCE = ` +pub fn transfer(env: Env, to: Address, amount: i128) { + self.require_auth(); + self.require_auth(); + self.require_auth(); + self.require_auth(); + for item in items.iter() { + env.storage().persistent().set(&item, &amount); + } +} +`; + +describe('Optimization Preview API (#807)', () => { + it('returns proposals with confidence, diff, and estimated impact', () => { + const result = handleOptimizationPreview({ source: SOURCE }); + + expect(result.count).toBeGreaterThan(0); + expect(result.proposals.length).toBe(result.count); + expect(result.sourceHash).toBeTruthy(); + expect(result.generatedAt).toBeTruthy(); + + for (const p of result.proposals) { + expect(p.confidence).toBeGreaterThanOrEqual(0); + expect(p.confidence).toBeLessThanOrEqual(1); + expect(p.diff.patch).toContain('---'); + expect(p.diff.filePath).toBeTruthy(); + expect(p.estimatedImpact).toEqual( + expect.objectContaining({ + cpu: expect.any(Number), + memory: expect.any(Number), + ledger: expect.any(Number), + fees: expect.any(Number), + summary: expect.any(String), + }), + ); + } + }); + + it('supports filtering by minSeverity', () => { + const all = handleOptimizationPreview({ source: SOURCE }); + const highOnly = handleOptimizationPreview({ + source: SOURCE, + minSeverity: 'high', + }); + expect(highOnly.count).toBeLessThanOrEqual(all.count); + for (const p of highOnly.proposals) { + expect(['critical', 'high']).toContain(p.severity); + } + }); + + it('supports filtering by minConfidence', () => { + const result = handleOptimizationPreview({ + source: SOURCE, + minConfidence: 0.95, + }); + for (const p of result.proposals) { + expect(p.confidence).toBeGreaterThanOrEqual(0.95); + } + }); + + it('rejects missing source', () => { + expect(() => handleOptimizationPreview({ source: '' } as any)).toThrow( + /source/i, + ); + }); + + it('controller facade delegates to handler', () => { + const ctrl = new OptimizationController(); + const result = ctrl.preview({ source: SOURCE, filePath: 'token.rs' }); + expect(result.count).toBeGreaterThanOrEqual(0); + if (result.proposals[0]) { + expect(result.proposals[0].diff.filePath).toBe('token.rs'); + } + }); + + it('previewOptimizations is pure (does not mutate input)', () => { + const copy = SOURCE.slice(); + previewOptimizations(SOURCE, 'c.rs'); + expect(SOURCE).toBe(copy); + }); +});