From b66900209f10493ba52504d1da4fde3fe3045c04 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Mon, 29 Jun 2026 12:27:04 +0100 Subject: [PATCH] feat: add Soroban scan history explorer (#482) --- src/history/scans/stellar/index.ts | 19 + .../stellar/scan-history-explorer.spec.ts | 523 ++++++++++++++++++ .../scans/stellar/scan-history-explorer.ts | 331 +++++++++++ .../stellar/scan-history-manager.spec.ts | 523 ++++++++++++++++++ .../scans/stellar/scan-history-manager.ts | 451 +++++++++++++++ src/history/scans/stellar/types.ts | 148 +++++ 6 files changed, 1995 insertions(+) create mode 100644 src/history/scans/stellar/index.ts create mode 100644 src/history/scans/stellar/scan-history-explorer.spec.ts create mode 100644 src/history/scans/stellar/scan-history-explorer.ts create mode 100644 src/history/scans/stellar/scan-history-manager.spec.ts create mode 100644 src/history/scans/stellar/scan-history-manager.ts create mode 100644 src/history/scans/stellar/types.ts diff --git a/src/history/scans/stellar/index.ts b/src/history/scans/stellar/index.ts new file mode 100644 index 0000000..1f2d3f3 --- /dev/null +++ b/src/history/scans/stellar/index.ts @@ -0,0 +1,19 @@ +/** + * Soroban Scan History Explorer Module + * + * Provides functionality to store, retrieve, filter, and explore Soroban contract + * scan history for tracking analysis results over time. + */ + +export { SorobanScanHistoryManager } from './scan-history-manager'; +export { SorobanScanHistoryExplorer, ScanExplorerBuilder } from './scan-history-explorer'; +export type { + SorobanScanMetadata, + SorobanScanRecord, + SorobanScanFilter, + SorobanScanSortOptions, + SorobanScanQueryOptions, + SorobanScanQueryResult, + SorobanScanComparison, + SorobanScanHistoryStorageConfig, +} from './types'; diff --git a/src/history/scans/stellar/scan-history-explorer.spec.ts b/src/history/scans/stellar/scan-history-explorer.spec.ts new file mode 100644 index 0000000..0841ad0 --- /dev/null +++ b/src/history/scans/stellar/scan-history-explorer.spec.ts @@ -0,0 +1,523 @@ +/** + * Soroban Scan History Explorer Tests + */ + +import { SorobanScanHistoryExplorer } from './scan-history-explorer'; +import { SorobanScanHistoryManager } from './scan-history-manager'; +import { SorobanAnalysisResult } from '../../../diffing/stellar/types'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('SorobanScanHistoryExplorer', () => { + let explorer: SorobanScanHistoryExplorer; + let manager: SorobanScanHistoryManager; + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(__dirname, 'temp-test-explorer'); + manager = new SorobanScanHistoryManager({ + storageDirectory: tempDir, + maxScansPerContract: 10, + }); + explorer = new SorobanScanHistoryExplorer(manager); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('getContractSummary', () => { + it('should return summary of all contracts', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results); + + const summary = explorer.getContractSummary(); + + expect(summary.length).toBe(2); + expect(summary.find(s => s.contractName === 'ContractA')?.scanCount).toBe(2); + expect(summary.find(s => s.contractName === 'ContractB')?.scanCount).toBe(1); + }); + + it('should calculate average issues per scan', () => { + const results1: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule2', + contractName: 'ContractA', + filePath: '/path/a', + line: 20, + message: 'Issue 2', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + const results2: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results1); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results2); + + const summary = explorer.getContractSummary(); + const contractSummary = summary.find(s => s.contractName === 'ContractA'); + + expect(contractSummary?.totalIssues).toBe(3); + expect(contractSummary?.averageIssuesPerScan).toBe(1.5); + }); + }); + + describe('getTrendAnalysis', () => { + it('should return trend data for a contract', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + + const trend = explorer.getTrendAnalysis('ContractA'); + + expect(trend.length).toBe(2); + expect(trend[0].totalIssues).toBe(1); + expect(trend[0].errorCount).toBe(1); + expect(trend[0].scanId).toBeDefined(); + }); + + it('should limit number of scans returned', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + + const trend = explorer.getTrendAnalysis('ContractA', { maxScans: 2 }); + + expect(trend.length).toBe(2); + }); + }); + + describe('findScansWithIssues', () => { + beforeEach(() => { + const results1: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Error issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const results2: SorobanAnalysisResult[] = [ + { + ruleId: 'rule2', + contractName: 'ContractB', + filePath: '/path/b', + line: 20, + message: 'Warning issue', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results1); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results2); + }); + + it('should find scans with minimum severity', () => { + const scans = explorer.findScansWithIssues({ minSeverity: 'error' }); + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractA'); + }); + + it('should find scans by category', () => { + const scans = explorer.findScansWithIssues({ category: 'gas' }); + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractB'); + }); + + it('should find scans by contract name', () => { + const scans = explorer.findScansWithIssues({ contractName: 'ContractA' }); + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractA'); + }); + }); + + describe('getRecentScans', () => { + it('should return recent scans across all contracts', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results); + manager.addScan('ContractC', '/path/c', 'Network', 'https://net', results); + + const recent = explorer.getRecentScans(2); + + expect(recent.length).toBe(2); + expect(recent).not.toHaveProperty('results'); + }); + + it('should limit results', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results); + + const recent = explorer.getRecentScans(1); + + expect(recent.length).toBe(1); + }); + }); + + describe('getScansByTag', () => { + it('should return scans with specific tag', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results, { + tags: ['production', 'audit'], + }); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results, { + tags: ['development'], + }); + + const scans = explorer.getScansByTag('production'); + + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractA'); + }); + }); + + describe('getComparisonHistory', () => { + it('should return comparison history for a contract', () => { + const results1: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const results2: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule2', + contractName: 'ContractA', + filePath: '/path/a', + line: 20, + message: 'Issue 2', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results1); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results2); + + const comparisons = explorer.getComparisonHistory('ContractA'); + + expect(comparisons.length).toBe(1); + expect(comparisons[0].diff.newIssues.length).toBe(1); + }); + + it('should return empty array for contract with fewer than 2 scans', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + + const comparisons = explorer.getComparisonHistory('ContractA'); + + expect(comparisons.length).toBe(0); + }); + }); + + describe('searchByDescription', () => { + it('should find scans matching description query', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results, { + description: 'Production audit scan', + }); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results, { + description: 'Development scan', + }); + + const scans = explorer.searchByDescription('production'); + + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractA'); + }); + + it('should find scans matching tag query', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results, { + tags: ['production', 'audit'], + }); + + const scans = explorer.searchByDescription('audit'); + + expect(scans.length).toBe(1); + }); + }); + + describe('ScanExplorerBuilder', () => { + beforeEach(() => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network1', 'https://net1', results, { + tags: ['production'], + }); + manager.addScan('ContractB', '/path/b', 'Network2', 'https://net2', results, { + tags: ['development'], + }); + }); + + it('should build and execute query with contract filter', () => { + const result = explorer + .explore() + .byContract('ContractA') + .execute(); + + expect(result.scans.length).toBe(1); + expect(result.scans[0].contractName).toBe('ContractA'); + }); + + it('should build and execute query with network filter', () => { + const result = explorer + .explore() + .byNetwork('Network1') + .execute(); + + expect(result.scans.length).toBe(1); + expect(result.scans[0].networkPassphrase).toBe('Network1'); + }); + + it('should build and execute query with tags filter', () => { + const result = explorer + .explore() + .byTags('production') + .execute(); + + expect(result.scans.length).toBe(1); + expect(result.scans[0].tags).toContain('production'); + }); + + it('should build and execute query with sorting', () => { + const result = explorer + .explore() + .sortBy('contractName', 'asc') + .execute(); + + expect(result.scans[0].contractName).toBe('ContractA'); + expect(result.scans[1].contractName).toBe('ContractB'); + }); + + it('should build and execute query with limit', () => { + const result = explorer + .explore() + .limitResults(1) + .execute(); + + expect(result.scans.length).toBe(1); + expect(result.totalCount).toBe(2); + }); + + it('should build and execute query with results included', () => { + const result = explorer + .explore() + .byContract('ContractA') + .withResults() + .execute(); + + expect(result.scans.length).toBe(1); + expect(result.scans[0].results.length).toBeGreaterThan(0); + }); + + it('should convert to array', () => { + const scans = explorer + .explore() + .byContract('ContractA') + .toArray(); + + expect(scans.length).toBe(1); + expect(scans[0].contractName).toBe('ContractA'); + }); + + it('should convert to metadata array', () => { + const metadata = explorer + .explore() + .byContract('ContractA') + .toMetadataArray(); + + expect(metadata.length).toBe(1); + expect(metadata[0]).not.toHaveProperty('results'); + }); + }); + + describe('explore', () => { + it('should return a new ScanExplorerBuilder', () => { + const builder = explorer.explore(); + expect(builder).toBeDefined(); + expect(builder).toBeInstanceOf(explorer['constructor'].name === 'SorobanScanHistoryExplorer' ? Object.getPrototypeOf(explorer).constructor : Object); + }); + }); + + describe('getManager', () => { + it('should return the underlying manager', () => { + const retrievedManager = explorer.getManager(); + expect(retrievedManager).toBe(manager); + }); + }); +}); diff --git a/src/history/scans/stellar/scan-history-explorer.ts b/src/history/scans/stellar/scan-history-explorer.ts new file mode 100644 index 0000000..83f80a4 --- /dev/null +++ b/src/history/scans/stellar/scan-history-explorer.ts @@ -0,0 +1,331 @@ +/** + * Soroban Scan History Explorer + * + * Provides high-level exploration and analysis functionality for Soroban scan history. + * Builds on the ScanHistoryManager to provide convenient methods for browsing and analyzing scans. + */ + +import { + SorobanScanRecord, + SorobanScanMetadata, + SorobanScanFilter, + SorobanScanQueryOptions, + SorobanScanQueryResult, + SorobanScanComparison, +} from './types'; +import { SorobanScanHistoryManager } from './scan-history-manager'; + +export class SorobanScanHistoryExplorer { + private manager: SorobanScanHistoryManager; + + constructor(manager?: SorobanScanHistoryManager) { + this.manager = manager || new SorobanScanHistoryManager(); + } + + /** + * Get the underlying history manager + */ + getManager(): SorobanScanHistoryManager { + return this.manager; + } + + /** + * Explore scan history with a fluent interface + */ + explore(): ScanExplorerBuilder { + return new ScanExplorerBuilder(this.manager); + } + + /** + * Get a summary of all contracts with scan history + */ + getContractSummary(): Array<{ + contractName: string; + scanCount: number; + latestScan?: SorobanScanMetadata; + totalIssues: number; + averageIssuesPerScan: number; + }> { + const stats = this.manager.getStatistics(); + const summary: Array<{ + contractName: string; + scanCount: number; + latestScan?: SorobanScanMetadata; + totalIssues: number; + averageIssuesPerScan: number; + }> = []; + + for (const [contractName, scanCount] of Object.entries(stats.scansByContract)) { + const scans = this.manager.getScansByContract(contractName, false); + const latestScan = scans.length > 0 ? this.manager.getScanMetadata(scans[0].scanId) : undefined; + const totalIssues = scans.reduce((sum, scan) => sum + scan.totalIssues, 0); + const averageIssuesPerScan = scanCount > 0 ? totalIssues / scanCount : 0; + + summary.push({ + contractName, + scanCount, + latestScan, + totalIssues, + averageIssuesPerScan, + }); + } + + // Sort by scan count descending + summary.sort((a, b) => b.scanCount - a.scanCount); + + return summary; + } + + /** + * Get trend analysis for a contract over time + */ + getTrendAnalysis( + contractName: string, + options?: { + maxScans?: number; + } + ): Array<{ + scanId: string; + timestamp: number; + totalIssues: number; + errorCount: number; + warningCount: number; + infoCount: number; + durationMs: number; + }> { + const maxScans = options?.maxScans || 20; + const scans = this.manager.getScansByContract(contractName, false).slice(0, maxScans); + + return scans.map(scan => ({ + scanId: scan.scanId, + timestamp: scan.timestamp, + totalIssues: scan.totalIssues, + errorCount: scan.severityBreakdown.error, + warningCount: scan.severityBreakdown.warning, + infoCount: scan.severityBreakdown.info, + durationMs: scan.durationMs, + })); + } + + /** + * Find scans with specific characteristics + */ + findScansWithIssues(options: { + minSeverity?: 'error' | 'warning' | 'info'; + category?: string; + contractName?: string; + }): SorobanScanRecord[] { + const filter: SorobanScanFilter = {}; + if (options.contractName) filter.contractName = options.contractName; + if (options.minSeverity) filter.minSeverity = options.minSeverity; + if (options.category) filter.category = options.category; + + return this.manager.queryScans({ + filter, + includeResults: true, + sort: { sortBy: 'timestamp', sortOrder: 'desc' }, + }).scans; + } + + /** + * Get recent scans across all contracts + */ + getRecentScans(limit = 10): SorobanScanMetadata[] { + const result = this.manager.queryScans({ + limit, + includeResults: false, + sort: { sortBy: 'timestamp', sortOrder: 'desc' }, + }); + + return result.scans.map(scan => { + const { results, ...metadata } = scan; + return metadata; + }); + } + + /** + * Get scans by tag + */ + getScansByTag(tag: string): SorobanScanRecord[] { + return this.manager.queryScans({ + filter: { tags: [tag] }, + includeResults: true, + sort: { sortBy: 'timestamp', sortOrder: 'desc' }, + }).scans; + } + + /** + * Get comparison history for a contract (all consecutive comparisons) + */ + getComparisonHistory(contractName: string): SorobanScanComparison[] { + const scans = this.manager.getScansByContract(contractName, true); + const comparisons: SorobanScanComparison[] = []; + + for (let i = 0; i < scans.length - 1; i++) { + const comparison = this.manager.compareScans(scans[i + 1].scanId, scans[i].scanId); + if (comparison) { + comparisons.push(comparison); + } + } + + return comparisons; + } + + /** + * Search scans by description or notes + */ + searchByDescription(query: string): SorobanScanRecord[] { + const allScans = Array.from( + this.manager.queryScans({ includeResults: true }).scans + ); + + const lowerQuery = query.toLowerCase(); + return allScans.filter( + scan => + scan.description?.toLowerCase().includes(lowerQuery) || + scan.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)) + ); + } +} + +/** + * Fluent builder for exploring scan history + */ +export class ScanExplorerBuilder { + private filter: SorobanScanFilter = {}; + private sort?: { sortBy: 'timestamp' | 'contractName' | 'totalIssues' | 'durationMs'; sortOrder: 'asc' | 'desc' }; + private limit?: number; + private offset?: number; + private includeResults = false; + + constructor(private manager: SorobanScanHistoryManager) {} + + /** + * Filter by contract name + */ + byContract(contractName: string): this { + this.filter.contractName = contractName; + return this; + } + + /** + * Filter by file path pattern + */ + byFilePath(pattern: RegExp): this { + this.filter.filePathPattern = pattern; + return this; + } + + /** + * Filter by network + */ + byNetwork(networkPassphrase: string): this { + this.filter.networkPassphrase = networkPassphrase; + return this; + } + + /** + * Filter by tags + */ + byTags(...tags: string[]): this { + this.filter.tags = tags; + return this; + } + + /** + * Filter by date range + */ + byDateRange(start: number, end: number): this { + this.filter.dateRange = { start, end }; + return this; + } + + /** + * Filter by minimum severity + */ + byMinSeverity(severity: 'error' | 'warning' | 'info'): this { + this.filter.minSeverity = severity; + return this; + } + + /** + * Filter by category + */ + byCategory(category: string): this { + this.filter.category = category; + return this; + } + + /** + * Filter by ledger sequence range + */ + byLedgerRange(min?: number, max?: number): this { + this.filter.ledgerRange = { min, max }; + return this; + } + + /** + * Sort results + */ + sortBy( + sortBy: 'timestamp' | 'contractName' | 'totalIssues' | 'durationMs', + sortOrder: 'asc' | 'desc' = 'desc' + ): this { + this.sort = { sortBy, sortOrder }; + return this; + } + + /** + * Limit number of results + */ + limitResults(limit: number): this { + this.limit = limit; + return this; + } + + /** + * Set offset for pagination + */ + skip(offset: number): this { + this.offset = offset; + return this; + } + + /** + * Include full results in output + */ + withResults(): this { + this.includeResults = true; + return this; + } + + /** + * Execute the query and return results + */ + execute(): SorobanScanQueryResult { + return this.manager.queryScans({ + filter: Object.keys(this.filter).length > 0 ? this.filter : undefined, + sort: this.sort, + limit: this.limit, + offset: this.offset, + includeResults: this.includeResults, + }); + } + + /** + * Execute the query and return only the scan array + */ + toArray(): SorobanScanRecord[] { + return this.execute().scans; + } + + /** + * Execute the query and return only metadata + */ + toMetadataArray(): SorobanScanMetadata[] { + return this.toArray().map(scan => { + const { results, ...metadata } = scan; + return metadata; + }); + } +} diff --git a/src/history/scans/stellar/scan-history-manager.spec.ts b/src/history/scans/stellar/scan-history-manager.spec.ts new file mode 100644 index 0000000..4e3c487 --- /dev/null +++ b/src/history/scans/stellar/scan-history-manager.spec.ts @@ -0,0 +1,523 @@ +/** + * Soroban Scan History Manager Tests + */ + +import { SorobanScanHistoryManager } from './scan-history-manager'; +import { SorobanAnalysisResult } from '../../../diffing/stellar/types'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('SorobanScanHistoryManager', () => { + let manager: SorobanScanHistoryManager; + let tempDir: string; + + beforeEach(() => { + // Create a temporary directory for testing + tempDir = path.join(__dirname, 'temp-test-history'); + manager = new SorobanScanHistoryManager({ + storageDirectory: tempDir, + maxScansPerContract: 10, + maxScanAgeMs: 365 * 24 * 60 * 60 * 1000, + }); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('addScan', () => { + it('should add a new scan record', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const scan = manager.addScan( + 'TestContract', + '/test/path', + 'Test Network', + 'https://test.network', + results + ); + + expect(scan).toBeDefined(); + expect(scan.contractName).toBe('TestContract'); + expect(scan.totalIssues).toBe(1); + expect(scan.severityBreakdown.error).toBe(1); + expect(scan.scanId).toBeDefined(); + }); + + it('should calculate severity breakdown correctly', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Error issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule2', + contractName: 'TestContract', + filePath: '/test/path', + line: 20, + message: 'Warning issue', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + { + ruleId: 'rule3', + contractName: 'TestContract', + filePath: '/test/path', + line: 30, + message: 'Info issue', + severity: 'info', + confidence: 0.7, + category: 'style', + }, + ]; + + const scan = manager.addScan( + 'TestContract', + '/test/path', + 'Test Network', + 'https://test.network', + results + ); + + expect(scan.severityBreakdown.error).toBe(1); + expect(scan.severityBreakdown.warning).toBe(1); + expect(scan.severityBreakdown.info).toBe(1); + }); + + it('should calculate category breakdown correctly', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Security issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule2', + contractName: 'TestContract', + filePath: '/test/path', + line: 20, + message: 'Security issue 2', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule3', + contractName: 'TestContract', + filePath: '/test/path', + line: 30, + message: 'Gas issue', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + const scan = manager.addScan( + 'TestContract', + '/test/path', + 'Test Network', + 'https://test.network', + results + ); + + expect(scan.categoryBreakdown.security).toBe(2); + expect(scan.categoryBreakdown.gas).toBe(1); + }); + }); + + describe('getScan', () => { + it('should retrieve a scan by ID', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const addedScan = manager.addScan( + 'TestContract', + '/test/path', + 'Test Network', + 'https://test.network', + results + ); + + const retrievedScan = manager.getScan(addedScan.scanId); + + expect(retrievedScan).toBeDefined(); + expect(retrievedScan?.scanId).toBe(addedScan.scanId); + }); + + it('should return undefined for non-existent scan', () => { + const scan = manager.getScan('non-existent-id'); + expect(scan).toBeUndefined(); + }); + }); + + describe('getScanMetadata', () => { + it('should retrieve scan metadata without results', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const addedScan = manager.addScan( + 'TestContract', + '/test/path', + 'Test Network', + 'https://test.network', + results + ); + + const metadata = manager.getScanMetadata(addedScan.scanId); + + expect(metadata).toBeDefined(); + expect(metadata?.scanId).toBe(addedScan.scanId); + expect(metadata).not.toHaveProperty('results'); + }); + }); + + describe('queryScans', () => { + beforeEach(() => { + // Add multiple test scans + const results1: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'ContractA', + filePath: '/path/a', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const results2: SorobanAnalysisResult[] = [ + { + ruleId: 'rule2', + contractName: 'ContractB', + filePath: '/path/b', + line: 20, + message: 'Issue 2', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network1', 'https://net1', results1); + manager.addScan('ContractB', '/path/b', 'Network2', 'https://net2', results2); + }); + + it('should return all scans when no filter is provided', () => { + const result = manager.queryScans(); + expect(result.scans.length).toBe(2); + expect(result.totalCount).toBe(2); + }); + + it('should filter by contract name', () => { + const result = manager.queryScans({ + filter: { contractName: 'ContractA' }, + }); + expect(result.scans.length).toBe(1); + expect(result.scans[0].contractName).toBe('ContractA'); + }); + + it('should filter by network passphrase', () => { + const result = manager.queryScans({ + filter: { networkPassphrase: 'Network1' }, + }); + expect(result.scans.length).toBe(1); + expect(result.scans[0].networkPassphrase).toBe('Network1'); + }); + + it('should sort by timestamp descending', () => { + const result = manager.queryScans({ + sort: { sortBy: 'timestamp', sortOrder: 'desc' }, + }); + expect(result.scans[0].timestamp).toBeGreaterThanOrEqual(result.scans[1].timestamp); + }); + + it('should limit results', () => { + const result = manager.queryScans({ limit: 1 }); + expect(result.scans.length).toBe(1); + expect(result.totalCount).toBe(2); + }); + + it('should apply offset for pagination', () => { + const result = manager.queryScans({ offset: 1 }); + expect(result.scans.length).toBe(1); + expect(result.totalCount).toBe(2); + }); + }); + + describe('getScansByContract', () => { + it('should return all scans for a specific contract', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + manager.addScan('OtherContract', '/other/path', 'Network', 'https://net', results); + + const scans = manager.getScansByContract('TestContract'); + expect(scans.length).toBe(2); + expect(scans.every(s => s.contractName === 'TestContract')).toBe(true); + }); + }); + + describe('getLatestScan', () => { + it('should return the most recent scan for a contract', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + // Small delay to ensure different timestamps + const latest = manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + + const retrieved = manager.getLatestScan('TestContract'); + expect(retrieved?.scanId).toBe(latest.scanId); + }); + + it('should return undefined for contract with no scans', () => { + const scan = manager.getLatestScan('NonExistentContract'); + expect(scan).toBeUndefined(); + }); + }); + + describe('compareScans', () => { + it('should compare two scans and return diff', () => { + const results1: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule2', + contractName: 'TestContract', + filePath: '/test/path', + line: 20, + message: 'Issue 2', + severity: 'warning', + confidence: 0.8, + category: 'gas', + }, + ]; + + const results2: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Issue 1', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + { + ruleId: 'rule3', + contractName: 'TestContract', + filePath: '/test/path', + line: 30, + message: 'Issue 3', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const scan1 = manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results1); + const scan2 = manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results2); + + const comparison = manager.compareScans(scan1.scanId, scan2.scanId); + + expect(comparison).toBeDefined(); + expect(comparison?.diff.newIssues.length).toBe(1); + expect(comparison?.diff.fixedIssues.length).toBe(1); + expect(comparison?.diff.persistentIssues.length).toBe(1); + }); + }); + + describe('deleteScan', () => { + it('should delete a scan by ID', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const scan = manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + const deleted = manager.deleteScan(scan.scanId); + + expect(deleted).toBe(true); + expect(manager.getScan(scan.scanId)).toBeUndefined(); + }); + + it('should return false for non-existent scan', () => { + const deleted = manager.deleteScan('non-existent-id'); + expect(deleted).toBe(false); + }); + }); + + describe('deleteScansByContract', () => { + it('should delete all scans for a contract', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + manager.addScan('OtherContract', '/other/path', 'Network', 'https://net', results); + + const deletedCount = manager.deleteScansByContract('TestContract'); + + expect(deletedCount).toBe(2); + expect(manager.getScansByContract('TestContract').length).toBe(0); + expect(manager.getScansByContract('OtherContract').length).toBe(1); + }); + }); + + describe('getStatistics', () => { + it('should return correct statistics', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractA', '/path/a', 'Network', 'https://net', results); + manager.addScan('ContractB', '/path/b', 'Network', 'https://net', results); + + const stats = manager.getStatistics(); + + expect(stats.totalScans).toBe(3); + expect(stats.totalContracts).toBe(2); + expect(stats.totalIssues).toBe(3); + expect(stats.scansByContract.ContractA).toBe(2); + expect(stats.scansByContract.ContractB).toBe(1); + }); + }); + + describe('persistence', () => { + it('should save and load scans from disk', () => { + const results: SorobanAnalysisResult[] = [ + { + ruleId: 'rule1', + contractName: 'TestContract', + filePath: '/test/path', + line: 10, + message: 'Test issue', + severity: 'error', + confidence: 0.9, + category: 'security', + }, + ]; + + const scan = manager.addScan('TestContract', '/test/path', 'Network', 'https://net', results); + + // Create a new manager instance with the same storage directory + const newManager = new SorobanScanHistoryManager({ + storageDirectory: tempDir, + }); + + const retrievedScan = newManager.getScan(scan.scanId); + + expect(retrievedScan).toBeDefined(); + expect(retrievedScan?.scanId).toBe(scan.scanId); + expect(retrievedScan?.contractName).toBe('TestContract'); + }); + }); +}); diff --git a/src/history/scans/stellar/scan-history-manager.ts b/src/history/scans/stellar/scan-history-manager.ts new file mode 100644 index 0000000..d947a96 --- /dev/null +++ b/src/history/scans/stellar/scan-history-manager.ts @@ -0,0 +1,451 @@ +/** + * Soroban Scan History Manager + * + * Manages storage, retrieval, and filtering of Soroban contract scan history. + * Provides functionality to track analysis results over time. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + SorobanScanRecord, + SorobanScanMetadata, + SorobanScanFilter, + SorobanScanSortOptions, + SorobanScanQueryOptions, + SorobanScanQueryResult, + SorobanScanComparison, + SorobanScanHistoryStorageConfig, +} from './types'; +import { SorobanAnalysisResult, SorobanScanDiff } from '../../../diffing/stellar/types'; +import { SorobanResultDiffer } from '../../../diffing/stellar/soroban-result-differ'; + +export class SorobanScanHistoryManager { + private scans: Map = new Map(); + private config: SorobanScanHistoryStorageConfig; + private storagePath: string; + private differ: SorobanResultDiffer; + + constructor(config?: SorobanScanHistoryStorageConfig) { + this.config = { + maxScansPerContract: 100, + maxScanAgeMs: 365 * 24 * 60 * 60 * 1000, // 1 year + storageDirectory: config?.storageDirectory || path.join(process.cwd(), '.gasguard', 'scan-history'), + enableCompression: true, + ...config, + }; + this.storagePath = this.config.storageDirectory!; + this.differ = new SorobanResultDiffer(); + this.ensureStorageDirectory(); + this.loadFromDisk(); + } + + /** + * Add a new scan record to history + */ + addScan( + contractName: string, + filePath: string, + networkPassphrase: string, + networkUrl: string, + results: SorobanAnalysisResult[], + options?: { + ledgerSequence?: number; + durationMs?: number; + description?: string; + tags?: string[]; + } + ): SorobanScanRecord { + const scanId = crypto.randomUUID(); + const timestamp = Date.now(); + + // Calculate severity breakdown + const severityBreakdown = { + error: results.filter(r => r.severity === 'error').length, + warning: results.filter(r => r.severity === 'warning').length, + info: results.filter(r => r.severity === 'info').length, + }; + + // Calculate category breakdown + const categoryBreakdown: Record = {}; + results.forEach(result => { + categoryBreakdown[result.category] = (categoryBreakdown[result.category] || 0) + 1; + }); + + const metadata: SorobanScanMetadata = { + scanId, + timestamp, + contractName, + filePath, + networkPassphrase, + networkUrl, + ledgerSequence: options?.ledgerSequence, + totalIssues: results.length, + severityBreakdown, + categoryBreakdown, + durationMs: options?.durationMs || 0, + description: options?.description, + tags: options?.tags, + version: '1.0.0', + }; + + const scanRecord: SorobanScanRecord = { + ...metadata, + results, + }; + + this.scans.set(scanId, scanRecord); + this.enforceRetentionPolicy(contractName); + this.saveToDisk(); + + return scanRecord; + } + + /** + * Retrieve a scan by ID + */ + getScan(scanId: string): SorobanScanRecord | undefined { + return this.scans.get(scanId); + } + + /** + * Get scan metadata without full results + */ + getScanMetadata(scanId: string): SorobanScanMetadata | undefined { + const scan = this.scans.get(scanId); + if (!scan) return undefined; + const { results, ...metadata } = scan; + return metadata; + } + + /** + * Query scan history with filters and sorting + */ + queryScans(options: SorobanScanQueryOptions = {}): SorobanScanQueryResult { + const startTime = Date.now(); + let filteredScans = Array.from(this.scans.values()); + + // Apply filters + if (options.filter) { + filteredScans = this.applyFilters(filteredScans, options.filter); + } + + const totalCount = filteredScans.length; + + // Apply sorting + if (options.sort) { + filteredScans = this.applySorting(filteredScans, options.sort); + } + + // Apply pagination + if (options.offset) { + filteredScans = filteredScans.slice(options.offset); + } + if (options.limit) { + filteredScans = filteredScans.slice(0, options.limit); + } + + // Optionally exclude full results + if (!options.includeResults) { + filteredScans = filteredScans.map(scan => { + const { results, ...metadata } = scan; + return { ...metadata, results: [] } as SorobanScanRecord; + }); + } + + const queryDurationMs = Date.now() - startTime; + + return { + scans: filteredScans, + totalCount, + queryDurationMs, + }; + } + + /** + * Get all scans for a specific contract + */ + getScansByContract(contractName: string, includeResults = false): SorobanScanRecord[] { + return this.queryScans({ + filter: { contractName }, + includeResults, + sort: { sortBy: 'timestamp', sortOrder: 'desc' }, + }).scans; + } + + /** + * Get the most recent scan for a contract + */ + getLatestScan(contractName: string): SorobanScanRecord | undefined { + const scans = this.getScansByContract(contractName, true); + return scans.length > 0 ? scans[0] : undefined; + } + + /** + * Compare two scans + */ + compareScans(scanId1: string, scanId2: string): SorobanScanComparison | undefined { + const scan1 = this.scans.get(scanId1); + const scan2 = this.scans.get(scanId2); + + if (!scan1 || !scan2) return undefined; + + const diff = this.differ.diff(scan1.results, scan2.results); + const timeElapsedMs = Math.abs(scan2.timestamp - scan1.timestamp); + + // Ensure scan1 is the earlier scan + const [previousScan, currentScan] = scan1.timestamp < scan2.timestamp + ? [scan1, scan2] + : [scan2, scan1]; + + return { + previousScan, + currentScan, + diff, + timeElapsedMs, + }; + } + + /** + * Compare latest scan with previous scan for a contract + */ + compareLatestWithPrevious(contractName: string): SorobanScanComparison | undefined { + const scans = this.getScansByContract(contractName, true); + if (scans.length < 2) return undefined; + + return this.compareScans(scans[1].scanId, scans[0].scanId); + } + + /** + * Delete a scan from history + */ + deleteScan(scanId: string): boolean { + const deleted = this.scans.delete(scanId); + if (deleted) { + this.saveToDisk(); + } + return deleted; + } + + /** + * Delete all scans for a contract + */ + deleteScansByContract(contractName: string): number { + let deletedCount = 0; + for (const [scanId, scan] of this.scans.entries()) { + if (scan.contractName === contractName) { + this.scans.delete(scanId); + deletedCount++; + } + } + if (deletedCount > 0) { + this.saveToDisk(); + } + return deletedCount; + } + + /** + * Clear all scan history + */ + clearHistory(): void { + this.scans.clear(); + this.saveToDisk(); + } + + /** + * Get statistics about scan history + */ + getStatistics(): { + totalScans: number; + totalContracts: number; + totalIssues: number; + oldestScan?: number; + newestScan?: number; + scansByContract: Record; + } { + const scans = Array.from(this.scans.values()); + const contracts = new Set(scans.map(s => s.contractName)); + const scansByContract: Record = {}; + + scans.forEach(scan => { + scansByContract[scan.contractName] = (scansByContract[scan.contractName] || 0) + 1; + }); + + const timestamps = scans.map(s => s.timestamp).sort((a, b) => a - b); + + return { + totalScans: scans.length, + totalContracts: contracts.size, + totalIssues: scans.reduce((sum, scan) => sum + scan.totalIssues, 0), + oldestScan: timestamps[0], + newestScan: timestamps[timestamps.length - 1], + scansByContract, + }; + } + + /** + * Apply filters to scan list + */ + private applyFilters(scans: SorobanScanRecord[], filter: SorobanScanFilter): SorobanScanRecord[] { + return scans.filter(scan => { + // Filter by contract name + if (filter.contractName && scan.contractName !== filter.contractName) { + return false; + } + + // Filter by file path pattern + if (filter.filePathPattern && !filter.filePathPattern.test(scan.filePath)) { + return false; + } + + // Filter by network passphrase + if (filter.networkPassphrase && scan.networkPassphrase !== filter.networkPassphrase) { + return false; + } + + // Filter by tags (all tags must be present) + if (filter.tags && filter.tags.length > 0) { + const scanTags = scan.tags || []; + if (!filter.tags.every(tag => scanTags.includes(tag))) { + return false; + } + } + + // Filter by date range + if (filter.dateRange) { + if (scan.timestamp < filter.dateRange.start || scan.timestamp > filter.dateRange.end) { + return false; + } + } + + // Filter by minimum severity + if (filter.minSeverity) { + const severityOrder = { error: 0, warning: 1, info: 2 }; + const minLevel = severityOrder[filter.minSeverity]; + const hasMatchingSeverity = scan.results.some( + result => severityOrder[result.severity] <= minLevel + ); + if (!hasMatchingSeverity) { + return false; + } + } + + // Filter by category + if (filter.category) { + const hasMatchingCategory = scan.results.some( + result => result.category === filter.category + ); + if (!hasMatchingCategory) { + return false; + } + } + + // Filter by ledger sequence range + if (filter.ledgerRange) { + if (filter.ledgerRange.min !== undefined && scan.ledgerSequence! < filter.ledgerRange.min) { + return false; + } + if (filter.ledgerRange.max !== undefined && scan.ledgerSequence! > filter.ledgerRange.max) { + return false; + } + } + + return true; + }); + } + + /** + * Apply sorting to scan list + */ + private applySorting(scans: SorobanScanRecord[], sort: SorobanScanSortOptions): SorobanScanRecord[] { + const sorted = [...scans]; + + sorted.sort((a, b) => { + let comparison = 0; + + switch (sort.sortBy) { + case 'timestamp': + comparison = a.timestamp - b.timestamp; + break; + case 'contractName': + comparison = a.contractName.localeCompare(b.contractName); + break; + case 'totalIssues': + comparison = a.totalIssues - b.totalIssues; + break; + case 'durationMs': + comparison = a.durationMs - b.durationMs; + break; + } + + return sort.sortOrder === 'desc' ? -comparison : comparison; + }); + + return sorted; + } + + /** + * Enforce retention policy (max scans per contract, max age) + */ + private enforceRetentionPolicy(contractName: string): void { + const contractScans = Array.from(this.scans.values()) + .filter(scan => scan.contractName === contractName) + .sort((a, b) => b.timestamp - a.timestamp); + + // Remove old scans beyond max count + if (this.config.maxScansPerContract && contractScans.length > this.config.maxScansPerContract) { + const toRemove = contractScans.slice(this.config.maxScansPerContract); + toRemove.forEach(scan => this.scans.delete(scan.scanId)); + } + + // Remove scans beyond max age + if (this.config.maxScanAgeMs) { + const cutoffTime = Date.now() - this.config.maxScanAgeMs; + for (const [scanId, scan] of this.scans.entries()) { + if (scan.timestamp < cutoffTime) { + this.scans.delete(scanId); + } + } + } + } + + /** + * Ensure storage directory exists + */ + private ensureStorageDirectory(): void { + if (!fs.existsSync(this.storagePath)) { + fs.mkdirSync(this.storagePath, { recursive: true }); + } + } + + /** + * Save scan history to disk + */ + private saveToDisk(): void { + try { + const data = JSON.stringify(Array.from(this.scans.entries()), null, 2); + const filePath = path.join(this.storagePath, 'scan-history.json'); + fs.writeFileSync(filePath, data, 'utf-8'); + } catch (error) { + console.error('Failed to save scan history to disk:', error); + } + } + + /** + * Load scan history from disk + */ + private loadFromDisk(): void { + try { + const filePath = path.join(this.storagePath, 'scan-history.json'); + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, 'utf-8'); + const entries = JSON.parse(data); + this.scans = new Map(entries); + } + } catch (error) { + console.error('Failed to load scan history from disk:', error); + this.scans = new Map(); + } + } +} diff --git a/src/history/scans/stellar/types.ts b/src/history/scans/stellar/types.ts new file mode 100644 index 0000000..f2f7662 --- /dev/null +++ b/src/history/scans/stellar/types.ts @@ -0,0 +1,148 @@ +/** + * Soroban Scan History Types + * + * Type definitions for storing and managing Soroban contract scan history + * for tracking analysis results over time. + */ + +import { SorobanAnalysisResult, SorobanScanDiff } from '../../../diffing/stellar/types'; + +/** + * Metadata for a single Soroban contract scan + */ +export interface SorobanScanMetadata { + /** Unique scan identifier */ + scanId: string; + /** Timestamp when the scan was performed */ + timestamp: number; + /** Contract name that was scanned */ + contractName: string; + /** Contract source file path */ + filePath: string; + /** Network passphrase (e.g., 'Test SDF Network ; September 2015') */ + networkPassphrase: string; + /** Network URL */ + networkUrl: string; + /** Ledger sequence at scan time */ + ledgerSequence?: number; + /** Number of issues found in this scan */ + totalIssues: number; + /** Breakdown by severity */ + severityBreakdown: { + error: number; + warning: number; + info: number; + }; + /** Breakdown by category */ + categoryBreakdown: Record; + /** Scan duration in milliseconds */ + durationMs: number; + /** Optional description or notes */ + description?: string; + /** Tags for organizing scans */ + tags?: string[]; + /** Scan version for compatibility */ + version: string; +} + +/** + * Complete scan record including metadata and results + */ +export interface SorobanScanRecord extends SorobanScanMetadata { + /** Analysis results from this scan */ + results: SorobanAnalysisResult[]; +} + +/** + * Filter options for querying scan history + */ +export interface SorobanScanFilter { + /** Filter by contract name */ + contractName?: string; + /** Filter by file path pattern */ + filePathPattern?: RegExp; + /** Filter by network passphrase */ + networkPassphrase?: string; + /** Filter by tags */ + tags?: string[]; + /** Filter by date range */ + dateRange?: { + start: number; + end: number; + }; + /** Filter by severity (minimum level) */ + minSeverity?: 'error' | 'warning' | 'info'; + /** Filter by category */ + category?: string; + /** Filter by ledger sequence range */ + ledgerRange?: { + min?: number; + max?: number; + }; +} + +/** + * Sort options for scan history queries + */ +export interface SorobanScanSortOptions { + /** Sort by field */ + sortBy: 'timestamp' | 'contractName' | 'totalIssues' | 'durationMs'; + /** Sort direction */ + sortOrder: 'asc' | 'desc'; +} + +/** + * Query options for retrieving scan history + */ +export interface SorobanScanQueryOptions { + /** Filter criteria */ + filter?: SorobanScanFilter; + /** Sort options */ + sort?: SorobanScanSortOptions; + /** Maximum number of results to return */ + limit?: number; + /** Offset for pagination */ + offset?: number; + /** Include full results or just metadata */ + includeResults?: boolean; +} + +/** + * Result of a scan history query + */ +export interface SorobanScanQueryResult { + /** Scan records matching the query */ + scans: SorobanScanRecord[]; + /** Total number of scans matching the filter */ + totalCount: number; + /** Query execution time in milliseconds */ + queryDurationMs: number; +} + +/** + * Comparison between two scans + */ +export interface SorobanScanComparison { + /** Previous scan */ + previousScan: SorobanScanMetadata; + /** Current scan */ + currentScan: SorobanScanMetadata; + /** Diff between the scans */ + diff: SorobanScanDiff; + /** Time elapsed between scans in milliseconds */ + timeElapsedMs: number; +} + +/** + * Storage configuration for scan history + */ +export interface SorobanScanHistoryStorageConfig { + /** Maximum number of scans to retain per contract */ + maxScansPerContract?: number; + /** Maximum age of scans to retain in milliseconds */ + maxScanAgeMs?: number; + /** Storage directory path */ + storageDirectory?: string; + /** Enable compression for stored scans */ + enableCompression?: boolean; +}