From 79c496777734012597d8428cee713b2bf7e1ba6f Mon Sep 17 00:00:00 2001 From: Alqku Date: Fri, 26 Jun 2026 10:55:12 +0000 Subject: [PATCH] feat: implement Soroban incremental scan engine (#508) - Hash-based change detection to skip unchanged files - Cache with invalidate() and clearCache() helpers - Returns cacheHitRate and durationMs metrics - 8 tests covering all acceptance criteria --- .../stellar/incremental-scan-engine.ts | 104 ++++++ src/scanning/incremental/stellar/index.ts | 7 + tests/incremental-analysis.spec.ts | 315 +++++++----------- 3 files changed, 231 insertions(+), 195 deletions(-) create mode 100644 src/scanning/incremental/stellar/incremental-scan-engine.ts create mode 100644 src/scanning/incremental/stellar/index.ts diff --git a/src/scanning/incremental/stellar/incremental-scan-engine.ts b/src/scanning/incremental/stellar/incremental-scan-engine.ts new file mode 100644 index 0000000..9087122 --- /dev/null +++ b/src/scanning/incremental/stellar/incremental-scan-engine.ts @@ -0,0 +1,104 @@ +/** + * Soroban Incremental Scan Engine + * + * Detects changed files and reuses previous scan results for unchanged files, + * minimizing scan duration for large Soroban contract repositories. + */ + +import { createHash } from "crypto"; + +export interface ScanInput { + filePath: string; + source: string; +} + +export interface ScanFinding { + ruleId: string; + message: string; + severity: "critical" | "high" | "medium" | "low" | "info"; + line: number; +} + +export interface FileScanResult { + filePath: string; + findings: ScanFinding[]; + scannedAt: number; +} + +export interface IncrementalScanResult { + results: FileScanResult[]; + scanned: string[]; + skipped: string[]; + cacheHitRate: number; + durationMs: number; +} + +type ScanFn = (input: ScanInput) => ScanFinding[]; + +interface CacheEntry { + contentHash: string; + result: FileScanResult; +} + +export class StellarIncrementalScanEngine { + private cache = new Map(); + + private static hash(source: string): string { + return createHash("sha256").update(source).digest("hex"); + } + + /** + * Run an incremental scan. Only files whose content has changed since the + * last run are re-scanned; results for unchanged files are served from cache. + */ + scan(inputs: ScanInput[], scanFn: ScanFn): IncrementalScanResult { + const start = Date.now(); + const results: FileScanResult[] = []; + const scanned: string[] = []; + const skipped: string[] = []; + + for (const input of inputs) { + const hash = StellarIncrementalScanEngine.hash(input.source); + const cached = this.cache.get(input.filePath); + + if (cached && cached.contentHash === hash) { + results.push(cached.result); + skipped.push(input.filePath); + } else { + const findings = scanFn(input); + const result: FileScanResult = { + filePath: input.filePath, + findings, + scannedAt: Date.now(), + }; + this.cache.set(input.filePath, { contentHash: hash, result }); + results.push(result); + scanned.push(input.filePath); + } + } + + const total = inputs.length; + return { + results, + scanned, + skipped, + cacheHitRate: total > 0 ? skipped.length / total : 0, + durationMs: Date.now() - start, + }; + } + + /** Force a file to be rescanned on the next run. */ + invalidate(filePath: string): void { + this.cache.delete(filePath); + } + + /** Clear all cached results. */ + clearCache(): void { + this.cache.clear(); + } + + /** Number of files currently tracked in the cache. */ + get trackedCount(): number { + return this.cache.size; + } +} diff --git a/src/scanning/incremental/stellar/index.ts b/src/scanning/incremental/stellar/index.ts new file mode 100644 index 0000000..24fdcc8 --- /dev/null +++ b/src/scanning/incremental/stellar/index.ts @@ -0,0 +1,7 @@ +export { StellarIncrementalScanEngine } from "./incremental-scan-engine"; +export type { + ScanInput, + ScanFinding, + FileScanResult, + IncrementalScanResult, +} from "./incremental-scan-engine"; diff --git a/tests/incremental-analysis.spec.ts b/tests/incremental-analysis.spec.ts index c332173..d965275 100644 --- a/tests/incremental-analysis.spec.ts +++ b/tests/incremental-analysis.spec.ts @@ -1,195 +1,120 @@ -// import { Test, TestingModule } from '@nestjs/testing'; -// import { IncrementalAnalyzerSimpleService } from '../apps/api-service/src/analyzer/incremental-analyzer-simple.service'; -// import { ScannerService } from '../apps/api-service/src/scanner/scanner.service'; -// import { RuleViolation } from '../apps/api-service/src/scanner/interfaces/scanner.interface'; - -// describe('IncrementalAnalyzerService', () => { -// let service: IncrementalAnalyzerSimpleService; -// let scannerService: jest.Mocked; - -// const mockViolation: RuleViolation = { -// ruleName: 'unused-state-variable', -// severity: 'warning', -// lineNumber: 10, -// description: 'Unused state variable detected', -// suggestion: 'Remove the unused variable', -// variableName: 'unusedVar', -// }; - -// const mockScanResult = { -// source: 'test.sol', -// violations: [mockViolation], -// scanTime: new Date(), -// }; - -// beforeEach(async () => { -// const mockScannerService = { -// scanContent: jest.fn(), -// }; - -// const module: TestingModule = await Test.createTestingModule({ -// providers: [ -// IncrementalAnalyzerSimpleService, -// { -// provide: ScannerService, -// useValue: mockScannerService, -// }, -// ], -// }).compile(); - -// service = module.get(IncrementalAnalyzerSimpleService); -// scannerService = module.get(ScannerService) as jest.Mocked; -// }); - -// it('should be defined', () => { -// expect(service).toBeDefined(); -// }); - -// describe('analyzeCodeIncremental', () => { -// it('should analyze single file code', async () => { -// scannerService.scanContent.mockResolvedValue(mockScanResult); - -// const result = await service.analyzeCodeIncremental('contract Test {}', 'test.sol'); - -// expect(result.source).toBe('test.sol'); -// expect(result.violations).toHaveLength(1); -// expect(result.incrementalStats.totalFiles).toBe(1); -// expect(result.incrementalStats.filesAnalyzed).toBe(1); -// expect(result.incrementalStats.isIncremental).toBe(false); -// expect(scannerService.scanContent).toHaveBeenCalledWith('contract Test {}', 'test.sol'); -// }); - -// it('should handle empty code', async () => { -// scannerService.scanContent.mockResolvedValue({ source: 'empty.sol', violations: [], scanTime: new Date() }); - -// const result = await service.analyzeCodeIncremental('', 'empty.sol'); - -// expect(result.violations).toHaveLength(0); -// expect(result.summary).toContain('No violations found'); -// }); -// }); - -// describe('analyzeRepositoryIncremental', () => { -// it('should analyze repository', async () => { -// scannerService.scanContent.mockResolvedValue(mockScanResult); - -// const result = await service.analyzeRepositoryIncremental('/path/to/repo'); - -// expect(result.source).toBe('/path/to/repo'); -// expect(result.incrementalStats.totalFiles).toBe(0); // Simplified implementation returns 0 -// expect(result.incrementalStats.isIncremental).toBe(false); -// }); - -// it('should force full analysis when requested', async () => { -// scannerService.scanContent.mockResolvedValue(mockScanResult); - -// const result = await service.analyzeRepositoryIncremental('/path/to/repo', { -// forceFull: true, -// }); - -// expect(result.incrementalStats.isIncremental).toBe(false); -// }); - -// it('should handle analysis errors gracefully', async () => { -// scannerService.scanContent.mockRejectedValue(new Error('Analysis failed')); - -// await expect(service.analyzeRepositoryIncremental('/path/to/repo')).rejects.toThrow('Analysis failed'); -// }); -// }); - -// describe('cache operations', () => { -// it('should get cache stats', async () => { -// const stats = await service.getCacheStats('/path/to/repo'); - -// expect(stats.totalCachedFiles).toBe(0); -// expect(stats.cacheAge).toBeNull(); -// expect(stats.dependencyNodes).toBe(0); -// expect(stats.dependencyEdges).toBe(0); -// }); - -// it('should clear cache', async () => { -// await service.clearCache('/path/to/repo'); - -// // Should not throw any errors -// expect(true).toBe(true); -// }); - -// it('should invalidate specific files', async () => { -// await service.invalidateFiles('/path/to/repo', ['file1.sol', 'file2.rs']); - -// // Should not throw any errors -// expect(true).toBe(true); -// }); -// }); - -// describe('violation formatting', () => { -// it('should format violations correctly', async () => { -// scannerService.scanContent.mockResolvedValue(mockScanResult); - -// const result = await service.analyzeCodeIncremental('contract Test {}', 'test.sol'); - -// expect(result.violations[0].severityIcon).toBe('⚠️'); -// expect(result.violations[0].formattedMessage).toContain('WARNING'); -// expect(result.violations[0].formattedMessage).toContain('Line 10'); -// }); - -// it('should generate summary correctly', async () => { -// const multipleViolations = [ -// mockViolation, -// { ...mockViolation, severity: 'error' as const, lineNumber: 20 }, -// { ...mockViolation, severity: 'info' as const, lineNumber: 30 }, -// ]; -// scannerService.scanContent.mockResolvedValue({ -// source: 'test.sol', -// violations: multipleViolations, -// scanTime: new Date(), -// }); - -// const result = await service.analyzeCodeIncremental('contract Test {}', 'test.sol'); - -// expect(result.summary).toContain('3 total violations'); -// expect(result.summary).toContain('1 errors'); -// expect(result.summary).toContain('1 warnings'); -// expect(result.summary).toContain('1 info'); -// }); - -// it('should calculate storage savings', async () => { -// const storageViolation = { -// ...mockViolation, -// ruleName: 'unused-state-variables', -// }; -// scannerService.scanContent.mockResolvedValue({ -// source: 'test.sol', -// violations: [storageViolation], -// scanTime: new Date(), -// }); - -// const result = await service.analyzeCodeIncremental('contract Test {}', 'test.sol'); - -// expect(result.storageSavings.unusedVariables).toBe(1); -// expect(result.storageSavings.estimatedSavingsKb).toBe(2.5); -// expect(result.storageSavings.monthlyLedgerRentSavings).toBe(0.0025); -// }); - -// it('should generate recommendations', async () => { -// scannerService.scanContent.mockResolvedValue(mockScanResult); - -// const result = await service.analyzeCodeIncremental('contract Test {}', 'test.sol'); - -// expect(result.recommendations).toContain('Remove 1 unused state variables to reduce storage costs'); -// expect(result.recommendations).toContain('Consider using more efficient data types where possible'); -// }); - -// it('should generate positive recommendations for clean code', async () => { -// scannerService.scanContent.mockResolvedValue({ -// source: 'clean.sol', -// violations: [], -// scanTime: new Date(), -// }); - -// const result = await service.analyzeCodeIncremental('contract Clean {}', 'clean.sol'); - -// expect(result.recommendations).toContain('Your contract looks good! Consider regular audits to maintain code quality.'); -// }); -// }); -// }); +import { describe, it, expect, jest } from "@jest/globals"; +import { + StellarIncrementalScanEngine, + ScanInput, + ScanFinding, +} from "../src/scanning/incremental/stellar/incremental-scan-engine"; + +const mockFinding = (ruleId = "test-rule"): ScanFinding => ({ + ruleId, + message: "Test finding", + severity: "medium", + line: 1, +}); + +const noopScan = (_input: ScanInput): ScanFinding[] => []; +const alwaysFindsScan = (input: ScanInput): ScanFinding[] => [ + mockFinding(input.filePath), +]; + +describe("StellarIncrementalScanEngine", () => { + it("scans all files on the first run", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [ + { filePath: "a.rs", source: "fn main() {}" }, + { filePath: "b.rs", source: "fn other() {}" }, + ]; + + const result = engine.scan(inputs, noopScan); + + expect(result.scanned).toEqual(["a.rs", "b.rs"]); + expect(result.skipped).toHaveLength(0); + expect(result.cacheHitRate).toBe(0); + }); + + it("skips unchanged files on subsequent runs", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [{ filePath: "a.rs", source: "fn main() {}" }]; + + engine.scan(inputs, noopScan); + const second = engine.scan(inputs, noopScan); + + expect(second.scanned).toHaveLength(0); + expect(second.skipped).toEqual(["a.rs"]); + expect(second.cacheHitRate).toBe(1); + }); + + it("rescans only changed files", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [ + { filePath: "a.rs", source: "fn main() {}" }, + { filePath: "b.rs", source: "fn other() {}" }, + ]; + + engine.scan(inputs, noopScan); + + const changed: ScanInput[] = [ + { filePath: "a.rs", source: "fn main() { /* changed */ }" }, + { filePath: "b.rs", source: "fn other() {}" }, + ]; + const second = engine.scan(changed, noopScan); + + expect(second.scanned).toEqual(["a.rs"]); + expect(second.skipped).toEqual(["b.rs"]); + expect(second.cacheHitRate).toBeCloseTo(0.5); + }); + + it("returns cached findings for skipped files", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [{ filePath: "a.rs", source: "let x = 1;" }]; + + engine.scan(inputs, alwaysFindsScan); + const second = engine.scan(inputs, alwaysFindsScan); + + expect(second.results[0]?.findings).toHaveLength(1); + }); + + it("invalidate forces a rescan on the next run", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [{ filePath: "a.rs", source: "fn main() {}" }]; + + engine.scan(inputs, noopScan); + engine.invalidate("a.rs"); + const second = engine.scan(inputs, noopScan); + + expect(second.scanned).toEqual(["a.rs"]); + expect(second.skipped).toHaveLength(0); + }); + + it("clearCache resets the engine state", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [{ filePath: "a.rs", source: "fn main() {}" }]; + + engine.scan(inputs, noopScan); + expect(engine.trackedCount).toBe(1); + + engine.clearCache(); + expect(engine.trackedCount).toBe(0); + + const second = engine.scan(inputs, noopScan); + expect(second.scanned).toEqual(["a.rs"]); + }); + + it("handles empty inputs", () => { + const engine = new StellarIncrementalScanEngine(); + const result = engine.scan([], noopScan); + + expect(result.results).toHaveLength(0); + expect(result.scanned).toHaveLength(0); + expect(result.cacheHitRate).toBe(0); + }); + + it("tracks scan duration", () => { + const engine = new StellarIncrementalScanEngine(); + const inputs: ScanInput[] = [{ filePath: "a.rs", source: "fn main() {}" }]; + + const result = engine.scan(inputs, noopScan); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); +});