1pub mod blame;
36pub mod cache;
37pub mod config;
38#[cfg(feature = "native")]
39pub mod contracts;
40pub mod coverage;
41mod defect_patterns;
42pub mod diff;
43pub mod languages;
44pub mod localization;
45#[cfg(feature = "native")]
46pub mod model_parity;
47mod modes_analyze;
48mod modes_falsify;
49mod modes_fuzz;
50mod modes_hunt;
51pub mod patterns;
52pub mod pmat_quality;
53pub mod spec;
54pub mod ticket;
55mod types;
56
57#[allow(unused_imports)]
58pub use localization::{CrashBucketer, MultiChannelLocalizer, ScoredLocation};
59pub use patterns::{compute_test_lines, is_real_pattern, should_suppress_finding};
60pub use spec::ParsedSpec;
61pub use ticket::PmatTicket;
62pub use types::*;
63
64use std::path::Path;
65use std::time::Instant;
66
67#[cfg(test)]
69use modes_analyze::{
70 analyze_common_patterns, categorize_clippy_warning, extract_clippy_finding,
71 match_custom_pattern, match_lang_pattern, parse_defect_category, parse_finding_severity,
72 scan_file_for_patterns, PatternMatchContext,
73};
74#[cfg(test)]
75use modes_falsify::{analyze_file_for_mutations, detect_mutation_targets, run_falsify_mode};
76#[cfg(test)]
77use modes_fuzz::{
78 crate_forbids_unsafe, run_deep_hunt_mode, run_fuzz_mode, scan_file_for_deep_conditionals,
79 scan_file_for_unsafe_blocks, source_forbids_unsafe,
80};
81#[cfg(test)]
82use modes_hunt::{
83 analyze_coverage_hotspots, analyze_stack_trace, parse_lcov_da_line, parse_lcov_for_hotspots,
84 report_uncovered_hotspots, run_hunt_mode,
85};
86
87#[cfg(feature = "native")]
89fn eprint_phase(phase: &str, mode: &HuntMode) {
90 use crate::ansi_colors::Colorize;
91 eprintln!(" {} {}", format!("[{:>8}]", mode).dimmed(), phase);
92}
93
94pub fn hunt(project_path: &Path, config: HuntConfig) -> HuntResult {
96 let start = Instant::now();
97
98 if let Some(cached) = cache::load_cached(project_path, &config) {
100 #[cfg(feature = "native")]
101 {
102 use crate::ansi_colors::Colorize;
103 eprintln!(" {} hit — using cached findings", "[ cache]".dimmed());
104 }
105 let mut result = HuntResult::new(project_path, cached.mode, config);
106 result.findings = cached.findings;
107 result.duration_ms = start.elapsed().as_millis() as u64;
108 result.finalize();
109 return result;
110 }
111
112 let mut result = HuntResult::new(project_path, config.mode, config.clone());
113
114 #[cfg(feature = "native")]
116 eprint_phase("Scanning...", &config.mode);
117 let phase_start = Instant::now();
118
119 match config.mode {
120 HuntMode::Falsify => modes_falsify::run_falsify_mode(project_path, &config, &mut result),
121 HuntMode::Hunt => modes_hunt::run_hunt_mode(project_path, &config, &mut result),
122 HuntMode::Analyze => modes_analyze::run_analyze_mode(project_path, &config, &mut result),
123 HuntMode::Fuzz => modes_fuzz::run_fuzz_mode(project_path, &config, &mut result),
124 HuntMode::DeepHunt => modes_fuzz::run_deep_hunt_mode(project_path, &config, &mut result),
125 HuntMode::Quick => run_quick_mode(project_path, &config, &mut result),
126 }
127 result.phase_timings.mode_dispatch_ms = phase_start.elapsed().as_millis() as u64;
128
129 #[cfg(feature = "native")]
131 if config.use_pmat_quality {
132 eprint_phase("Quality index...", &config.mode);
133 let pmat_start = Instant::now();
134 let query = config.pmat_query.as_deref().unwrap_or("*");
135 if let Some(index) = pmat_quality::build_quality_index(project_path, query, 200) {
136 result.phase_timings.pmat_index_ms = pmat_start.elapsed().as_millis() as u64;
137
138 let weights_start = Instant::now();
139 eprint_phase("Applying weights...", &config.mode);
140 pmat_quality::apply_quality_weights(
141 &mut result.findings,
142 &index,
143 config.quality_weight,
144 );
145 pmat_quality::apply_regression_risk(&mut result.findings, &index);
146 result.phase_timings.pmat_weights_ms = weights_start.elapsed().as_millis() as u64;
147 }
148 }
149
150 #[cfg(feature = "native")]
152 if config.coverage_weight > 0.0 {
153 let cov_path =
155 config.coverage_path.clone().or_else(|| coverage::find_coverage_file(project_path));
156
157 if let Some(cov_path) = cov_path {
158 if let Some(cov_index) = coverage::load_coverage_index(&cov_path) {
159 eprint_phase("Coverage weights...", &config.mode);
160 coverage::apply_coverage_weights(
161 &mut result.findings,
162 &cov_index,
163 config.coverage_weight,
164 );
165 }
166 }
167 }
168
169 #[cfg(feature = "native")]
171 run_contract_gap_phase(project_path, &config, &mut result);
172
173 #[cfg(feature = "native")]
175 run_model_parity_phase(project_path, &config, &mut result);
176
177 #[cfg(feature = "native")]
179 eprint_phase("Finalizing...", &config.mode);
180 let finalize_start = Instant::now();
181
182 result.duration_ms = start.elapsed().as_millis() as u64;
183 result.finalize();
184 result.phase_timings.finalize_ms = finalize_start.elapsed().as_millis() as u64;
185
186 cache::save_cache(project_path, &config, &result.findings, result.mode);
188
189 result
190}
191
192#[cfg(feature = "native")]
194fn run_contract_gap_phase(project_path: &Path, config: &HuntConfig, result: &mut HuntResult) {
195 if config.contracts_path.is_none() && !config.contracts_auto {
196 return;
197 }
198 let Some(dir) =
199 contracts::discover_contracts_dir(project_path, config.contracts_path.as_deref())
200 else {
201 return;
202 };
203 eprint_phase("Contract gaps...", &config.mode);
204 let contract_start = Instant::now();
205 for f in contracts::analyze_contract_gaps(&dir, project_path) {
206 if f.suspiciousness >= config.min_suspiciousness {
207 result.add_finding(f);
208 }
209 }
210 result.phase_timings.contract_gap_ms = contract_start.elapsed().as_millis() as u64;
211}
212
213#[cfg(feature = "native")]
215fn run_model_parity_phase(project_path: &Path, config: &HuntConfig, result: &mut HuntResult) {
216 if config.model_parity_path.is_none() && !config.model_parity_auto {
217 return;
218 }
219 let Some(dir) =
220 model_parity::discover_model_parity_dir(project_path, config.model_parity_path.as_deref())
221 else {
222 return;
223 };
224 eprint_phase("Model parity...", &config.mode);
225 let parity_start = Instant::now();
226 for f in model_parity::analyze_model_parity_gaps(&dir, project_path) {
227 if f.suspiciousness >= config.min_suspiciousness {
228 result.add_finding(f);
229 }
230 }
231 result.phase_timings.model_parity_ms = parity_start.elapsed().as_millis() as u64;
232}
233
234pub fn hunt_ensemble(project_path: &Path, base_config: HuntConfig) -> HuntResult {
236 let start = Instant::now();
237 let mut combined = HuntResult::new(project_path, HuntMode::Analyze, base_config.clone());
238
239 for mode in [HuntMode::Analyze, HuntMode::Hunt, HuntMode::Falsify] {
241 let mut config = base_config.clone();
242 config.mode = mode;
243 let mode_result = hunt(project_path, config);
244
245 for finding in mode_result.findings {
246 let exists = combined.findings.iter().any(|f| {
251 f.file == finding.file
252 && f.line == finding.line
253 && f.category == finding.category
254 && f.title == finding.title
255 });
256 if !exists {
257 combined.add_finding(finding);
258 }
259 }
260 }
261
262 combined.duration_ms = start.elapsed().as_millis() as u64;
263 combined.finalize();
264 combined
265}
266
267pub fn hunt_with_spec(
272 project_path: &Path,
273 spec_path: &Path,
274 section_filter: Option<&str>,
275 mut config: HuntConfig,
276) -> Result<(HuntResult, ParsedSpec), String> {
277 let start = Instant::now();
278
279 let mut parsed_spec = ParsedSpec::parse(spec_path)?;
281
282 let claim_ids: Vec<String> = if let Some(section) = section_filter {
284 parsed_spec.claims_for_section(section).iter().map(|c| c.id.clone()).collect()
285 } else {
286 parsed_spec.claims.iter().map(|c| c.id.clone()).collect()
287 };
288
289 for claim in &mut parsed_spec.claims {
291 claim.implementations = spec::find_implementations(claim, project_path);
292 }
293
294 let mut target_paths: Vec<std::path::PathBuf> = parsed_spec
296 .claims
297 .iter()
298 .filter(|c| claim_ids.contains(&c.id))
299 .flat_map(|c| c.implementations.iter().map(|i| i.file.clone()))
300 .collect();
301
302 target_paths.sort();
304 target_paths.dedup();
305
306 if target_paths.is_empty() {
308 target_paths = config.targets.clone();
309 }
310
311 config.targets = target_paths
313 .iter()
314 .map(|p| p.parent().unwrap_or(Path::new("src")).to_path_buf())
315 .collect::<std::collections::HashSet<_>>()
316 .into_iter()
317 .collect();
318
319 if config.targets.is_empty() {
320 config.targets = vec![std::path::PathBuf::from("src")];
321 }
322
323 let use_pmat_quality = config.use_pmat_quality;
325 let pmat_query_str = config.pmat_query.clone();
326
327 let mut result = hunt(project_path, config);
329
330 let mapping = spec::map_findings_to_claims(&parsed_spec.claims, &result.findings, project_path);
332
333 if use_pmat_quality {
335 let query = pmat_query_str.as_deref().unwrap_or("*");
336 apply_spec_quality_gate(&mut parsed_spec, project_path, &mut result, query);
337 }
338
339 let findings_by_claim: Vec<(String, Vec<Finding>)> = mapping.into_iter().collect();
341 if let Ok(updated_content) = parsed_spec.update_with_findings(&findings_by_claim) {
342 parsed_spec.original_content = updated_content;
343 }
344
345 result.duration_ms = start.elapsed().as_millis() as u64;
346
347 Ok((result, parsed_spec))
348}
349
350fn apply_spec_quality_gate(
352 parsed_spec: &mut ParsedSpec,
353 project_path: &Path,
354 result: &mut HuntResult,
355 query: &str,
356) {
357 let Some(index) = pmat_quality::build_quality_index(project_path, query, 200) else {
358 return;
359 };
360 for claim in &mut parsed_spec.claims {
361 for imp in &claim.implementations {
362 let Some(pmat) = pmat_quality::lookup_quality(&index, &imp.file, imp.line) else {
363 continue;
364 };
365 let is_low_quality =
366 pmat.tdg_grade == "D" || pmat.tdg_grade == "F" || pmat.complexity > 20;
367 if !is_low_quality {
368 continue;
369 }
370 result.add_finding(
371 Finding::new(
372 format!("BH-QGATE-{}", claim.id),
373 &imp.file,
374 imp.line,
375 format!(
376 "Quality gate: claim `{}` implemented by low-quality code",
377 claim.id
378 ),
379 )
380 .with_description(format!(
381 "Function `{}` (grade {}, complexity {}) implements spec claim `{}`; consider refactoring",
382 pmat.function_name, pmat.tdg_grade, pmat.complexity, claim.id
383 ))
384 .with_severity(FindingSeverity::Medium)
385 .with_category(DefectCategory::LogicErrors)
386 .with_suspiciousness(0.6)
387 .with_discovered_by(HuntMode::Analyze)
388 .with_evidence(FindingEvidence::quality_metrics(
389 &pmat.tdg_grade,
390 pmat.tdg_score,
391 pmat.complexity,
392 )),
393 );
394 }
395 }
396}
397
398pub fn hunt_with_ticket(
402 project_path: &Path,
403 ticket_ref: &str,
404 mut config: HuntConfig,
405) -> Result<HuntResult, String> {
406 let ticket = PmatTicket::parse(ticket_ref, project_path)?;
408
409 config.targets = ticket.target_paths();
411
412 Ok(hunt(project_path, config))
414}
415
416fn run_quick_mode(project_path: &Path, config: &HuntConfig, result: &mut HuntResult) {
419 modes_analyze::analyze_common_patterns(project_path, config, result);
421}
422
423#[cfg(test)]
424#[path = "tests_mod.rs"]
425mod tests;