diff --git a/packages/cli/src/commands/annotate.ts b/packages/cli/src/commands/annotate.ts new file mode 100644 index 0000000..9e5fc7e --- /dev/null +++ b/packages/cli/src/commands/annotate.ts @@ -0,0 +1,28 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { annotateFile, Annotation } from '../../../src/reporting/annotator'; + +export const annotateCommand = new Command('annotate') + .description('Annotate source files with inline issue comments') + .argument('', 'Source file to annotate') + .option('-o, --output ', 'Output file path (default: .annotated)') + .option('--line ', 'Line number for a demo annotation', '1') + .action((file: string, options) => { + try { + // In a real integration the annotations would come from a scan result. + // Here we demonstrate with a placeholder annotation. + const annotations: Annotation[] = [ + { + line: parseInt(options.line, 10), + message: 'Potential gas inefficiency detected – review this pattern.', + severity: 'warning', + }, + ]; + + const result = annotateFile(file, annotations, options.output); + console.log(chalk.green(`✓ Annotated file written to ${result.filePath}`)); + } catch (err) { + console.error(chalk.red(`Error annotating file: ${err}`)); + process.exit(1); + } + }); diff --git a/src/analysis/coverage/coverage-analyzer.ts b/src/analysis/coverage/coverage-analyzer.ts new file mode 100644 index 0000000..edd0c10 --- /dev/null +++ b/src/analysis/coverage/coverage-analyzer.ts @@ -0,0 +1,75 @@ +/** + * Rule Coverage Analyzer (#235) + * Tracks analyzed AST nodes vs total nodes and reports uncovered patterns. + */ + +export interface AstNode { + type: string; + id?: string; + children?: AstNode[]; +} + +export interface CoverageMetrics { + totalNodes: number; + analyzedNodes: number; + coveragePercent: number; + uncoveredPatterns: string[]; +} + +export class RuleCoverageAnalyzer { + private analyzedNodeIds = new Set(); + private totalNodes = 0; + private uncoveredPatterns: string[] = []; + + /** + * Walk the AST and count all nodes. + */ + registerAst(node: AstNode): void { + this.totalNodes++; + if (node.children) { + for (const child of node.children) { + this.registerAst(child); + } + } + } + + /** + * Mark a node as covered by a rule. + */ + markAnalyzed(nodeId: string): void { + this.analyzedNodeIds.add(nodeId); + } + + /** + * Record a pattern that was not matched by any rule. + */ + reportUncovered(pattern: string): void { + if (!this.uncoveredPatterns.includes(pattern)) { + this.uncoveredPatterns.push(pattern); + } + } + + /** + * Generate coverage metrics. + */ + getMetrics(): CoverageMetrics { + const analyzedNodes = this.analyzedNodeIds.size; + const coveragePercent = + this.totalNodes === 0 + ? 100 + : Math.round((analyzedNodes / this.totalNodes) * 100); + + return { + totalNodes: this.totalNodes, + analyzedNodes, + coveragePercent, + uncoveredPatterns: [...this.uncoveredPatterns], + }; + } + + reset(): void { + this.analyzedNodeIds.clear(); + this.totalNodes = 0; + this.uncoveredPatterns = []; + } +} diff --git a/src/analysis/coverage/index.ts b/src/analysis/coverage/index.ts new file mode 100644 index 0000000..aad9ba0 --- /dev/null +++ b/src/analysis/coverage/index.ts @@ -0,0 +1,2 @@ +export { RuleCoverageAnalyzer } from './coverage-analyzer'; +export type { AstNode, CoverageMetrics } from './coverage-analyzer'; diff --git a/src/analysis/heuristics/heuristic-engine.ts b/src/analysis/heuristics/heuristic-engine.ts new file mode 100644 index 0000000..0add79f --- /dev/null +++ b/src/analysis/heuristics/heuristic-engine.ts @@ -0,0 +1,135 @@ +/** + * Heuristic-Based Pattern Detection (#237) + * Detects gas issues using heuristics that combine multiple signals + * beyond what static rules alone can catch. + */ + +export interface HeuristicSignal { + name: string; + weight: number; // 0.0 – 1.0 + matched: boolean; +} + +export interface HeuristicResult { + patternName: string; + score: number; // weighted sum of matched signals + threshold: number; + detected: boolean; + signals: HeuristicSignal[]; +} + +export interface HeuristicPattern { + name: string; + threshold: number; + signals: Array<{ + name: string; + weight: number; + test: (code: string) => boolean; + }>; +} + +const DEFAULT_PATTERNS: HeuristicPattern[] = [ + { + name: 'inefficient-loop', + threshold: 0.6, + signals: [ + { + name: 'loop-keyword', + weight: 0.4, + test: (code) => /\b(for|while|loop)\b/.test(code), + }, + { + name: 'storage-read-in-loop', + weight: 0.4, + test: (code) => /\b(storage|env\.storage)\b/.test(code) && /\b(for|while)\b/.test(code), + }, + { + name: 'unbounded-iteration', + weight: 0.2, + test: (code) => /\.len\(\)|\.length/.test(code), + }, + ], + }, + { + name: 'redundant-storage-write', + threshold: 0.5, + signals: [ + { + name: 'repeated-assignment', + weight: 0.5, + test: (code) => { + const assignments = code.match(/\w+\s*=\s*[^=]/g) ?? []; + const keys = assignments.map((a) => a.split('=')[0].trim()); + return keys.length !== new Set(keys).size; + }, + }, + { + name: 'storage-keyword', + weight: 0.5, + test: (code) => /\b(storage|env\.storage|self\.\w+)\b/.test(code), + }, + ], + }, + { + name: 'large-data-on-chain', + threshold: 0.7, + signals: [ + { + name: 'string-type', + weight: 0.4, + test: (code) => /\bString\b|\bstring\b/.test(code), + }, + { + name: 'vec-of-bytes', + weight: 0.3, + test: (code) => /Vec|bytes/.test(code), + }, + { + name: 'large-literal', + weight: 0.3, + test: (code) => /"[^"]{64,}"/.test(code), + }, + ], + }, +]; + +export class HeuristicEngine { + private patterns: HeuristicPattern[]; + + constructor(patterns: HeuristicPattern[] = DEFAULT_PATTERNS) { + this.patterns = patterns; + } + + /** + * Run all heuristic patterns against a code snippet. + */ + analyze(code: string): HeuristicResult[] { + return this.patterns.map((pattern) => { + const signals: HeuristicSignal[] = pattern.signals.map((s) => ({ + name: s.name, + weight: s.weight, + matched: s.test(code), + })); + + const score = signals.reduce( + (sum, s) => sum + (s.matched ? s.weight : 0), + 0, + ); + + return { + patternName: pattern.name, + score, + threshold: pattern.threshold, + detected: score >= pattern.threshold, + signals, + }; + }); + } + + /** + * Return only the patterns that were detected. + */ + detectIssues(code: string): HeuristicResult[] { + return this.analyze(code).filter((r) => r.detected); + } +} diff --git a/src/analysis/heuristics/index.ts b/src/analysis/heuristics/index.ts new file mode 100644 index 0000000..aa5f394 --- /dev/null +++ b/src/analysis/heuristics/index.ts @@ -0,0 +1,6 @@ +export { HeuristicEngine } from './heuristic-engine'; +export type { + HeuristicSignal, + HeuristicResult, + HeuristicPattern, +} from './heuristic-engine'; diff --git a/src/auto-fix/rollback/mod.rs b/src/auto-fix/rollback/mod.rs new file mode 100644 index 0000000..a91af32 --- /dev/null +++ b/src/auto-fix/rollback/mod.rs @@ -0,0 +1,2 @@ +pub mod rollback_manager; +pub use rollback_manager::{AppliedFix, RollbackManager}; diff --git a/src/auto-fix/rollback/rollback_manager.rs b/src/auto-fix/rollback/rollback_manager.rs new file mode 100644 index 0000000..70c5b93 --- /dev/null +++ b/src/auto-fix/rollback/rollback_manager.rs @@ -0,0 +1,110 @@ +/// Auto-Fix Rollback System (#238) +/// Tracks applied fixes and provides a revert mechanism. + +use std::collections::HashMap; + +/// A single applied fix that can be rolled back. +#[derive(Debug, Clone)] +pub struct AppliedFix { + pub id: String, + pub file_path: String, + pub original_content: String, + pub patched_content: String, + pub rule_id: String, +} + +/// Manages a stack of applied fixes and supports rollback. +#[derive(Debug, Default)] +pub struct RollbackManager { + history: Vec, + /// Map from fix id → index in history for O(1) lookup. + index: HashMap, +} + +impl RollbackManager { + pub fn new() -> Self { + Self::default() + } + + /// Record a fix that has been applied. + pub fn record(&mut self, fix: AppliedFix) { + let idx = self.history.len(); + self.index.insert(fix.id.clone(), idx); + self.history.push(fix); + } + + /// Revert the most recently applied fix. + /// Returns the original content that should be written back to disk. + pub fn rollback_last(&mut self) -> Option { + let fix = self.history.pop()?; + self.index.remove(&fix.id); + Some(fix) + } + + /// Revert a specific fix by id. + /// Returns the fix if found, None otherwise. + pub fn rollback_by_id(&mut self, id: &str) -> Option { + let idx = *self.index.get(id)?; + self.index.remove(id); + Some(self.history.remove(idx)) + } + + /// Number of fixes currently tracked. + pub fn len(&self) -> usize { + self.history.len() + } + + pub fn is_empty(&self) -> bool { + self.history.is_empty() + } + + /// List all tracked fix ids in application order. + pub fn fix_ids(&self) -> Vec<&str> { + self.history.iter().map(|f| f.id.as_str()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_fix(id: &str) -> AppliedFix { + AppliedFix { + id: id.to_string(), + file_path: "contract.rs".to_string(), + original_content: "original".to_string(), + patched_content: "patched".to_string(), + rule_id: "RULE-001".to_string(), + } + } + + #[test] + fn test_record_and_rollback_last() { + let mut mgr = RollbackManager::new(); + mgr.record(make_fix("fix-1")); + mgr.record(make_fix("fix-2")); + assert_eq!(mgr.len(), 2); + + let reverted = mgr.rollback_last().unwrap(); + assert_eq!(reverted.id, "fix-2"); + assert_eq!(mgr.len(), 1); + } + + #[test] + fn test_rollback_by_id() { + let mut mgr = RollbackManager::new(); + mgr.record(make_fix("fix-1")); + mgr.record(make_fix("fix-2")); + + let reverted = mgr.rollback_by_id("fix-1").unwrap(); + assert_eq!(reverted.id, "fix-1"); + assert_eq!(reverted.original_content, "original"); + assert_eq!(mgr.len(), 1); + } + + #[test] + fn test_rollback_unknown_id_returns_none() { + let mut mgr = RollbackManager::new(); + assert!(mgr.rollback_by_id("nonexistent").is_none()); + } +} diff --git a/src/reporting/annotator/code-annotator.ts b/src/reporting/annotator/code-annotator.ts new file mode 100644 index 0000000..6e50afb --- /dev/null +++ b/src/reporting/annotator/code-annotator.ts @@ -0,0 +1,59 @@ +/** + * Inline Code Annotation Output (#236) + * Annotates source code lines with detected issues as inline comments. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface Annotation { + line: number; // 1-based + message: string; + severity?: 'error' | 'warning' | 'info'; +} + +export interface AnnotationResult { + filePath: string; + annotatedContent: string; +} + +/** + * Insert inline comments into source code at the specified lines. + */ +export function annotateSource( + source: string, + annotations: Annotation[], + commentPrefix = '//', +): string { + const lines = source.split('\n'); + // Sort descending so inserting doesn't shift subsequent line numbers + const sorted = [...annotations].sort((a, b) => b.line - a.line); + + for (const ann of sorted) { + const idx = ann.line - 1; + if (idx < 0 || idx >= lines.length) continue; + const tag = ann.severity ? `[${ann.severity.toUpperCase()}]` : '[NOTE]'; + lines.splice(idx, 0, `${commentPrefix} GasGuard ${tag}: ${ann.message}`); + } + + return lines.join('\n'); +} + +/** + * Annotate a file on disk and write the result to an output path. + */ +export function annotateFile( + filePath: string, + annotations: Annotation[], + outputPath?: string, +): AnnotationResult { + const source = fs.readFileSync(filePath, 'utf8'); + const ext = path.extname(filePath); + const commentPrefix = ext === '.rs' ? '//' : ext === '.vy' ? '#' : '//'; + const annotatedContent = annotateSource(source, annotations, commentPrefix); + + const dest = outputPath ?? filePath + '.annotated'; + fs.writeFileSync(dest, annotatedContent, 'utf8'); + + return { filePath: dest, annotatedContent }; +} diff --git a/src/reporting/annotator/index.ts b/src/reporting/annotator/index.ts new file mode 100644 index 0000000..4544f81 --- /dev/null +++ b/src/reporting/annotator/index.ts @@ -0,0 +1,2 @@ +export { annotateSource, annotateFile } from './code-annotator'; +export type { Annotation, AnnotationResult } from './code-annotator';