#![deny(warnings)]
pub mod aggregates;
pub mod analysis;
pub mod ast;
pub mod callgraph;
pub mod cfg;
pub mod compact;
pub mod config;
pub mod db;
pub mod delta;
pub mod discover;
pub mod gate;
pub mod git;
pub mod html;
pub mod imports;
pub mod language;
pub mod metrics;
pub mod models;
pub mod parser;
pub mod patterns;
pub mod policy;
pub mod prune;
pub mod report;
pub mod risk;
pub mod sarif;
pub mod scoring;
pub mod snapshot;
pub mod suppression;
pub mod touch_cache;
pub mod trainer;
pub mod trends;
pub use callgraph::CallGraph;
pub use config::ResolvedConfig;
pub use git::GitContext;
pub use report::{render_json, render_text, render_text_grouped, sort_reports, FunctionRiskReport};
pub use snapshot::TouchMode;
use anyhow::{Context, Result};
use swc_common::{sync::Lrc, SourceMap};
pub struct AnalysisOptions {
pub min_lrs: Option<f64>,
pub top_n: Option<usize>,
}
pub fn analyze(
path: &std::path::Path,
options: AnalysisOptions,
) -> anyhow::Result<Vec<FunctionRiskReport>> {
analyze_with_config(path, options, None)
}
pub fn analyze_with_config(
path: &std::path::Path,
options: AnalysisOptions,
resolved_config: Option<&ResolvedConfig>,
) -> anyhow::Result<Vec<FunctionRiskReport>> {
analyze_with_progress(path, options, resolved_config, None)
}
pub fn analyze_with_progress(
path: &std::path::Path,
options: AnalysisOptions,
resolved_config: Option<&ResolvedConfig>,
progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>,
) -> anyhow::Result<Vec<FunctionRiskReport>> {
use rayon::prelude::*;
use std::sync::atomic::{AtomicUsize, Ordering};
let weights = resolved_config.map(|c| risk::LrsWeights {
cc: c.weight_cc,
nd: c.weight_nd,
fo: c.weight_fo,
ns: c.weight_ns,
});
let thresholds = resolved_config.map(|c| risk::RiskThresholds {
moderate: c.moderate_threshold,
high: c.high_threshold,
critical: c.critical_threshold,
});
let pattern_thresholds = resolved_config.map(|c| &c.pattern_thresholds);
let source_files: Vec<_> = collect_source_files(path)?
.into_iter()
.filter(|f| resolved_config.map_or(true, |c| c.should_include(f)))
.collect();
let total_files = source_files.len();
if total_files > 0 {
if let Some(f) = progress {
f(0, total_files);
}
}
let counter = AtomicUsize::new(0);
let mut raw_results: Vec<(usize, &std::path::Path, Result<Vec<FunctionRiskReport>>)> =
source_files
.par_iter()
.enumerate()
.map(|(file_index, file_path)| {
let cm: Lrc<SourceMap> = Default::default();
let result = analysis::analyze_file_with_config(
file_path,
&cm,
file_index,
&options,
weights.as_ref(),
thresholds.as_ref(),
pattern_thresholds,
);
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(f) = progress {
f(done, total_files);
}
(file_index, file_path.as_path(), result)
})
.collect();
raw_results.sort_by_key(|(idx, _, _)| *idx);
let mut skipped_files: usize = 0;
let final_reports = if let Some(top_n) = options.top_n {
use std::cmp::Ordering;
use std::collections::BinaryHeap;
struct MinByLrs(FunctionRiskReport);
impl PartialEq for MinByLrs {
fn eq(&self, other: &Self) -> bool {
self.0.lrs == other.0.lrs
}
}
impl Eq for MinByLrs {}
impl PartialOrd for MinByLrs {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MinByLrs {
fn cmp(&self, other: &Self) -> Ordering {
other
.0
.lrs
.partial_cmp(&self.0.lrs)
.unwrap_or(Ordering::Equal)
}
}
let mut heap: BinaryHeap<MinByLrs> = BinaryHeap::with_capacity(top_n + 1);
for (_file_index, file_path, result) in raw_results {
match result {
Ok(reports) => {
for r in reports {
heap.push(MinByLrs(r));
if heap.len() > top_n {
heap.pop();
}
}
}
Err(e) => {
eprintln!("warning: skipping file {}: {}", file_path.display(), e);
skipped_files += 1;
}
}
}
let mut v: Vec<FunctionRiskReport> = heap.into_iter().map(|w| w.0).collect();
v.sort_by(|a, b| b.lrs.partial_cmp(&a.lrs).unwrap_or(Ordering::Equal));
v
} else {
let mut all_reports = Vec::new();
for (_file_index, file_path, result) in raw_results {
match result {
Ok(reports) => all_reports.extend(reports),
Err(e) => {
eprintln!("warning: skipping file {}: {}", file_path.display(), e);
skipped_files += 1;
}
}
}
sort_reports(all_reports)
};
if skipped_files > 0 {
eprintln!("Skipped {} file(s) due to analysis errors", skipped_files);
}
Ok(final_reports)
}
fn is_supported_source_file(filename: &str) -> bool {
if filename.ends_with(".d.ts") {
return false;
}
if let Some(ext) = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
{
language::Language::from_extension(ext).is_some()
} else {
false
}
}
pub(crate) fn collect_source_files(path: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
let mut files = Vec::new();
if path.is_file() {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if is_supported_source_file(filename) {
files.push(path.to_path_buf());
}
}
} else if path.is_dir() {
collect_source_files_recursive(path, &mut files)?;
}
files.sort();
Ok(files)
}
fn is_skipped_dir(name: &str) -> bool {
matches!(
name,
"node_modules"
| "dist"
| "build"
| "out"
| "coverage"
| "target"
| "vendor"
| "venv"
| "__pycache__"
| "storybook-static"
| "generated"
| "__generated__"
) || name.starts_with('.')
}
fn process_dir_entry(
path: std::path::PathBuf,
metadata: std::fs::Metadata,
files: &mut Vec<std::path::PathBuf>,
) -> Result<()> {
use std::ffi::OsStr;
if metadata.is_symlink() {
return Ok(());
}
if metadata.is_dir() {
if let Some(name) = path.file_name().and_then(|n: &OsStr| n.to_str()) {
if is_skipped_dir(name) {
return Ok(());
}
}
collect_source_files_recursive(&path, files)?;
} else if metadata.is_file() {
if let Some(filename) = path.file_name().and_then(|n: &OsStr| n.to_str()) {
if is_supported_source_file(filename) {
files.push(path);
}
}
}
Ok(())
}
fn collect_source_files_recursive(
dir: &std::path::Path,
files: &mut Vec<std::path::PathBuf>,
) -> Result<()> {
for entry_result in std::fs::read_dir(dir)
.with_context(|| format!("Failed to read directory: {}", dir.display()))?
{
let entry = entry_result?;
let path = entry.path();
let metadata = std::fs::symlink_metadata(&path)
.with_context(|| format!("Failed to read metadata: {}", path.display()))?;
process_dir_entry(path, metadata, files)?;
}
Ok(())
}
fn build_name_index<'r>(
reports: &'r [FunctionRiskReport],
graph: &mut callgraph::CallGraph,
) -> (
Vec<String>,
std::collections::HashMap<&'r str, Vec<usize>>,
Vec<u32>,
) {
let mut function_ids: Vec<String> = Vec::with_capacity(reports.len());
let mut name_to_idx: std::collections::HashMap<&'r str, Vec<usize>> =
std::collections::HashMap::new();
let mut report_to_graph_idx: Vec<u32> = Vec::with_capacity(reports.len());
for (i, report) in reports.iter().enumerate() {
let function_id = format!("{}::{}", report.file, report.function);
let graph_idx = graph.intern(function_id.clone());
function_ids.push(function_id);
report_to_graph_idx.push(graph_idx);
name_to_idx
.entry(report.function.as_str())
.or_default()
.push(i);
}
(function_ids, name_to_idx, report_to_graph_idx)
}
fn resolve_callee(
callee_name: &str,
caller_idx: usize,
caller_file: &str,
reports: &[FunctionRiskReport],
name_to_idx: &std::collections::HashMap<&str, Vec<usize>>,
import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
) -> Option<usize> {
let possible_indices = name_to_idx.get(callee_name)?;
let normalized_caller_file = caller_file.replace('\\', "/");
for &idx in possible_indices {
if idx == caller_idx {
continue;
}
let normalized_callee = reports[idx].file.replace('\\', "/");
if normalized_callee == normalized_caller_file {
return Some(idx);
}
}
if let Some(imports) = import_map.get(caller_file) {
for &idx in possible_indices {
if idx != caller_idx && imports.contains(&reports[idx].file) {
return Some(idx);
}
}
}
possible_indices
.first()
.copied()
.filter(|&idx| idx != caller_idx)
}
fn add_callee_edges(
reports: &[FunctionRiskReport],
name_to_idx: &std::collections::HashMap<&str, Vec<usize>>,
import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
graph: &mut callgraph::CallGraph,
report_to_graph_idx: &[u32],
) -> (usize, usize) {
let mut total = 0usize;
let mut resolved = 0usize;
for (caller_report_idx, report) in reports.iter().enumerate() {
let caller_graph_idx = report_to_graph_idx[caller_report_idx];
let mut added_callees = std::collections::HashSet::<u32>::new();
for callee_name in &report.callees {
total += 1;
if name_to_idx.contains_key(callee_name.as_str()) {
resolved += 1;
if let Some(callee_report_idx) = resolve_callee(
callee_name,
caller_report_idx,
&report.file,
reports,
name_to_idx,
import_map,
) {
let callee_graph_idx = report_to_graph_idx[callee_report_idx];
if added_callees.insert(callee_graph_idx) {
graph.add_adj(caller_graph_idx, callee_graph_idx);
}
}
}
}
}
(total, resolved)
}
pub fn build_call_graph_from_db(
db: &db::TempDb,
sha: &str,
repo_root: &std::path::Path,
) -> Result<callgraph::CallGraph> {
let rows = db.load_callee_rows(sha)?;
let mut graph = callgraph::CallGraph::new();
let mut name_to_idx: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
let mut row_to_graph_idx: Vec<u32> = Vec::with_capacity(rows.len());
for (i, (function_id, file, _)) in rows.iter().enumerate() {
let graph_idx = graph.intern(function_id.clone());
row_to_graph_idx.push(graph_idx);
let name = function_id
.get(file.len() + 2..)
.unwrap_or(function_id.as_str())
.to_string();
name_to_idx.entry(name).or_default().push(i);
}
let file_list: Vec<&str> = rows.iter().map(|(_, f, _)| f.as_str()).collect();
let file_deps = crate::imports::resolve_file_deps(&file_list, repo_root);
let mut import_map: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for (from, to) in file_deps {
import_map.entry(from).or_default().insert(to);
}
let mut total = 0usize;
let mut resolved = 0usize;
for (caller_idx, (_, caller_file, callees)) in rows.iter().enumerate() {
let caller_graph_idx = row_to_graph_idx[caller_idx];
let caller_file_norm = caller_file.replace('\\', "/");
let mut added: std::collections::HashSet<u32> = std::collections::HashSet::new();
for callee_name in callees {
total += 1;
if let Some(candidates) = name_to_idx.get(callee_name.as_str()) {
resolved += 1;
let mut chosen = None;
for &idx in candidates {
if idx == caller_idx {
continue;
}
if rows[idx].1.replace('\\', "/") == caller_file_norm {
chosen = Some(idx);
break;
}
}
if chosen.is_none() {
if let Some(imports) = import_map.get(caller_file.as_str()) {
for &idx in candidates {
if idx != caller_idx && imports.contains(&rows[idx].1) {
chosen = Some(idx);
break;
}
}
}
}
if chosen.is_none() {
chosen = candidates.iter().copied().find(|&idx| idx != caller_idx);
}
if let Some(callee_idx) = chosen {
let callee_graph_idx = row_to_graph_idx[callee_idx];
if added.insert(callee_graph_idx) {
graph.add_adj(caller_graph_idx, callee_graph_idx);
}
}
}
}
}
graph.total_callee_names = total;
graph.resolved_callee_names = resolved;
Ok(graph)
}
pub fn build_call_graph(
reports: &[FunctionRiskReport],
repo_root: &std::path::Path,
) -> Result<callgraph::CallGraph> {
let mut graph = callgraph::CallGraph::new();
let (_, name_to_idx, report_to_graph_idx) = build_name_index(reports, &mut graph);
let file_list: Vec<&str> = reports.iter().map(|r| r.file.as_str()).collect();
let file_deps = crate::imports::resolve_file_deps(&file_list, repo_root);
let mut import_map: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for (from, to) in file_deps {
import_map.entry(from).or_default().insert(to);
}
let (total, resolved) = add_callee_edges(
reports,
&name_to_idx,
&import_map,
&mut graph,
&report_to_graph_idx,
);
graph.total_callee_names = total;
graph.resolved_callee_names = resolved;
Ok(graph)
}