Skip to main content

hotspots_core/
lib.rs

1//! Hotspots core library - static analysis for TypeScript, JavaScript, Go, Java, Python, and Rust
2
3#![deny(warnings)]
4
5// Global invariants enforced in this crate:
6// - Analysis is strictly per-function
7// - No global mutable state
8// - No randomness, clocks, or async
9// - File analysis is parallelized via rayon; all other logic is single-threaded
10// - Deterministic traversal order must be explicit
11// - Formatting, comments, and whitespace must not affect results
12// - Identical input yields byte-for-byte identical output
13
14pub 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
63/// Analyze files at the given path with default configuration
64pub fn analyze(
65    path: &std::path::Path,
66    options: AnalysisOptions,
67) -> anyhow::Result<Vec<FunctionRiskReport>> {
68    analyze_with_config(path, options, None)
69}
70
71/// Analyze files at the given path with optional resolved configuration
72pub 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
80/// Like [`analyze_with_config`] but accepts an optional progress callback.
81///
82/// `progress`, when provided, is called as `(files_done, total_files)`:
83/// - Once with `(0, total)` immediately after file discovery (skipped when no
84///   source files are found)
85/// - Once with `(n, total)` after each file is processed (order not guaranteed
86///   across parallel workers)
87pub 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    // Build weights/thresholds from config
97    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    // Collect and filter source files upfront so the total is known before analysis begins
111    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    // Parallel file analysis: each worker creates its own SourceMap (Lrc is !Send
124    // so it cannot be shared, but creating one per-task on a single thread is safe).
125    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    // Restore deterministic ordering (parallel workers complete out of order)
150    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        // Bounded min-heap: maintain at most top_n reports keyed by lrs ascending
156        // so the root is always the lowest score seen so far.
157        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                // Reverse so BinaryHeap (max-heap) pops the lowest lrs first
175                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
225/// Check if a file is a supported source file
226fn is_supported_source_file(filename: &str) -> bool {
227    // Skip TypeScript declaration files (.d.ts)
228    if filename.ends_with(".d.ts") {
229        return false;
230    }
231
232    // Use language detection to check if file is supported
233    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
243/// Collect all supported source files from a path (file or directory)
244///
245/// Supported languages and extensions:
246/// - TypeScript: .ts, .mts, .cts (excludes .d.ts declaration files)
247/// - TSX: .tsx, .mtsx, .ctsx
248/// - JavaScript: .js, .mjs, .cjs
249/// - JSX: .jsx, .mjsx, .cjsx
250/// - Go: .go
251/// - Java: .java
252/// - Python: .py, .pyw
253/// - Rust: .rs
254pub(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    // Sort files for deterministic order
268    files.sort();
269
270    Ok(files)
271}
272
273/// Returns true for directory names that should not be traversed.
274/// These are pruned at walk time before any glob matching — keep this list
275/// to things that are unambiguously never first-party source code.
276fn 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
294/// Process one directory entry, pushing source files or recursing into dirs
295fn 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
324/// Recursively collect supported source files from a directory
325fn 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
342/// Pre-compute all function IDs and build the name→indices reverse index.
343///
344/// Each function ID is allocated exactly once. `name_to_id` values store indices
345/// into the returned `Vec<String>` rather than cloned Strings, avoiding O(N)
346/// duplicate allocations during callee resolution.
347///
348/// Returns (function_ids, name_to_report_idx, report_to_graph_idx).
349/// `report_to_graph_idx[i]` maps report index i to its graph node index — necessary
350/// because intern() deduplicates identical file::function IDs, so graph node count
351/// may be less than report count.
352fn 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
377/// Resolve the best callee index for a call site.
378///
379/// Priority 1: same-file callee.
380/// Priority 2: callee in a file the caller explicitly imports.
381/// Priority 3: first name match (fallback).
382/// Returns None for self-calls or unresolved names.
383fn 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    // Priority 1: same file
395    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    // Priority 2: imported file
406    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    // Priority 3: first match (fallback)
415    possible_indices
416        .first()
417        .copied()
418        .filter(|&idx| idx != caller_idx)
419}
420
421/// Add AST-derived edges to the graph; return (total_callee_names, resolved_callee_names)
422fn 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
457/// Build a call graph from lean DB rows instead of full FunctionRiskReport slices.
458///
459/// Loads only `(function_id, file, callees)` from the TempDb — ~2 MB for 51k functions
460/// vs ~23 MB for the full reports Vec. The caller should have already dropped the reports
461/// Vec before calling this.
462///
463/// Resolution priority is identical to `build_call_graph`: same-file first, then
464/// imported-file, then first name match.
465pub 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    // Intern all function IDs and build name → row-index map.
475    // Extract the function name by stripping the "file::" prefix.
476    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    // Build import map for import-guided resolution.
491    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    // Add edges with same priority logic as build_call_graph.
500    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                // Priority 1: same file
511                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                // Priority 2: imported file
522                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                // Priority 3: first match
533                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
550/// Build a call graph from AST-derived callee names in function reports.
551pub 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    // Build import map for import-guided resolution (priority 2 after same-file)
559    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}