#![deny(warnings)]
pub mod aggregates;
pub mod analysis;
pub mod ast;
pub mod callgraph;
pub mod cfg;
pub mod config;
pub mod delta;
pub mod discover;
pub mod git;
pub mod html;
pub mod imports;
pub mod language;
pub mod metrics;
pub mod parser;
pub mod patterns;
pub mod policy;
pub mod prune;
pub mod report;
pub mod risk;
pub mod scoring;
pub mod snapshot;
pub mod suppression;
pub mod touch_cache;
pub mod trends;
pub use callgraph::CallGraph;
pub use config::ResolvedConfig;
pub use git::GitContext;
pub use report::{render_json, render_text, sort_reports, FunctionRiskReport};
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 all_reports = Vec::new();
let mut skipped_files: usize = 0;
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;
}
}
}
if skipped_files > 0 {
eprintln!("Skipped {} file(s) due to analysis errors", skipped_files);
}
let sorted_reports = sort_reports(all_reports);
let final_reports = if let Some(top_n) = options.top_n {
sorted_reports.into_iter().take(top_n).collect()
} else {
sorted_reports
};
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
}
}
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 {
name.starts_with('.')
|| name == "node_modules"
|| name == "dist"
|| name == "build"
|| name == "out"
|| name == "coverage"
|| name == "target"
}
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(
reports: &[FunctionRiskReport],
graph: &mut callgraph::CallGraph,
) -> std::collections::HashMap<String, Vec<String>> {
let mut name_to_id: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for report in reports {
let function_id = format!("{}::{}", report.file, report.function);
graph.add_node(function_id.clone());
name_to_id
.entry(report.function.clone())
.or_default()
.push(function_id);
}
name_to_id
}
fn resolve_callee(
callee_name: &str,
caller_id: &str,
caller_file: &str,
name_to_id: &std::collections::HashMap<String, Vec<String>>,
import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
) -> Option<String> {
let possible_callees = name_to_id.get(callee_name)?;
let normalized_caller_file = caller_file.replace('\\', "/");
for callee_id in possible_callees {
let normalized_callee = callee_id.replace('\\', "/");
if normalized_callee.starts_with(&format!("{}::", normalized_caller_file)) {
return (callee_id != caller_id).then(|| callee_id.clone());
}
}
if let Some(imports) = import_map.get(caller_file) {
for callee_id in possible_callees {
let callee_file = callee_id.split("::").next().unwrap_or("");
if imports.contains(callee_file) && callee_id != caller_id {
return Some(callee_id.clone());
}
}
}
possible_callees
.first()
.filter(|id| *id != caller_id)
.cloned()
}
fn add_callee_edges(
reports: &[FunctionRiskReport],
name_to_id: &std::collections::HashMap<String, Vec<String>>,
import_map: &std::collections::HashMap<String, std::collections::HashSet<String>>,
graph: &mut callgraph::CallGraph,
) -> (usize, usize) {
let mut total = 0usize;
let mut resolved = 0usize;
for report in reports {
let caller_id = format!("{}::{}", report.file, report.function);
let mut added_callees = std::collections::HashSet::new();
for callee_name in &report.callees {
total += 1;
if name_to_id.contains_key(callee_name.as_str()) {
resolved += 1;
if let Some(callee_id) = resolve_callee(
callee_name,
&caller_id,
&report.file,
name_to_id,
import_map,
) {
if added_callees.insert(callee_id.clone()) {
graph.add_edge(caller_id.clone(), callee_id);
}
}
}
}
}
(total, resolved)
}
pub fn build_call_graph(
reports: &[FunctionRiskReport],
repo_root: &std::path::Path,
) -> Result<callgraph::CallGraph> {
let mut graph = callgraph::CallGraph::new();
let name_to_id = 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_id, &import_map, &mut graph);
graph.total_callee_names = total;
graph.resolved_callee_names = resolved;
Ok(graph)
}