1#![deny(warnings)]
4
5pub mod aggregates;
15pub mod analysis;
16pub mod ast;
17pub mod callgraph;
18pub mod cfg;
19pub mod compact;
20pub mod config;
21pub mod coupling;
22pub mod db;
23pub mod delta;
24pub mod discover;
25pub mod gate;
26pub mod git;
27pub mod history_signals;
28pub mod html;
29pub mod imports;
30pub mod isolation_forest;
31pub mod language;
32pub mod metrics;
33pub mod models;
34pub mod parser;
35pub mod patterns;
36pub mod phrases;
37pub mod policy;
38pub mod prune;
39pub mod report;
40pub mod risk;
41pub mod sarif;
42pub mod scoring;
43pub mod snapshot;
44pub mod suppression;
45pub mod touch_cache;
46pub mod trainer;
47pub mod trends;
48
49pub use callgraph::CallGraph;
50pub use config::ResolvedConfig;
51pub use git::GitContext;
52pub use report::{render_json, render_text, render_text_grouped, sort_reports, FunctionRiskReport};
53pub use snapshot::TouchMode;
54
55use anyhow::{Context, Result};
56use swc_common::{sync::Lrc, SourceMap};
57
58pub struct AnalysisOptions {
59 pub min_lrs: Option<f64>,
60 pub top_n: Option<usize>,
61}
62
63pub fn analyze(
65 path: &std::path::Path,
66 options: AnalysisOptions,
67) -> anyhow::Result<Vec<FunctionRiskReport>> {
68 analyze_with_config(path, options, None)
69}
70
71pub fn analyze_with_config(
73 path: &std::path::Path,
74 options: AnalysisOptions,
75 resolved_config: Option<&ResolvedConfig>,
76) -> anyhow::Result<Vec<FunctionRiskReport>> {
77 analyze_with_progress(path, options, resolved_config, None)
78}
79
80pub fn analyze_with_progress(
88 path: &std::path::Path,
89 options: AnalysisOptions,
90 resolved_config: Option<&ResolvedConfig>,
91 progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>,
92) -> anyhow::Result<Vec<FunctionRiskReport>> {
93 use rayon::prelude::*;
94 use std::sync::atomic::{AtomicUsize, Ordering};
95
96 let weights = resolved_config.map(|c| risk::LrsWeights {
98 cc: c.weight_cc,
99 nd: c.weight_nd,
100 fo: c.weight_fo,
101 ns: c.weight_ns,
102 });
103 let thresholds = resolved_config.map(|c| risk::RiskThresholds {
104 moderate: c.moderate_threshold,
105 high: c.high_threshold,
106 critical: c.critical_threshold,
107 });
108 let pattern_thresholds = resolved_config.map(|c| &c.pattern_thresholds);
109
110 let source_files: Vec<_> = collect_source_files(path)?
112 .into_iter()
113 .filter(|f| resolved_config.map_or(true, |c| c.should_include(f)))
114 .collect();
115 let total_files = source_files.len();
116
117 if total_files > 0 {
118 if let Some(f) = progress {
119 f(0, total_files);
120 }
121 }
122
123 let counter = AtomicUsize::new(0);
126 let mut raw_results: Vec<(usize, &std::path::Path, Result<Vec<FunctionRiskReport>>)> =
127 source_files
128 .par_iter()
129 .enumerate()
130 .map(|(file_index, file_path)| {
131 let cm: Lrc<SourceMap> = Default::default();
132 let result = analysis::analyze_file_with_config(
133 file_path,
134 &cm,
135 file_index,
136 &options,
137 weights.as_ref(),
138 thresholds.as_ref(),
139 pattern_thresholds,
140 );
141 let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
142 if let Some(f) = progress {
143 f(done, total_files);
144 }
145 (file_index, file_path.as_path(), result)
146 })
147 .collect();
148
149 raw_results.sort_by_key(|(idx, _, _)| *idx);
151
152 let mut skipped_files: usize = 0;
153
154 let final_reports = if let Some(top_n) = options.top_n {
155 use std::cmp::Ordering;
158 use std::collections::BinaryHeap;
159
160 struct MinByLrs(FunctionRiskReport);
161 impl PartialEq for MinByLrs {
162 fn eq(&self, other: &Self) -> bool {
163 self.0.lrs == other.0.lrs
164 }
165 }
166 impl Eq for MinByLrs {}
167 impl PartialOrd for MinByLrs {
168 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
169 Some(self.cmp(other))
170 }
171 }
172 impl Ord for MinByLrs {
173 fn cmp(&self, other: &Self) -> Ordering {
174 other
176 .0
177 .lrs
178 .partial_cmp(&self.0.lrs)
179 .unwrap_or(Ordering::Equal)
180 }
181 }
182
183 let mut heap: BinaryHeap<MinByLrs> = BinaryHeap::with_capacity(top_n + 1);
184 for (_file_index, file_path, result) in raw_results {
185 match result {
186 Ok(reports) => {
187 for r in reports {
188 heap.push(MinByLrs(r));
189 if heap.len() > top_n {
190 heap.pop();
191 }
192 }
193 }
194 Err(e) => {
195 eprintln!("warning: skipping file {}: {}", file_path.display(), e);
196 skipped_files += 1;
197 }
198 }
199 }
200
201 let mut v: Vec<FunctionRiskReport> = heap.into_iter().map(|w| w.0).collect();
202 v.sort_by(|a, b| b.lrs.partial_cmp(&a.lrs).unwrap_or(Ordering::Equal));
203 v
204 } else {
205 let mut all_reports = Vec::new();
206 for (_file_index, file_path, result) in raw_results {
207 match result {
208 Ok(reports) => all_reports.extend(reports),
209 Err(e) => {
210 eprintln!("warning: skipping file {}: {}", file_path.display(), e);
211 skipped_files += 1;
212 }
213 }
214 }
215 sort_reports(all_reports)
216 };
217
218 if skipped_files > 0 {
219 eprintln!("Skipped {} file(s) due to analysis errors", skipped_files);
220 }
221
222 Ok(final_reports)
223}
224
225fn is_supported_source_file(filename: &str) -> bool {
227 if filename.ends_with(".d.ts") {
229 return false;
230 }
231
232 if let Some(ext) = std::path::Path::new(filename)
234 .extension()
235 .and_then(|e| e.to_str())
236 {
237 language::Language::from_extension(ext).is_some()
238 } else {
239 false
240 }
241}
242
243pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
255 let mut files = Vec::new();
256
257 if path.is_file() {
258 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
259 if is_supported_source_file(filename) {
260 files.push(path.to_path_buf());
261 }
262 }
263 } else if path.is_dir() {
264 collect_source_files_recursive(path, &mut files)?;
265 }
266
267 files.sort();
269
270 Ok(files)
271}
272
273fn is_skipped_dir(name: &str) -> bool {
277 matches!(
278 name,
279 "node_modules"
280 | "dist"
281 | "build"
282 | "out"
283 | "coverage"
284 | "target"
285 | "vendor"
286 | "venv"
287 | "__pycache__"
288 | "storybook-static"
289 | "generated"
290 | "__generated__"
291 ) || name.starts_with('.')
292}
293
294fn process_dir_entry(
296 path: std::path::PathBuf,
297 metadata: std::fs::Metadata,
298 files: &mut Vec<std::path::PathBuf>,
299) -> Result<()> {
300 use std::ffi::OsStr;
301
302 if metadata.is_symlink() {
303 return Ok(());
304 }
305
306 if metadata.is_dir() {
307 if let Some(name) = path.file_name().and_then(|n: &OsStr| n.to_str()) {
308 if is_skipped_dir(name) {
309 return Ok(());
310 }
311 }
312 collect_source_files_recursive(&path, files)?;
313 } else if metadata.is_file() {
314 if let Some(filename) = path.file_name().and_then(|n: &OsStr| n.to_str()) {
315 if is_supported_source_file(filename) {
316 files.push(path);
317 }
318 }
319 }
320
321 Ok(())
322}
323
324fn collect_source_files_recursive(
326 dir: &std::path::Path,
327 files: &mut Vec<std::path::PathBuf>,
328) -> Result<()> {
329 for entry_result in std::fs::read_dir(dir)
330 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
331 {
332 let entry = entry_result?;
333 let path = entry.path();
334 let metadata = std::fs::symlink_metadata(&path)
335 .with_context(|| format!("Failed to read metadata: {}", path.display()))?;
336 process_dir_entry(path, metadata, files)?;
337 }
338
339 Ok(())
340}
341
342fn build_name_index<'r>(
353 reports: &'r [FunctionRiskReport],
354 graph: &mut callgraph::CallGraph,
355) -> (
356 Vec<String>,
357 std::collections::HashMap<&'r str, Vec<usize>>,
358 Vec<u32>,
359) {
360 let mut function_ids: Vec<String> = Vec::with_capacity(reports.len());
361 let mut name_to_idx: std::collections::HashMap<&'r str, Vec<usize>> =
362 std::collections::HashMap::new();
363 let mut report_to_graph_idx: Vec<u32> = Vec::with_capacity(reports.len());
364 for (i, report) in reports.iter().enumerate() {
365 let function_id = format!("{}::{}", report.file, report.function);
366 let graph_idx = graph.intern(function_id.clone());
367 function_ids.push(function_id);
368 report_to_graph_idx.push(graph_idx);
369 name_to_idx
370 .entry(report.function.as_str())
371 .or_default()
372 .push(i);
373 }
374 (function_ids, name_to_idx, report_to_graph_idx)
375}
376
377fn resolve_callee(
384 callee_name: &str,
385 caller_idx: usize,
386 caller_file: &str,
387 reports: &[FunctionRiskReport],
388 name_to_idx: &std::collections::HashMap<&str, Vec<usize>>,
389 import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
390) -> Option<usize> {
391 let possible_indices = name_to_idx.get(callee_name)?;
392 let normalized_caller_file = caller_file.replace('\\', "/");
393
394 for &idx in possible_indices {
396 if idx == caller_idx {
397 continue;
398 }
399 let normalized_callee = reports[idx].file.replace('\\', "/");
400 if normalized_callee == normalized_caller_file {
401 return Some(idx);
402 }
403 }
404
405 if let Some(imports) = import_map.get(caller_file) {
407 for &idx in possible_indices {
408 if idx != caller_idx && imports.contains(&reports[idx].file) {
409 return Some(idx);
410 }
411 }
412 }
413
414 possible_indices
416 .first()
417 .copied()
418 .filter(|&idx| idx != caller_idx)
419}
420
421fn add_callee_edges(
423 reports: &[FunctionRiskReport],
424 name_to_idx: &std::collections::HashMap<&str, Vec<usize>>,
425 import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
426 graph: &mut callgraph::CallGraph,
427 report_to_graph_idx: &[u32],
428) -> (usize, usize) {
429 let mut total = 0usize;
430 let mut resolved = 0usize;
431 for (caller_report_idx, report) in reports.iter().enumerate() {
432 let caller_graph_idx = report_to_graph_idx[caller_report_idx];
433 let mut added_callees = std::collections::HashSet::<u32>::new();
434 for callee_name in &report.callees {
435 total += 1;
436 if name_to_idx.contains_key(callee_name.as_str()) {
437 resolved += 1;
438 if let Some(callee_report_idx) = resolve_callee(
439 callee_name,
440 caller_report_idx,
441 &report.file,
442 reports,
443 name_to_idx,
444 import_map,
445 ) {
446 let callee_graph_idx = report_to_graph_idx[callee_report_idx];
447 if added_callees.insert(callee_graph_idx) {
448 graph.add_adj(caller_graph_idx, callee_graph_idx);
449 }
450 }
451 }
452 }
453 }
454 (total, resolved)
455}
456
457pub fn build_call_graph_from_db(
466 db: &db::TempDb,
467 sha: &str,
468 repo_root: &std::path::Path,
469) -> Result<callgraph::CallGraph> {
470 let rows = db.load_callee_rows(sha)?;
471
472 let mut graph = callgraph::CallGraph::new();
473
474 let mut name_to_idx: std::collections::HashMap<String, Vec<usize>> =
477 std::collections::HashMap::new();
478 let mut row_to_graph_idx: Vec<u32> = Vec::with_capacity(rows.len());
479
480 for (i, (function_id, file, _)) in rows.iter().enumerate() {
481 let graph_idx = graph.intern(function_id.clone());
482 row_to_graph_idx.push(graph_idx);
483 let name = function_id
484 .get(file.len() + 2..)
485 .unwrap_or(function_id.as_str())
486 .to_string();
487 name_to_idx.entry(name).or_default().push(i);
488 }
489
490 let file_list: Vec<&str> = rows.iter().map(|(_, f, _)| f.as_str()).collect();
492 let file_deps = crate::imports::resolve_file_deps(&file_list, repo_root);
493 let mut import_map: std::collections::HashMap<String, std::collections::HashSet<String>> =
494 std::collections::HashMap::new();
495 for (from, to) in file_deps {
496 import_map.entry(from).or_default().insert(to);
497 }
498
499 let mut total = 0usize;
501 let mut resolved = 0usize;
502 for (caller_idx, (_, caller_file, callees)) in rows.iter().enumerate() {
503 let caller_graph_idx = row_to_graph_idx[caller_idx];
504 let caller_file_norm = caller_file.replace('\\', "/");
505 let mut added: std::collections::HashSet<u32> = std::collections::HashSet::new();
506 for callee_name in callees {
507 total += 1;
508 if let Some(candidates) = name_to_idx.get(callee_name.as_str()) {
509 resolved += 1;
510 let mut chosen = None;
512 for &idx in candidates {
513 if idx == caller_idx {
514 continue;
515 }
516 if rows[idx].1.replace('\\', "/") == caller_file_norm {
517 chosen = Some(idx);
518 break;
519 }
520 }
521 if chosen.is_none() {
523 if let Some(imports) = import_map.get(caller_file.as_str()) {
524 for &idx in candidates {
525 if idx != caller_idx && imports.contains(&rows[idx].1) {
526 chosen = Some(idx);
527 break;
528 }
529 }
530 }
531 }
532 if chosen.is_none() {
534 chosen = candidates.iter().copied().find(|&idx| idx != caller_idx);
535 }
536 if let Some(callee_idx) = chosen {
537 let callee_graph_idx = row_to_graph_idx[callee_idx];
538 if added.insert(callee_graph_idx) {
539 graph.add_adj(caller_graph_idx, callee_graph_idx);
540 }
541 }
542 }
543 }
544 }
545 graph.total_callee_names = total;
546 graph.resolved_callee_names = resolved;
547 Ok(graph)
548}
549
550pub fn build_call_graph(
552 reports: &[FunctionRiskReport],
553 repo_root: &std::path::Path,
554) -> Result<callgraph::CallGraph> {
555 let mut graph = callgraph::CallGraph::new();
556 let (_, name_to_idx, report_to_graph_idx) = build_name_index(reports, &mut graph);
557
558 let file_list: Vec<&str> = reports.iter().map(|r| r.file.as_str()).collect();
560 let file_deps = crate::imports::resolve_file_deps(&file_list, repo_root);
561 let mut import_map: std::collections::HashMap<String, std::collections::HashSet<String>> =
562 std::collections::HashMap::new();
563 for (from, to) in file_deps {
564 import_map.entry(from).or_default().insert(to);
565 }
566
567 let (total, resolved) = add_callee_edges(
568 reports,
569 &name_to_idx,
570 &import_map,
571 &mut graph,
572 &report_to_graph_idx,
573 );
574 graph.total_callee_names = total;
575 graph.resolved_callee_names = resolved;
576 Ok(graph)
577}