use crate::report_types::LLMVerdict;
use crate::types::{FileRecord, MethodRecord, Reference};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use super::{Analyzer, ReviewProgress, ReviewProgressCallback};
pub(super) enum ReviewJob {
Method {
index: usize,
method: MethodRecord,
static_signals: Vec<String>,
file_context: String,
callee_context: Vec<Reference>,
},
File {
index: usize,
file: FileRecord,
static_signals: Vec<String>,
},
}
struct ReviewOutcome {
index: usize,
verdict: Option<LLMVerdict>,
in_tok: usize,
out_tok: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CheckpointEntry {
key: String,
verdict: Option<LLMVerdict>,
in_tok: usize,
out_tok: usize,
}
#[derive(Debug, Serialize, Deserialize)]
struct CheckpointFile {
version: u32,
fingerprint: u64,
completed: Vec<CheckpointEntry>,
}
struct CheckpointStore {
path: PathBuf,
fingerprint: u64,
completed: HashMap<String, CheckpointEntry>,
}
impl CheckpointStore {
fn load(path: &Path, fingerprint: u64) -> Result<Self, String> {
let completed = match std::fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<CheckpointFile>(&contents) {
Ok(file) if file.version == 1 && file.fingerprint == fingerprint => file
.completed
.into_iter()
.map(|entry| (entry.key.clone(), entry))
.collect(),
Ok(_) => HashMap::new(),
Err(err) => {
eprintln!(
"Ignoring unreadable Sniff checkpoint {}: {}",
path.display(),
err
);
HashMap::new()
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(err) => {
return Err(format!(
"failed to read Sniff checkpoint {}: {err}",
path.display()
));
}
};
Ok(Self {
path: path.to_path_buf(),
fingerprint,
completed,
})
}
fn record(&mut self, key: String, outcome: &ReviewOutcome) -> Result<(), String> {
self.completed.insert(
key.clone(),
CheckpointEntry {
key,
verdict: outcome.verdict.clone(),
in_tok: outcome.in_tok,
out_tok: outcome.out_tok,
},
);
self.persist()
}
fn persist(&self) -> Result<(), String> {
let mut completed: Vec<_> = self.completed.values().cloned().collect();
completed.sort_by(|left, right| left.key.cmp(&right.key));
let contents = serde_json::to_string_pretty(&CheckpointFile {
version: 1,
fingerprint: self.fingerprint,
completed,
})
.map_err(|err| format!("failed to serialize Sniff checkpoint: {err}"))?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create Sniff checkpoint directory {}: {err}",
parent.display()
)
})?;
}
let temporary = self.path.with_extension("json.tmp");
std::fs::write(&temporary, contents).map_err(|err| {
format!(
"failed to write Sniff checkpoint {}: {err}",
temporary.display()
)
})?;
if self.path.exists() {
std::fs::remove_file(&self.path).map_err(|err| {
format!(
"failed to replace Sniff checkpoint {}: {err}",
self.path.display()
)
})?;
}
std::fs::rename(&temporary, &self.path).map_err(|err| {
format!(
"failed to finalize Sniff checkpoint {}: {err}",
self.path.display()
)
})
}
fn remove(self) -> Result<(), String> {
match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(format!(
"failed to remove completed Sniff checkpoint {}: {err}",
self.path.display()
)),
}
}
}
impl ReviewJob {
fn label(&self) -> String {
match self {
Self::Method { method, .. } => format!("method {}::{}", method.file_path, method.name),
Self::File { file, .. } => format!("file {}", file.file_path),
}
}
fn checkpoint_key(&self, index: usize) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
match self {
Self::Method {
method,
static_signals,
file_context,
callee_context,
..
} => {
"method".hash(&mut hasher);
method.file_path.hash(&mut hasher);
method.name.hash(&mut hasher);
method.source.hash(&mut hasher);
method.start_line.hash(&mut hasher);
method.end_line.hash(&mut hasher);
static_signals.hash(&mut hasher);
file_context.hash(&mut hasher);
for reference in callee_context {
reference.file_path.hash(&mut hasher);
reference.line.hash(&mut hasher);
reference.snippet.hash(&mut hasher);
}
crate::roles::file_role_label(crate::roles::classify_file_role(&method.file_path))
.hash(&mut hasher);
}
Self::File {
file,
static_signals,
..
} => {
"file".hash(&mut hasher);
file.file_path.hash(&mut hasher);
file.source.hash(&mut hasher);
static_signals.hash(&mut hasher);
crate::roles::file_role_label(crate::roles::classify_file_role(&file.file_path))
.hash(&mut hasher);
}
}
format!("{index}:{:016x}", hasher.finish())
}
}
fn jobs_fingerprint(jobs: &[ReviewJob], review_context_key: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
review_context_key.hash(&mut hasher);
for (index, job) in jobs.iter().enumerate() {
job.checkpoint_key(index).hash(&mut hasher);
}
hasher.finish()
}
async fn run_review_job(
analyzer: Arc<Analyzer>,
job: ReviewJob,
on_progress: Option<&ReviewProgressCallback>,
) -> Result<ReviewOutcome, String> {
match job {
ReviewJob::Method {
index,
method,
static_signals,
file_context,
callee_context,
} => match analyzer
.analyze_method_review_with_context(
&method,
&static_signals,
&file_context,
&callee_context,
on_progress,
)
.await
{
Ok((Some(verdict), in_tok, out_tok)) => Ok(ReviewOutcome {
index,
verdict: Some(verdict),
in_tok,
out_tok,
}),
Ok((None, _, _)) => Err(format!(
"LLM review failed: method {}::{} returned no verdict",
method.file_path, method.name
)),
Err(err) => Err(format!(
"LLM review failed: method {}::{}: {}",
method.file_path, method.name, err
)),
},
ReviewJob::File {
index,
file,
static_signals,
} => match analyzer
.analyze_file_with_progress(&file, &static_signals, on_progress)
.await
{
Ok((verdict, in_tok, out_tok)) => Ok(ReviewOutcome {
index,
verdict,
in_tok,
out_tok,
}),
Err(err) => Err(format!(
"LLM review failed: file {}: {}",
file.file_path, err
)),
},
}
}
pub(super) async fn run_review_jobs(
analyzer: Arc<Analyzer>,
jobs: Vec<ReviewJob>,
on_progress: Option<ReviewProgressCallback>,
review_context_key: &str,
checkpoint_path: Option<&Path>,
) -> Result<Vec<LLMVerdict>, String> {
let fingerprint = jobs_fingerprint(&jobs, review_context_key);
let mut checkpoint = checkpoint_path
.map(|path| CheckpointStore::load(path, fingerprint))
.transpose()?;
let mut outcomes = Vec::with_capacity(jobs.len());
for (index, job) in jobs.into_iter().enumerate() {
let checkpoint_key = job.checkpoint_key(index);
if let Some(entry) = checkpoint
.as_ref()
.and_then(|store| store.completed.get(&checkpoint_key))
{
analyzer.in_tok.fetch_add(entry.in_tok, Ordering::SeqCst);
analyzer.out_tok.fetch_add(entry.out_tok, Ordering::SeqCst);
outcomes.push(ReviewOutcome {
index,
verdict: entry.verdict.clone(),
in_tok: entry.in_tok,
out_tok: entry.out_tok,
});
if let Some(callback) = on_progress.as_ref() {
callback(ReviewProgress::Completed);
}
continue;
}
let analyzer = Arc::clone(&analyzer);
if let Some(callback) = on_progress.as_ref() {
callback(ReviewProgress::Started { label: job.label() });
}
let outcome = run_review_job(analyzer.clone(), job, on_progress.as_ref()).await?;
analyzer.in_tok.fetch_add(outcome.in_tok, Ordering::SeqCst);
analyzer
.out_tok
.fetch_add(outcome.out_tok, Ordering::SeqCst);
if let Some(store) = checkpoint.as_mut() {
store.record(checkpoint_key, &outcome)?;
}
if let Some(op) = on_progress.as_ref() {
op(ReviewProgress::Completed);
}
outcomes.push(outcome);
}
if let Some(store) = checkpoint {
store.remove()?;
}
outcomes.sort_by_key(|outcome| outcome.index);
Ok(outcomes
.into_iter()
.filter_map(|outcome| outcome.verdict)
.collect())
}
#[cfg(test)]
mod tests {
use super::{CheckpointStore, ReviewJob, ReviewOutcome};
use crate::report_types::LLMVerdict;
use crate::types::{FindingTier, MethodRecord};
use std::time::{SystemTime, UNIX_EPOCH};
fn method_job(source: &str) -> ReviewJob {
ReviewJob::Method {
index: 0,
method: MethodRecord {
name: "demo".to_string(),
file_path: "src/demo.py".to_string(),
source: source.to_string(),
loc: 1,
param_count: 0,
start_line: 1,
end_line: 1,
is_exported: false,
language: "python".to_string(),
nesting_depth: 0,
references: Vec::new(),
real_ref_count: 0,
},
static_signals: Vec::new(),
file_context: String::new(),
callee_context: Vec::new(),
}
}
fn temp_checkpoint_path() -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be available")
.as_nanos();
std::env::temp_dir().join(format!("sniff-checkpoint-{nonce}.json"))
}
#[test]
fn checkpoint_round_trip_preserves_completed_verdicts() {
let path = temp_checkpoint_path();
let job = method_job("def demo():\n return 1\n");
let key = job.checkpoint_key(0);
let verdict = LLMVerdict {
verdict_type: "method_review".to_string(),
file_path: "src/demo.py".to_string(),
method_name: Some("demo".to_string()),
check_type: "method".to_string(),
smelly: false,
tier: FindingTier::Clean,
cohesive: Some(true),
name_accurate: Some(true),
evidence: String::new(),
reason: "clean".to_string(),
loc: 2,
start_line: 1,
end_line: 2,
};
let outcome = ReviewOutcome {
index: 0,
verdict: Some(verdict),
in_tok: 12,
out_tok: 3,
};
let mut store = CheckpointStore::load(&path, 77).unwrap();
store.record(key.clone(), &outcome).unwrap();
let loaded = CheckpointStore::load(&path, 77).unwrap();
let entry = loaded.completed.get(&key).expect("checkpoint entry");
assert_eq!(entry.in_tok, 12);
assert_eq!(entry.out_tok, 3);
assert_eq!(entry.verdict.as_ref().unwrap().tier, FindingTier::Clean);
loaded.remove().unwrap();
}
#[test]
fn changed_scan_fingerprint_does_not_reuse_old_reviews() {
let path = temp_checkpoint_path();
let job = method_job("def demo():\n return 1\n");
let key = job.checkpoint_key(0);
let outcome = ReviewOutcome {
index: 0,
verdict: None,
in_tok: 1,
out_tok: 1,
};
let mut store = CheckpointStore::load(&path, 77).unwrap();
store.record(key, &outcome).unwrap();
let changed = CheckpointStore::load(&path, 78).unwrap();
assert!(changed.completed.is_empty());
changed.remove().unwrap();
}
}