pub mod diff;
mod report_context;
pub mod stages;
pub mod state;
use anyhow::Context;
use crate::config::{load_project_config, ProjectConfig};
use crate::graph::frozen::CodeGraph;
use crate::graph::GraphQuery;
use crate::models::{Finding, Grade};
use crate::scoring::ScoreBreakdown;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct AnalysisConfig {
pub workers: usize,
pub skip_detectors: Vec<String>,
pub max_files: usize,
pub no_git: bool,
pub verify: bool,
pub all_detectors: bool,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
workers: 8,
skip_detectors: Vec::new(),
max_files: 0,
no_git: false,
verify: false,
all_detectors: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub enum AnalysisMode {
#[default]
Cold,
Incremental { files_changed: usize },
Cached,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreResult {
pub overall: f64,
pub grade: Grade,
pub breakdown: ScoreBreakdown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnalysisStats {
pub mode: AnalysisMode,
pub files_analyzed: usize,
pub total_functions: usize,
pub total_classes: usize,
pub total_loc: usize,
pub detectors_run: usize,
pub findings_before_postprocess: usize,
pub findings_filtered: usize,
pub timings: BTreeMap<String, Duration>,
}
#[derive(Debug, Clone)]
pub struct AnalysisResult {
pub findings: Vec<Finding>,
pub score: ScoreResult,
pub stats: AnalysisStats,
}
#[derive(Debug, Clone)]
pub enum ProgressEvent {
StageStarted {
name: Cow<'static, str>,
total: Option<usize>,
},
StageProgress {
current: usize,
},
StageCompleted {
name: Cow<'static, str>,
duration: Duration,
},
}
pub type ProgressFn = Arc<dyn Fn(ProgressEvent) + Send + Sync>;
pub use crate::cli::analyze::OutputOptions;
pub struct AnalysisEngine {
repo_path: PathBuf,
project_config: ProjectConfig,
state: Option<state::EngineState>,
progress: Option<ProgressFn>,
}
impl AnalysisEngine {
pub fn new(repo_path: &Path) -> anyhow::Result<Self> {
let repo_path = repo_path.canonicalize()?;
let project_config = load_project_config(&repo_path);
Ok(Self {
repo_path,
project_config,
state: None,
progress: None,
})
}
pub fn with_progress(mut self, progress: ProgressFn) -> Self {
self.progress = Some(progress);
self
}
pub fn graph(&self) -> Option<&dyn GraphQuery> {
self.state
.as_ref()
.map(|s| s.graph.as_ref() as &dyn GraphQuery)
}
pub fn co_change(&self) -> Option<&crate::git::co_change::CoChangeMatrix> {
self.state.as_ref().and_then(|s| s.co_change.as_deref())
}
pub fn style_profile(&self) -> Option<&crate::calibrate::StyleProfile> {
self.state.as_ref().map(|s| &s.style_profile)
}
pub fn code_graph(&self) -> Option<&CodeGraph> {
self.state.as_ref().map(|s| s.graph.as_ref())
}
pub fn graph_arc(&self) -> Option<Arc<CodeGraph>> {
self.state.as_ref().map(|s| Arc::clone(&s.graph))
}
pub fn graph_builder(&self) -> Option<&crate::graph::builder::GraphBuilder> {
self.state
.as_ref()
.and_then(|s| s.mutable_graph.as_ref())
}
pub fn project_config(&self) -> &ProjectConfig {
&self.project_config
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
pub fn analyze(&mut self, config: &AnalysisConfig) -> anyhow::Result<AnalysisResult> {
use stages::*;
let mut timings = BTreeMap::new();
let exclude_patterns = self.project_config.exclude.effective_patterns();
let collect_out = timed(&mut timings, "collect", || {
collect::collect_stage(&collect::CollectInput {
repo_path: &self.repo_path,
exclude_patterns: &exclude_patterns,
max_files: config.max_files,
})
})?;
let changes = match &self.state {
Some(state) => diff::FileChanges::compute(&state.file_hashes, &collect_out),
None => diff::FileChanges::cold(&collect_out),
};
if changes.nothing_changed() {
if let Some(ref state) = self.state {
return Ok(AnalysisResult {
findings: state.last_findings.clone(),
score: state.last_score.clone(),
stats: AnalysisStats {
mode: AnalysisMode::Cached,
..state.last_stats.clone()
},
});
}
}
let all_files = collect_out.all_paths();
let _ = crate::cache::ensure_cache_dir(&self.repo_path);
let is_incremental = self.state.is_some() && changes.is_delta();
if is_incremental {
self.analyze_incremental(config, &collect_out, &changes, all_files, timings)
} else {
self.analyze_cold(config, &collect_out, all_files, timings)
}
}
fn analyze_cold(
&mut self,
config: &AnalysisConfig,
collect_out: &stages::collect::CollectOutput,
all_files: Vec<PathBuf>,
mut timings: BTreeMap<String, Duration>,
) -> anyhow::Result<AnalysisResult> {
use stages::*;
let parse_out = timed(&mut timings, "parse", || {
parse::parse_stage(&parse::ParseInput {
files: all_files.clone(),
workers: config.workers,
progress: self.progress.clone(),
})
})?;
let mut graph_out = timed(&mut timings, "graph", || {
graph::graph_stage(&graph::GraphInput {
parse_results: &parse_out.results,
repo_path: &self.repo_path,
})
})?;
let git_out = if !config.no_git {
timed(&mut timings, "git_enrich", || {
git_enrich::git_enrich_stage(&mut git_enrich::GitEnrichInput {
repo_path: &self.repo_path,
graph: &mut graph_out.mutable_graph,
co_change_config: self.project_config.co_change.to_runtime(),
})
})?
} else {
git_enrich::GitEnrichOutput::skipped()
};
let file_churn = Arc::new(git_out.file_churn);
let co_change_arc: Option<Arc<crate::git::co_change::CoChangeMatrix>> =
Some(Arc::new(git_out.co_change_matrix));
let frozen = timed(&mut timings, "freeze", || {
graph::freeze_graph(
graph_out.mutable_graph,
graph_out.value_store,
co_change_arc.as_ref().map(|a| a.as_ref()),
)
});
let calibrate_out = timed(&mut timings, "calibrate", || {
calibrate::calibrate_stage(&calibrate::CalibrateInput {
parse_results: &parse_out.results,
file_count: collect_out.files.len(),
repo_path: &self.repo_path,
})
})?;
let detect_out = timed(&mut timings, "detect", || {
detect::detect_stage(&detect::DetectInput {
graph: frozen.graph.as_ref(),
source_files: &all_files,
repo_path: &self.repo_path,
project_config: &self.project_config,
style_profile: Some(&calibrate_out.style_profile),
ngram_model: calibrate_out.ngram_model.as_ref(),
value_store: frozen.value_store.as_ref(),
skip_detectors: &config.skip_detectors,
workers: config.workers,
progress: self.progress.clone(),
file_churn: Arc::clone(&file_churn),
co_change_matrix: co_change_arc.as_ref().map(Arc::clone),
all_detectors: config.all_detectors,
changed_files: None,
topology_changed: true,
cached_gd_precomputed: None,
cached_file_findings: None,
cached_graph_wide_findings: None,
})
})?;
let postprocess_out = timed(&mut timings, "postprocess", || {
postprocess::postprocess_stage(postprocess::PostprocessInput {
findings: detect_out.findings,
project_config: &self.project_config,
graph: frozen.graph.as_ref(),
all_files: &all_files,
repo_path: &self.repo_path,
verify: config.verify,
bypass_set: detect_out.bypass_set,
})
})?;
let mut final_findings = postprocess_out.findings;
final_findings.extend(detect_out.cached_findings);
let score_out = timed(&mut timings, "score", || {
score::score_stage(&score::ScoreInput {
graph: frozen.graph.as_ref(),
findings: &final_findings,
project_config: &self.project_config,
repo_path: &self.repo_path,
total_loc: parse_out.stats.total_loc,
})
})?;
let stats = AnalysisStats {
mode: AnalysisMode::Cold,
files_analyzed: collect_out.files.len(),
total_functions: parse_out.stats.total_functions,
total_classes: parse_out.stats.total_classes,
total_loc: parse_out.stats.total_loc,
detectors_run: detect_out.stats.detectors_run,
findings_before_postprocess: postprocess_out.stats.input_count,
findings_filtered: postprocess_out
.stats
.input_count
.saturating_sub(postprocess_out.stats.output_count),
timings,
};
self.state = Some(state::EngineState {
file_hashes: collect_out
.files
.iter()
.map(|f| (f.path.clone(), f.content_hash))
.collect(),
source_files: all_files,
graph: frozen.graph,
mutable_graph: None, edge_fingerprint: frozen.edge_fingerprint,
co_change: co_change_arc,
precomputed: Some(detect_out.precomputed),
style_profile: calibrate_out.style_profile,
ngram_model: calibrate_out.ngram_model,
findings_by_file: detect_out.findings_by_file,
graph_wide_findings: detect_out.graph_wide_findings,
last_findings: final_findings.clone(),
last_score: score_out.clone(),
last_stats: stats.clone(),
});
Ok(AnalysisResult {
findings: final_findings,
score: score_out,
stats,
})
}
fn analyze_incremental(
&mut self,
config: &AnalysisConfig,
collect_out: &stages::collect::CollectOutput,
changes: &diff::FileChanges,
all_files: Vec<PathBuf>,
mut timings: BTreeMap<String, Duration>,
) -> anyhow::Result<AnalysisResult> {
use stages::*;
let files_changed = changes.changed.len() + changes.added.len() + changes.removed.len();
let delta_files = changes.changed_and_added();
let prev_state = self.state.take().expect("incremental requires state");
let mut prev_co_change = prev_state.co_change;
crate::cache::global_cache().evict(&delta_files);
let parse_out = timed(&mut timings, "parse", || {
parse::parse_stage(&parse::ParseInput {
files: delta_files.clone(),
workers: config.workers,
progress: self.progress.clone(),
})
})?;
let can_patch = Arc::strong_count(&prev_state.graph) == 1;
let (frozen, file_churn, co_change) = if can_patch {
let code_graph = match Arc::try_unwrap(prev_state.graph) {
Ok(g) => g,
Err(_) => unreachable!("strong_count was 1"),
};
let mutable_graph = crate::graph::builder::GraphBuilder::from_frozen(code_graph);
let mut graph_out = timed(&mut timings, "graph", || {
graph::graph_patch_stage(graph::GraphPatchInput {
mutable_graph,
changed_files: changes.changed.clone(),
removed_files: changes.removed.clone(),
new_parse_results: parse_out.results.clone(),
repo_path: self.repo_path.clone(),
})
})?;
let git_out = if !config.no_git {
timed(&mut timings, "git_enrich", || {
git_enrich::git_enrich_stage(&mut git_enrich::GitEnrichInput {
repo_path: &self.repo_path,
graph: &mut graph_out.mutable_graph,
co_change_config: self.project_config.co_change.to_runtime(),
})
})?
} else {
git_enrich::GitEnrichOutput::skipped()
};
let file_churn = Arc::new(git_out.file_churn);
let co_change: Option<Arc<crate::git::co_change::CoChangeMatrix>> = if config.no_git {
prev_co_change.take()
} else {
Some(Arc::new(git_out.co_change_matrix))
};
let frozen = timed(&mut timings, "freeze", || {
graph::freeze_graph(
graph_out.mutable_graph,
graph_out.value_store,
co_change.as_ref().map(|a| a.as_ref()),
)
});
(frozen, file_churn, co_change)
} else {
let frozen = graph::FrozenGraphOutput {
graph: prev_state.graph,
value_store: None,
edge_fingerprint: prev_state.edge_fingerprint,
};
let file_churn = Arc::new(if !config.no_git {
timed(&mut timings, "git_enrich", || {
git_enrich::compute_file_churn(&self.repo_path)
})
} else {
std::collections::HashMap::new()
});
let co_change: Option<Arc<crate::git::co_change::CoChangeMatrix>> = prev_co_change.take();
(frozen, file_churn, co_change)
};
let style_profile = prev_state.style_profile;
let ngram_model = if prev_state.ngram_model.is_some() {
prev_state.ngram_model
} else {
None
};
let topology_changed = prev_state.edge_fingerprint != frozen.edge_fingerprint;
let cached_gd = if !topology_changed {
prev_state.precomputed.as_ref()
} else {
None
};
let detect_out = timed(&mut timings, "detect", || {
detect::detect_stage(&detect::DetectInput {
graph: frozen.graph.as_ref(),
source_files: &all_files,
repo_path: &self.repo_path,
project_config: &self.project_config,
style_profile: Some(&style_profile),
ngram_model: ngram_model.as_ref(),
value_store: frozen.value_store.as_ref(),
skip_detectors: &config.skip_detectors,
workers: config.workers,
progress: self.progress.clone(),
file_churn: Arc::clone(&file_churn),
co_change_matrix: co_change.as_ref().map(Arc::clone),
all_detectors: config.all_detectors,
changed_files: Some(&delta_files),
topology_changed,
cached_gd_precomputed: cached_gd,
cached_file_findings: Some(&prev_state.findings_by_file),
cached_graph_wide_findings: Some(&prev_state.graph_wide_findings),
})
})?;
let postprocess_out = timed(&mut timings, "postprocess", || {
postprocess::postprocess_stage(postprocess::PostprocessInput {
findings: detect_out.findings, project_config: &self.project_config,
graph: frozen.graph.as_ref(),
all_files: &all_files,
repo_path: &self.repo_path,
verify: config.verify,
bypass_set: detect_out.bypass_set,
})
})?;
let mut final_findings = postprocess_out.findings;
final_findings.extend(detect_out.cached_findings);
let total_functions = prev_state.last_stats.total_functions
.saturating_sub(parse_out.stats.total_functions)
.saturating_add(parse_out.stats.total_functions);
let total_classes = prev_state.last_stats.total_classes
.saturating_sub(parse_out.stats.total_classes)
.saturating_add(parse_out.stats.total_classes);
let total_loc = prev_state.last_stats.total_loc;
let score_out = timed(&mut timings, "score", || {
score::score_stage(&score::ScoreInput {
graph: frozen.graph.as_ref(),
findings: &final_findings,
project_config: &self.project_config,
repo_path: &self.repo_path,
total_loc,
})
})?;
let stats = AnalysisStats {
mode: AnalysisMode::Incremental { files_changed },
files_analyzed: collect_out.files.len(),
total_functions,
total_classes,
total_loc,
detectors_run: detect_out.stats.detectors_run,
findings_before_postprocess: postprocess_out.stats.input_count,
findings_filtered: postprocess_out
.stats
.input_count
.saturating_sub(postprocess_out.stats.output_count),
timings,
};
self.state = Some(state::EngineState {
file_hashes: collect_out
.files
.iter()
.map(|f| (f.path.clone(), f.content_hash))
.collect(),
source_files: all_files,
graph: frozen.graph,
mutable_graph: None, edge_fingerprint: frozen.edge_fingerprint,
co_change,
precomputed: Some(detect_out.precomputed),
style_profile,
ngram_model,
findings_by_file: detect_out.findings_by_file,
graph_wide_findings: detect_out.graph_wide_findings,
last_findings: final_findings.clone(),
last_score: score_out.clone(),
last_stats: stats.clone(),
});
Ok(AnalysisResult {
findings: final_findings,
score: score_out,
stats,
})
}
pub fn save(&self, session_path: &Path) -> anyhow::Result<()> {
let state = match &self.state {
Some(s) => s,
None => return Ok(()), };
std::fs::create_dir_all(session_path)
.with_context(|| format!("Failed to create session directory: {}", session_path.display()))?;
let meta = state::SessionMeta {
version: state::SESSION_VERSION,
binary_version: env!("CARGO_PKG_VERSION").to_string(),
file_hashes: state.file_hashes.clone(),
source_files: state.source_files.clone(),
edge_fingerprint: state.edge_fingerprint,
findings_by_file: state.findings_by_file.clone(),
graph_wide_findings: state.graph_wide_findings.clone(),
last_findings: state.last_findings.clone(),
last_score: state.last_score.clone(),
last_stats: state.last_stats.clone(),
};
let json = serde_json::to_string(&meta)
.context("Failed to serialize engine session")?;
let meta_path = session_path.join("engine_session.json");
std::fs::write(&meta_path, json)
.with_context(|| format!("Failed to write {}", meta_path.display()))?;
let graph_path = session_path.join("graph.bin");
state.graph.save_cache(&graph_path)
.context("Failed to save graph cache")?;
Ok(())
}
pub fn load(session_path: &Path, repo_path: &Path) -> anyhow::Result<Self> {
let repo_path = repo_path.canonicalize()?;
let project_config = load_project_config(&repo_path);
let meta_path = session_path.join("engine_session.json");
let json = std::fs::read_to_string(&meta_path)
.with_context(|| format!("Failed to read {}", meta_path.display()))?;
let meta: state::SessionMeta = serde_json::from_str(&json)
.context("Failed to deserialize engine session")?;
if meta.version != state::SESSION_VERSION {
anyhow::bail!(
"Session version mismatch: expected {}, found {}",
state::SESSION_VERSION,
meta.version
);
}
if meta.binary_version != env!("CARGO_PKG_VERSION") {
anyhow::bail!(
"Binary version mismatch: session was saved with {}, current is {}",
meta.binary_version,
env!("CARGO_PKG_VERSION")
);
}
let graph_path = session_path.join("graph.bin");
let graph = CodeGraph::load_cache(&graph_path)
.ok_or_else(|| anyhow::anyhow!(
"Failed to load graph cache from {}",
graph_path.display()
))?;
let state = state::EngineState {
file_hashes: meta.file_hashes,
source_files: meta.source_files,
graph: Arc::new(graph),
mutable_graph: None, edge_fingerprint: meta.edge_fingerprint,
co_change: None, precomputed: None,
style_profile: crate::calibrate::StyleProfile {
version: crate::calibrate::StyleProfile::VERSION,
generated_at: String::new(),
commit_sha: None,
total_files: 0,
total_functions: 0,
metrics: std::collections::HashMap::new(),
},
ngram_model: None,
findings_by_file: meta.findings_by_file,
graph_wide_findings: meta.graph_wide_findings,
last_findings: meta.last_findings,
last_score: meta.last_score,
last_stats: meta.last_stats,
};
Ok(Self {
repo_path,
project_config,
state: Some(state),
progress: None,
})
}
}
fn timed<T>(timings: &mut BTreeMap<String, Duration>, name: &str, f: impl FnOnce() -> T) -> T {
let start = std::time::Instant::now();
let result = f();
timings.insert(name.to_string(), start.elapsed());
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_engine_cold_analysis() {
let tmp = tempfile::tempdir().unwrap();
let py_file = tmp.path().join("main.py");
std::fs::write(
&py_file,
r#"
def hello():
print("hello world")
def add(a, b):
return a + b
"#,
)
.unwrap();
let mut engine = AnalysisEngine::new(tmp.path()).unwrap();
let config = AnalysisConfig {
workers: 2,
no_git: true, ..Default::default()
};
let result = engine.analyze(&config).unwrap();
assert!(matches!(result.stats.mode, AnalysisMode::Cold));
assert!(result.score.overall >= 0.0);
assert!(result.score.overall <= 100.0);
assert!(result.stats.files_analyzed >= 1);
}
#[test]
fn test_engine_second_call_cached() {
let tmp = tempfile::tempdir().unwrap();
let py_file = tmp.path().join("example.py");
std::fs::write(
&py_file,
r#"
def greet(name):
return f"Hello, {name}!"
"#,
)
.unwrap();
let mut engine = AnalysisEngine::new(tmp.path()).unwrap();
let config = AnalysisConfig {
workers: 2,
no_git: true,
..Default::default()
};
let r1 = engine.analyze(&config).unwrap();
assert!(matches!(r1.stats.mode, AnalysisMode::Cold));
let r2 = engine.analyze(&config).unwrap();
assert!(matches!(r2.stats.mode, AnalysisMode::Cached));
assert_eq!(r1.findings.len(), r2.findings.len());
assert_eq!(r1.score.overall, r2.score.overall);
assert_eq!(r1.score.grade, r2.score.grade);
}
#[test]
fn test_save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.py"), "def hello(): pass").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
max_files: 5,
workers: 2,
..Default::default()
};
let r1 = engine.analyze(&config).unwrap();
let session_dir = tempfile::tempdir().unwrap();
engine.save(session_dir.path()).unwrap();
drop(engine);
let engine2 = AnalysisEngine::load(session_dir.path(), dir.path()).unwrap();
assert!(engine2.graph().is_some());
let state = engine2.state.as_ref().unwrap();
assert_eq!(state.last_findings.len(), r1.findings.len());
assert_eq!(state.last_score.overall, r1.score.overall);
assert_eq!(state.last_score.grade, r1.score.grade);
}
#[test]
fn test_save_noop_without_state() {
let dir = tempfile::tempdir().unwrap();
let engine = AnalysisEngine::new(dir.path()).unwrap();
let session_dir = tempfile::tempdir().unwrap();
engine.save(session_dir.path()).unwrap();
assert!(!session_dir.path().join("engine_session.json").exists());
}
#[test]
fn test_load_cached_fast_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.py"), "def run(): return 42").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
let r1 = engine.analyze(&config).unwrap();
let session_dir = tempfile::tempdir().unwrap();
engine.save(session_dir.path()).unwrap();
drop(engine);
let mut engine2 = AnalysisEngine::load(session_dir.path(), dir.path()).unwrap();
let r2 = engine2.analyze(&config).unwrap();
assert!(matches!(r2.stats.mode, AnalysisMode::Cached));
assert_eq!(r1.findings.len(), r2.findings.len());
assert_eq!(r1.score.overall, r2.score.overall);
}
#[test]
fn test_incremental_after_file_modify() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
let r1 = engine.analyze(&config).unwrap();
assert!(matches!(r1.stats.mode, AnalysisMode::Cold));
std::fs::write(dir.path().join("main.py"), "def foo():\n return 42\n").unwrap();
let r2 = engine.analyze(&config).unwrap();
assert!(
matches!(r2.stats.mode, AnalysisMode::Incremental { .. }),
"Expected Incremental mode after file modify, got {:?}",
r2.stats.mode
);
}
#[test]
fn test_incremental_after_file_add() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
engine.analyze(&config).unwrap();
std::fs::write(dir.path().join("helper.py"), "def bar(): return 1").unwrap();
let r2 = engine.analyze(&config).unwrap();
assert!(
matches!(r2.stats.mode, AnalysisMode::Incremental { .. }),
"Expected Incremental mode after file add, got {:?}",
r2.stats.mode
);
}
#[test]
fn test_incremental_after_file_remove() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
std::fs::write(dir.path().join("helper.py"), "def bar(): return 1").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
engine.analyze(&config).unwrap();
std::fs::remove_file(dir.path().join("helper.py")).unwrap();
let r2 = engine.analyze(&config).unwrap();
assert!(
matches!(r2.stats.mode, AnalysisMode::Incremental { .. }),
"Expected Incremental mode after file remove, got {:?}",
r2.stats.mode
);
}
#[test]
fn test_incremental_then_cached() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
engine.analyze(&config).unwrap(); std::fs::write(dir.path().join("main.py"), "def foo():\n return 42\n").unwrap();
engine.analyze(&config).unwrap();
let r3 = engine.analyze(&config).unwrap();
assert!(
matches!(r3.stats.mode, AnalysisMode::Cached),
"Expected Cached mode on third call with no changes, got {:?}",
r3.stats.mode
);
}
#[test]
fn test_incremental_produces_valid_score() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let config = AnalysisConfig {
no_git: true,
workers: 2,
..Default::default()
};
engine.analyze(&config).unwrap();
std::fs::write(
dir.path().join("main.py"),
"def foo():\n return 42\n\ndef bar():\n return 0\n",
)
.unwrap();
let r2 = engine.analyze(&config).unwrap();
assert!(r2.score.overall >= 0.0 && r2.score.overall <= 100.0);
assert!(r2.score.overall >= 0.0);
}
#[test]
fn test_co_change_retained_after_analyze() {
let dir = tempfile::tempdir().unwrap();
let test_file = dir.path().join("main.py");
std::fs::write(&test_file, "def foo(): pass\n").unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"init",
])
.current_dir(dir.path())
.output()
.unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let _result = engine.analyze(&AnalysisConfig::default()).unwrap();
assert!(
engine.co_change().is_some(),
"CoChangeMatrix should be retained after analyze"
);
}
#[test]
fn test_build_report_context_returns_context() {
let dir = tempfile::tempdir().unwrap();
let test_file = dir.path().join("main.py");
std::fs::write(&test_file, "def foo(): pass\ndef bar(): pass\n").unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"init",
])
.current_dir(dir.path())
.output()
.unwrap();
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let result = engine.analyze(&AnalysisConfig::default()).unwrap();
let health = crate::models::HealthReport {
overall_score: result.score.overall,
grade: result.score.grade,
structure_score: result.score.breakdown.structure.final_score,
quality_score: result.score.breakdown.quality.final_score,
architecture_score: Some(result.score.breakdown.architecture.final_score),
findings: result.findings.clone(),
findings_summary: crate::models::FindingsSummary::from_findings(&result.findings),
total_files: result.stats.files_analyzed,
total_functions: result.stats.total_functions,
total_classes: result.stats.total_classes,
total_loc: result.stats.total_loc,
};
let ctx = engine
.build_report_context(health, crate::reporters::OutputFormat::Html)
.unwrap();
assert!(ctx.previous_health.is_none());
}
#[test]
fn test_full_incremental_pipeline_correctness() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("main.py"),
"import helper\n\ndef main():\n helper.greet()\n",
)
.unwrap();
std::fs::write(
dir.path().join("helper.py"),
"def greet():\n print('hello')\n",
)
.unwrap();
let config = AnalysisConfig {
no_git: true,
..Default::default()
};
let mut engine = AnalysisEngine::new(dir.path()).unwrap();
let cold_result = engine.analyze(&config).unwrap();
assert!(matches!(cold_result.stats.mode, AnalysisMode::Cold));
let cold_files = cold_result.stats.files_analyzed;
assert!(cold_files >= 2);
let cold_score = cold_result.score.overall;
let cold_findings_count = cold_result.findings.len();
let session_dir = tempfile::tempdir().unwrap();
engine.save(session_dir.path()).unwrap();
let mut engine2 = AnalysisEngine::load(session_dir.path(), dir.path()).unwrap();
let cached_result = engine2.analyze(&config).unwrap();
assert!(matches!(cached_result.stats.mode, AnalysisMode::Cached));
assert_eq!(cached_result.score.overall, cold_score);
assert_eq!(cached_result.findings.len(), cold_findings_count);
std::fs::write(
dir.path().join("helper.py"),
"def greet():\n print('hello world')\n\ndef farewell():\n print('bye')\n",
)
.unwrap();
let mut engine3 = AnalysisEngine::load(session_dir.path(), dir.path()).unwrap();
let incr_result = engine3.analyze(&config).unwrap();
assert!(matches!(
incr_result.stats.mode,
AnalysisMode::Incremental { .. }
));
assert!(incr_result.score.overall > 0.0);
assert_eq!(incr_result.stats.files_analyzed, cold_files);
}
}