use crate::ai::{
consensus_to_core_finding, create_provider, AuditContext, ConsensusConfig, ConsensusEngine,
GasAuditor, LlmProvider, LogicAuditor, SecurityAuditor,
};
use crate::chains::ChainRegistry;
use crate::core::{
AuditResult, AuditSummary, OutputFormat, ProjectConfig, RiskLevel, SecurityScores,
};
use crate::exploit;
use crate::gas;
use crate::notify;
use crate::plugins::PluginRegistry;
use crate::reports;
use crate::security::SecurityEngine;
use crate::suppressions;
use crate::utils::Cache;
use super::AuditArgs;
use anyhow::Result;
use colored::Colorize;
use std::time::Instant;
pub fn run(args: &AuditArgs) -> Result<()> {
if args.list_templates {
let templates = crate::templates::list_templates();
eprintln!("{}", "📋 Available Audit Templates:".bold());
eprintln!();
for t in &templates {
crate::templates::print_template(t);
}
return Ok(());
}
let template = if let Some(template_name) = &args.template {
match crate::templates::get_template(template_name) {
Some(template) => {
eprintln!(
"{} Using audit template: {} — {}",
"📋".bold(),
template.name.bold().cyan(),
template.description
);
if !template.focus_areas.is_empty() {
eprintln!(
" {} Focus areas: {}",
"🎯".bold(),
template.focus_areas.join(", ")
);
}
Some(template)
}
None => {
anyhow::bail!(
"Unknown audit template: '{}'. Use --list-templates to see available templates.",
template_name
);
}
}
} else {
None
};
let start = Instant::now();
let mut config = ProjectConfig::from_default_location();
apply_args(&mut config, args);
if let Some(t) = &template {
crate::templates::apply_to_config(&mut config, t);
}
let cache = Cache::new(&config)?;
let chain_registry = ChainRegistry::default();
let plugin_registry = PluginRegistry::new(&config)?;
let security_engine = SecurityEngine::new(&config, &plugin_registry)?;
let source_files = discover_sources(&config)?;
if source_files.is_empty() {
anyhow::bail!("No Solidity source files found in {:?}", config.src_dirs);
}
let changed_files = cache.filter_changed_files(&source_files);
let skipped_count = source_files.len() - changed_files.len();
let files_to_analyze = if changed_files.is_empty() && !source_files.is_empty() {
source_files.clone()
} else {
changed_files
};
if skipped_count > 0 && !files_to_analyze.is_empty() {
eprintln!(
"⏭️ Skipping {} unchanged files (content hash matched)",
skipped_count
);
}
if args.quick {
eprintln!(
"⚡ Running quick audit on {} files (chain: {})...",
files_to_analyze.len(),
args.shared.chain
);
} else {
eprintln!(
"🔍 Running security audit on {} files (chain: {})...",
files_to_analyze.len(),
args.shared.chain
);
}
let findings = if args.quick {
security_engine.analyze_files_quick(&files_to_analyze, &chain_registry)?
} else {
security_engine.analyze_files(&files_to_analyze, &chain_registry)?
};
if !args.quick {
for file in &files_to_analyze {
let _ = cache.record_file_hash(file);
}
}
let exploit_findings = if !args.quick && (args.exploit || args.full) {
eprintln!("💥 Running exploit path analysis...");
exploit::analyze_exploit_paths(&findings, &files_to_analyze)?
} else {
Vec::new()
};
let gas_findings = if !args.quick && (args.gas || args.full) {
eprintln!("⛽ Running gas analysis...");
gas::analyze_gas(&files_to_analyze, &config)?
} else {
Vec::new()
};
let ai_findings = if !args.quick && args.ai {
run_ai_analysis(&files_to_analyze, args, &config)?
} else {
Vec::new()
};
let mut all_findings = findings;
all_findings.extend(exploit_findings);
all_findings.extend(gas_findings);
all_findings.extend(ai_findings);
if args.generate_suppressions {
let path = args
.suppressions
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(".forge-guard-suppressions"));
suppressions::write(&path, &all_findings)?;
eprintln!(
"✅ Suppression file written to {} (generated from current findings)",
path.display()
);
return Ok(());
}
let suppression_list = match &args.suppressions {
Some(path) => {
let list = suppressions::load(path)?;
if !list.is_empty() {
eprintln!(
"⛔ Loaded {} suppression{} from {}",
list.len(),
if list.len() == 1 { "" } else { "s" },
path.display()
);
}
list
}
None => Vec::new(),
};
if !suppression_list.is_empty() {
let mut suppressed_count = 0usize;
for f in &mut all_findings {
if suppressions::is_suppressed(f, &suppression_list) {
f.suppressed = true;
suppressed_count += 1;
}
}
if suppressed_count > 0 {
eprintln!(
"⛔ Suppressed {} finding{} (use --show-suppressed to display)",
suppressed_count,
if suppressed_count == 1 { "" } else { "s" }
);
}
}
let active_findings: Vec<_> = if args.show_suppressed {
all_findings.clone()
} else {
all_findings
.iter()
.filter(|f| !f.suppressed)
.cloned()
.collect()
};
let scores = security_engine.calculate_scores(&active_findings);
let overall_score = calculate_overall(&scores, template.as_ref());
let risk_level = determine_risk_level(overall_score, &active_findings);
let min_scores_met = template_min_scores_met(&scores, template.as_ref());
let production_ready = overall_score >= config.min_deployment_score && min_scores_met;
let deployment_approved = !has_blocking_findings(&active_findings) && production_ready;
let duration = start.elapsed().as_secs_f64();
let result = AuditResult {
project_name: config.project_root.to_string_lossy().to_string(),
chain: args.shared.chain.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
duration_seconds: duration,
findings: active_findings.clone(),
scores,
overall_score,
risk_level,
production_ready,
deployment_approved,
summary: build_summary(&active_findings, &source_files),
};
match config.output {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Markdown => {
let md = reports::markdown::generate_report(&result)?;
println!("{}", md);
}
OutputFormat::Html => {
let html = reports::html::generate_report(&result)?;
println!("{}", html);
}
OutputFormat::Terminal => {
if args.quick || args.summary {
let summary = reports::generate_executive_summary(&result);
println!("{}", summary);
} else {
print_terminal_report(&result, args);
}
}
}
if args.shared.report {
let report_dir = std::path::PathBuf::from("reports");
std::fs::create_dir_all(&report_dir)?;
reports::json::write_report(&result, &report_dir.join("audit.json"))?;
let md = reports::markdown::generate_report(&result)?;
std::fs::write(report_dir.join("audit.md"), md)?;
let html = reports::html::generate_report(&result)?;
std::fs::write(report_dir.join("audit.html"), html)?;
eprintln!("📄 Reports saved to reports/");
}
cache.store("last_audit", &result)?;
if args.notify {
let notification = notify::AuditNotification {
title: "Forge Guard Audit".into(),
project: result.project_name.clone(),
chain: result.chain.clone(),
overall_score: Some(result.overall_score),
risk: Some(result.risk_level.to_string()),
success: result.production_ready,
findings: result.findings.clone(),
};
notify::notify_from_config(&config, ¬ification, false)?;
}
if args.shared.strict && active_findings.iter().any(|f| f.severity.score() >= 3) {
anyhow::bail!("Strict mode: findings detected with severity MEDIUM or higher");
}
Ok(())
}
fn apply_args(config: &mut ProjectConfig, args: &AuditArgs) {
config.chain = args.shared.chain.clone();
config.project_root = args.shared.project.clone();
config.strict = args.shared.strict;
config.offline = args.shared.offline;
config.production = args.shared.production;
config.parallelism = args.shared.parallelism;
config.output = if args.shared.json {
OutputFormat::Json
} else if args.shared.html {
OutputFormat::Html
} else if args.shared.markdown {
OutputFormat::Markdown
} else {
OutputFormat::Terminal
};
if args.sources != "src" && !args.sources.is_empty() {
config.src_dirs = args
.sources
.split(',')
.map(std::path::PathBuf::from)
.collect();
}
if let Some(exclude) = &args.exclude {
config.exclude = exclude.split(',').map(String::from).collect();
}
}
fn discover_sources(config: &ProjectConfig) -> Result<Vec<std::path::PathBuf>> {
let mut files = Vec::new();
for dir in &config.src_dirs {
let dir_path = if dir.is_absolute() {
dir.clone()
} else {
config.project_root.join(dir)
};
if !dir_path.exists() {
continue;
}
for entry in walkdir::WalkDir::new(&dir_path)
.into_iter()
.filter_entry(|e| {
!config
.exclude
.iter()
.any(|p| e.file_name().to_string_lossy().contains(p))
})
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "sol") {
files.push(path.to_path_buf());
}
}
}
Ok(files)
}
fn calculate_overall(
scores: &SecurityScores,
template: Option<&crate::templates::AuditTemplate>,
) -> u8 {
let cats: [(&str, u8); 12] = [
("access_control", scores.access_control),
("security", scores.security),
("fuzzing", scores.fuzzing),
("gas", scores.gas),
("architecture", scores.architecture),
("upgradeability", scores.upgradeability),
("dependencies", scores.dependencies),
("deployment", scores.deployment),
("proxy_safety", scores.proxy_safety),
("chain_compatibility", scores.chain_compatibility),
("production_readiness", scores.production_readiness),
("exploit_resistance", scores.exploit_resistance),
];
let mut total: u32 = 0;
let mut weight: u32 = 0;
for (name, val) in cats {
let is_focus = template
.map(|t| t.focus_areas.iter().any(|f| f == name))
.unwrap_or(false);
let w = if is_focus { 2 } else { 1 };
total += u32::from(val) * w;
weight += w;
}
(total / weight) as u8
}
fn template_min_scores_met(
scores: &SecurityScores,
template: Option<&crate::templates::AuditTemplate>,
) -> bool {
let Some(t) = template else {
return true;
};
if t.min_scores.is_empty() {
return true;
}
let category_score = |cat: &str| -> u8 {
match cat {
"access_control" => scores.access_control,
"security" => scores.security,
"fuzzing" => scores.fuzzing,
"gas" => scores.gas,
"architecture" => scores.architecture,
"upgradeability" => scores.upgradeability,
"dependencies" => scores.dependencies,
"deployment" => scores.deployment,
"proxy_safety" => scores.proxy_safety,
"chain_compatibility" => scores.chain_compatibility,
"production_readiness" => scores.production_readiness,
"exploit_resistance" => scores.exploit_resistance,
_ => 100, }
};
t.min_scores
.iter()
.all(|(cat, min)| category_score(cat) >= *min)
}
fn determine_risk_level(score: u8, findings: &[crate::core::Finding]) -> RiskLevel {
let has_critical = findings
.iter()
.any(|f| f.severity == crate::core::Severity::Critical);
let has_high = findings
.iter()
.any(|f| f.severity == crate::core::Severity::High);
if has_critical || score < 30 {
RiskLevel::Critical
} else if has_high || score < 50 {
RiskLevel::High
} else if score < 70 {
RiskLevel::Medium
} else if score < 85 {
RiskLevel::Low
} else {
RiskLevel::Minimal
}
}
fn has_blocking_findings(findings: &[crate::core::Finding]) -> bool {
findings.iter().any(|f| f.blocks_deployment)
}
fn build_summary(
findings: &[crate::core::Finding],
sources: &[std::path::PathBuf],
) -> AuditSummary {
let mut summary = AuditSummary {
total_findings: findings.len(),
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
info_count: 0,
files_analyzed: sources.len(),
lines_analyzed: 0,
contracts_analyzed: sources.len(),
};
for f in findings {
match f.severity {
crate::core::Severity::Critical => summary.critical_count += 1,
crate::core::Severity::High => summary.high_count += 1,
crate::core::Severity::Medium => summary.medium_count += 1,
crate::core::Severity::Low => summary.low_count += 1,
crate::core::Severity::Informational => summary.info_count += 1,
}
}
for src in sources {
if let Ok(content) = std::fs::read_to_string(src) {
summary.lines_analyzed += content.lines().count();
}
}
summary
}
fn print_terminal_report(result: &AuditResult, _args: &AuditArgs) {
use colored::*;
println!(
"\n{}",
"═══════════════════════════════════════════════".bright_blue()
);
println!(
"{}",
" FORGE AUDIT — SECURITY REPORT "
.bright_blue()
.bold()
);
println!(
"{}",
"═══════════════════════════════════════════════".bright_blue()
);
println!("\n📋 Project: {}", result.project_name);
println!("⛓️ Chain: {}", result.chain);
println!("🕐 Duration: {:.2}s", result.duration_seconds);
println!("📁 Files: {}", result.summary.files_analyzed);
println!("\n{}", "── Findings ──".bold());
println!(
" 🛑 Critical: {}",
result.summary.critical_count.to_string().red().bold()
);
println!(
" 🔴 High: {}",
result.summary.high_count.to_string().red()
);
println!(
" 🟡 Medium: {}",
result.summary.medium_count.to_string().yellow()
);
println!(
" 🔵 Low: {}",
result.summary.low_count.to_string().blue()
);
println!(
" ⚪ Info: {}",
result.summary.info_count.to_string().dimmed()
);
println!("\n{}", "── Scores ──".bold());
println!(
" 🔐 Access Control: {:>3}/100",
result.scores.access_control
);
println!(
" 🛡️ Security: {:>3}/100",
result.scores.security
);
println!(" 🎯 Fuzzing: {:>3}/100", result.scores.fuzzing);
println!(" ⛽ Gas: {:>3}/100", result.scores.gas);
println!(
" 🏗️ Architecture: {:>3}/100",
result.scores.architecture
);
println!(
" ⬆️ Upgradeability: {:>3}/100",
result.scores.upgradeability
);
println!(
" 📦 Dependencies: {:>3}/100",
result.scores.dependencies
);
println!(
" 🚀 Deployment: {:>3}/100",
result.scores.deployment
);
println!(
" 🔗 Proxy Safety: {:>3}/100",
result.scores.proxy_safety
);
println!(
" ⛓️ Chain Compat: {:>3}/100",
result.scores.chain_compatibility
);
println!(
" ✅ Production Ready: {:>3}/100",
result.scores.production_readiness
);
println!(
" 💥 Exploit Resistance: {:>3}/100",
result.scores.exploit_resistance
);
let overall_color = if result.overall_score >= 85 {
"green"
} else if result.overall_score >= 70 {
"yellow"
} else {
"red"
};
println!(
"\n{} {}",
"Overall Score:".bold(),
result.overall_score.to_string().color(overall_color).bold()
);
println!(
"{} {}",
"Risk Level:".bold(),
format!("{}", result.risk_level)
.color(match result.risk_level {
RiskLevel::Critical => "red",
RiskLevel::High => "red",
RiskLevel::Medium => "yellow",
RiskLevel::Low => "green",
RiskLevel::Minimal => "green",
})
.bold()
);
println!(
"{} {}",
"Production Ready:".bold(),
if result.production_ready {
"✅ YES".green().bold()
} else {
"❌ NO".red().bold()
}
);
println!(
"{} {}",
"Deployment:".bold(),
if result.deployment_approved {
"✅ APPROVED".green().bold()
} else {
"❌ BLOCKED".red().bold()
}
);
let critical_high: Vec<_> = result
.findings
.iter()
.filter(|f| {
f.severity == crate::core::Severity::Critical
|| f.severity == crate::core::Severity::High
})
.collect();
if !critical_high.is_empty() {
println!("\n{}", "── Top Findings ──".bold().red());
for f in critical_high.iter().take(5) {
let marker = if f.suppressed {
" ⛔ [SUPPRESSED]"
} else {
""
};
println!(
"\n [{}] {}{}",
f.severity.to_string().red().bold(),
f.title.bold(),
marker
);
println!(
" 📄 {}:{}",
f.file.as_deref().unwrap_or("?"),
f.line.map_or("?".into(), |l| l.to_string())
);
println!(" 💡 {}", f.recommendation);
}
if critical_high.len() > 5 {
println!(
"\n ... and {} more critical/high findings",
critical_high.len() - 5
);
}
}
println!(
"\n{}",
"═══════════════════════════════════════════════".bright_blue()
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::*;
#[test]
fn test_calculate_overall_perfect() {
let scores = SecurityScores::perfect();
assert_eq!(calculate_overall(&scores, None), 100);
}
#[test]
fn test_calculate_overall_mixed() {
let scores = SecurityScores {
access_control: 85,
security: 55,
fuzzing: 100,
gas: 92,
architecture: 75,
upgradeability: 100,
dependencies: 100,
deployment: 80,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 65,
exploit_resistance: 70,
};
let overall = calculate_overall(&scores, None);
assert!(overall > 0 && overall <= 100);
assert_eq!(overall, 85);
}
#[test]
fn test_calculate_overall_low_scores() {
let scores = SecurityScores {
access_control: 30,
security: 20,
fuzzing: 50,
gas: 40,
architecture: 10,
upgradeability: 0,
dependencies: 60,
deployment: 25,
proxy_safety: 0,
chain_compatibility: 100,
production_readiness: 15,
exploit_resistance: 35,
};
let overall = calculate_overall(&scores, None);
assert!(overall < 50);
}
#[test]
fn test_determine_risk_level_critical_from_findings() {
let findings = vec![Finding::builder()
.id("T1")
.title("Critical")
.description("")
.severity(Severity::Critical)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build()];
let level = determine_risk_level(100, &findings);
assert_eq!(level, RiskLevel::Critical);
}
#[test]
fn test_determine_risk_level_high_from_findings() {
let findings = vec![Finding::builder()
.id("T2")
.title("High")
.description("")
.severity(Severity::High)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build()];
let level = determine_risk_level(100, &findings);
assert_eq!(level, RiskLevel::High);
}
#[test]
fn test_determine_risk_level_by_score() {
assert_eq!(determine_risk_level(20, &[]), RiskLevel::Critical);
assert_eq!(determine_risk_level(29, &[]), RiskLevel::Critical);
assert_eq!(determine_risk_level(30, &[]), RiskLevel::High);
assert_eq!(determine_risk_level(49, &[]), RiskLevel::High);
assert_eq!(determine_risk_level(50, &[]), RiskLevel::Medium);
assert_eq!(determine_risk_level(69, &[]), RiskLevel::Medium);
assert_eq!(determine_risk_level(70, &[]), RiskLevel::Low);
assert_eq!(determine_risk_level(84, &[]), RiskLevel::Low);
assert_eq!(determine_risk_level(85, &[]), RiskLevel::Minimal);
assert_eq!(determine_risk_level(100, &[]), RiskLevel::Minimal);
}
#[test]
fn test_has_blocking_findings_true() {
let f = Finding::builder()
.id("B")
.title("Blocks")
.description("")
.severity(Severity::High)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.blocks_deployment(true)
.build();
assert!(has_blocking_findings(&[f]));
}
#[test]
fn test_has_blocking_findings_false() {
let f = Finding::builder()
.id("NB")
.title("No Block")
.description("")
.severity(Severity::Low)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Best Practices")
.blocks_deployment(false)
.build();
assert!(!has_blocking_findings(&[f]));
}
#[test]
fn test_has_blocking_findings_empty() {
assert!(!has_blocking_findings(&[]));
}
#[test]
fn test_build_summary_empty() {
let summary = build_summary(&[], &[]);
assert_eq!(summary.total_findings, 0);
assert_eq!(summary.files_analyzed, 0);
assert_eq!(summary.lines_analyzed, 0);
}
#[test]
fn test_build_summary_with_findings() {
let findings = vec![
Finding::builder()
.id("F1")
.title("Critical")
.description("")
.severity(Severity::Critical)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("F2")
.title("High")
.description("")
.severity(Severity::High)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("F3")
.title("Medium")
.description("")
.severity(Severity::Medium)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Gas")
.build(),
Finding::builder()
.id("F4")
.title("Low")
.description("")
.severity(Severity::Low)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Best Practices")
.build(),
Finding::builder()
.id("F5")
.title("Info")
.description("")
.severity(Severity::Informational)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Style")
.build(),
];
let sources = vec![
std::path::PathBuf::from("a.sol"),
std::path::PathBuf::from("b.sol"),
];
let summary = build_summary(&findings, &sources);
assert_eq!(summary.total_findings, 5);
assert_eq!(summary.critical_count, 1);
assert_eq!(summary.high_count, 1);
assert_eq!(summary.medium_count, 1);
assert_eq!(summary.low_count, 1);
assert_eq!(summary.info_count, 1);
assert_eq!(summary.files_analyzed, 2);
assert_eq!(summary.contracts_analyzed, 2);
}
#[test]
fn test_calculate_overall_with_focus_areas() {
let scores = SecurityScores {
access_control: 100,
security: 100,
fuzzing: 100,
gas: 100,
architecture: 100,
upgradeability: 100,
dependencies: 100,
deployment: 100,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 100,
exploit_resistance: 50,
};
assert_eq!(calculate_overall(&scores, None), 95);
let t = crate::templates::AuditTemplate {
name: "test".into(),
focus_areas: vec!["exploit_resistance".into()],
..Default::default()
};
assert_eq!(calculate_overall(&scores, Some(&t)), 92);
}
#[test]
fn test_template_min_scores_met() {
let scores = SecurityScores {
access_control: 90,
security: 60,
..SecurityScores::perfect()
};
assert!(template_min_scores_met(&scores, None));
let mut t = crate::templates::AuditTemplate::new("t", "");
t.min_scores.insert("access_control".into(), 85);
assert!(template_min_scores_met(&scores, Some(&t)));
let mut t2 = crate::templates::AuditTemplate::new("t2", "");
t2.min_scores.insert("access_control".into(), 95);
assert!(!template_min_scores_met(&scores, Some(&t2)));
}
#[test]
fn test_template_min_scores_unknown_category_passes() {
let scores = SecurityScores::perfect();
let mut t = crate::templates::AuditTemplate::new("t", "");
t.min_scores.insert("not_a_real_category".into(), 100);
assert!(template_min_scores_met(&scores, Some(&t)));
}
#[test]
fn test_determine_risk_level_critical_override_is_stronger() {
let f = Finding::builder()
.id("C")
.title("Critical")
.description("")
.severity(Severity::Critical)
.file("x.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build();
assert_eq!(determine_risk_level(95, &[f]), RiskLevel::Critical);
}
}
fn run_ai_analysis(
files: &[std::path::PathBuf],
args: &AuditArgs,
config: &ProjectConfig,
) -> Result<Vec<crate::core::Finding>> {
use std::sync::Arc;
eprintln!(
"🤖 Running AI analysis (provider: {}, model: {})...",
args.ai_provider, args.ai_model
);
let boxed: Box<dyn LlmProvider> = create_provider(
&args.ai_provider,
&args.ai_model,
config.ai.temperature,
config.ai.max_tokens,
args.ai_api_key.clone(),
args.ollama_endpoint.clone(),
)?;
let provider: Arc<dyn LlmProvider> = Arc::from(boxed);
let mut engine = ConsensusEngine::new(ConsensusConfig {
min_confidence: config.ai.min_confidence,
..Default::default()
});
engine.register(Box::new(SecurityAuditor::new(Arc::clone(&provider))));
if args.ai_full || args.full {
engine.register(Box::new(GasAuditor::new(Arc::clone(&provider))));
engine.register(Box::new(LogicAuditor::new(Arc::clone(&provider))));
}
let mut all_ai_findings = Vec::new();
for file_path in files {
let source_code = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
eprintln!(" ⚠️ Could not read {}: {e}", file_path.display());
continue;
}
};
let file_name = file_path.to_string_lossy();
let ctx = AuditContext::new(&source_code, &file_name, &config.chain);
let report = engine.analyze(&ctx);
if report.auditor_count > 0 {
eprintln!(
" 🤖 {}: {} findings ({} consensus, {} filtered)",
file_name,
report.findings.len(),
report.deduplicated_count,
report.filtered_count
);
}
for cf in &report.findings {
all_ai_findings.push(consensus_to_core_finding(cf, &file_name));
}
}
eprintln!(
"🤖 AI analysis complete — {} findings reported",
all_ai_findings.len()
);
Ok(all_ai_findings)
}