//! Unhandled Promise Rejection Detector
//!
//! Graph-enhanced detection of unhandled promises:
//! - Trace promise chains across function boundaries
//! - Check if async functions have try/catch at call site
//! - Higher severity for promises in critical paths
use crate::detectors::base::{Detector, DetectorConfig};
use crate::detectors::text_utils::strip_string_literals;
use crate::graph::GraphQueryExt;
use crate::models::{deterministic_finding_id, Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::LazyLock;
use tracing::info;
static PROMISE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(new Promise|\.then\(|fetch\(|axios\.|\.json\(\))").expect("valid regex")
});
static ASYNC_FUNC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"async\s+(function\s+)?(\w+)").expect("valid regex"));
pub struct UnhandledPromiseDetector {
#[allow(dead_code)] // Part of detector pattern, used for file scanning
repository_path: PathBuf,
max_findings: usize,
}
impl UnhandledPromiseDetector {
crate::detectors::detector_new!(50);
/// Find all async functions in the codebase
fn find_async_functions(
&self,
files: &dyn crate::detectors::file_provider::FileProvider,
) -> HashSet<String> {
let mut async_funcs = HashSet::new();
for path in files.files_with_extensions(&["js", "ts", "jsx", "tsx"]) {
if let Some(content) = files.content(path) {
for cap in ASYNC_FUNC.captures_iter(&content) {
if let Some(name) = cap.get(2) {
async_funcs.insert(name.as_str().to_string());
}
}
}
}
async_funcs
}
/// Check if the promise is in a critical path (auth, payment, etc.)
fn is_critical_context(line: &str, surrounding: &str) -> bool {
let combined = format!("{} {}", line, surrounding).to_lowercase();
combined.contains("auth")
|| combined.contains("login")
|| combined.contains("payment")
|| combined.contains("order")
|| combined.contains("user")
|| combined.contains("session")
|| combined.contains("token")
|| combined.contains("credential")
}
}
/// Check if `line` contains a CALL to async function `name` (not a declaration).
/// `export async function putLocalEnvelopes(` contains `putLocalEnvelopes(` but is
/// a declaration — the text before the match ends with `function`. Actual calls like
/// `putLocalEnvelopes(data)` or `await putLocalEnvelopes(data)` do not.
fn is_async_call(line: &str, name: &str) -> bool {
let call = format!("{}(", name);
if let Some(idx) = line.find(&call) {
// Check the text before the match doesn't end with "function" (declaration)
let before = line[..idx].trim_end();
!before.ends_with("function") && !line.contains("await")
} else {
false
}
}
/// Extract variable name from an assignment: `const x = ...` -> Some("x")
fn extract_assignment_target(line: &str) -> Option<&str> {
let trimmed = line.trim();
for prefix in &["const ", "let ", "var "] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let name = rest
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()?;
if !name.is_empty() {
return Some(name);
}
}
}
None
}
/// Check if `haystack` contains `word` delimited by non-word characters (or string boundaries).
/// Equivalent to regex `\bword\b` but with zero allocation.
fn contains_word(haystack: &str, word: &str) -> bool {
let h = haystack.as_bytes();
let w = word.as_bytes();
if w.is_empty() || w.len() > h.len() {
return false;
}
for i in 0..=h.len() - w.len() {
if &h[i..i + w.len()] == w {
let before_ok = i == 0 || !(h[i - 1].is_ascii_alphanumeric() || h[i - 1] == b'_');
let after_ok = i + w.len() >= h.len()
|| !(h[i + w.len()].is_ascii_alphanumeric() || h[i + w.len()] == b'_');
if before_ok && after_ok {
return true;
}
}
}
false
}
/// Check if a variable is returned later in the same function scope.
fn is_returned_in_scope(lines: &[&str], from: usize, var_name: &str) -> bool {
let mut depth = 0i32;
for line in &lines[from + 1..] {
let t = line.trim();
depth += t.matches('{').count() as i32;
depth -= t.matches('}').count() as i32;
if depth < 0 {
break;
}
if t.starts_with("return ") && contains_word(t, var_name) {
return true;
}
}
false
}
impl Detector for UnhandledPromiseDetector {
fn name(&self) -> &'static str {
"unhandled-promise"
}
fn description(&self) -> &'static str {
"Detects promises without error handling"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["js", "ts", "jsx", "tsx"]
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let graph = ctx.graph;
let files = &ctx.as_file_provider();
let mut findings = vec![];
let async_funcs = self.find_async_functions(files);
for path in files.files_with_extensions(&["js", "ts", "jsx", "tsx"]) {
if findings.len() >= self.max_findings {
break;
}
let path_str = path.to_string_lossy().to_string();
// Skip test files
if crate::detectors::base::is_test_path(&path_str) {
continue;
}
if let Some(content) = files.content(path) {
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
if crate::detectors::is_line_suppressed(line, prev_line) {
continue;
}
// Skip comments
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("*") {
continue;
}
// Skip async function DECLARATION lines — they define, not invoke
if trimmed.starts_with("async function ")
|| trimmed.contains("= async (")
|| trimmed.contains("= async function")
|| (trimmed.contains("async ") && trimmed.ends_with("{"))
{
continue;
}
// Skip React Query hooks — they handle promise rejection internally
if trimmed.contains("useMutation(")
|| trimmed.contains("useQuery(")
|| trimmed.contains("queryFn")
|| trimmed.contains("mutationFn")
{
continue;
}
// Skip if the line has proper await
if trimmed.contains("await ") {
continue;
}
// Skip void-prefixed promises — explicitly marked as intentionally unawaited
if trimmed.starts_with("void ") {
continue;
}
// Redact string-literal contents before pattern matching so
// `new Promise` / `.then(` appearing inside data strings
// (generated changelogs, commit messages, fixture text)
// can't trigger the detector. AST-equivalent: skip
// `string` / `template_string` node bodies.
let line_code = strip_string_literals(line);
let has_promise = PROMISE_PATTERN.is_match(&line_code);
// Verify that this line is actually inside an async function
// or deals with promises. Don't flag sync code.
if !has_promise
&& !line_code.contains("await ")
&& !line_code.contains(".then(")
{
// Check if calling a known async function (not declaring one)
let calls_async_fn =
async_funcs.iter().any(|f| is_async_call(&line_code, f));
if !calls_async_fn {
continue;
}
}
// Verify the containing function is actually async
// Walk backward tracking brace depth to find the NEAREST enclosing function
let mut is_in_async_context = false;
let mut depth = 0i32;
for j in (0..=i).rev() {
let prev = lines[j].trim();
// Track braces going backward: } increases depth, { decreases
depth += prev.matches('}').count() as i32;
depth -= prev.matches('{').count() as i32;
// We've exited our scope — this is the enclosing function definition
if depth < 0 {
if prev.contains("async ") {
is_in_async_context = true;
}
break;
}
// Also check for function declaration at same scope level
if depth == 0
&& (prev.starts_with("async function ")
|| prev.starts_with("export async function ")
|| (prev.contains("async ") && prev.contains("=>"))
|| prev.starts_with("async ("))
{
is_in_async_context = true;
break;
}
if depth == 0
&& (prev.starts_with("function ")
|| prev.starts_with("export function "))
&& !prev.contains("async")
{
break;
}
}
// Only flag un-caught promises, not async function calls in sync code
// (calling async from sync is expected — you'd handle it at the call site)
if !is_in_async_context && !has_promise {
continue;
}
// Also check calls to known async functions without await.
// Only flag if the current function context is itself async — calling
// an async function from sync code is expected (you can't await there).
let calls_async = async_funcs.iter().any(|f| is_async_call(&line_code, f));
// Skip returned promises — caller handles errors (no-floating-promises #4)
if (has_promise || calls_async)
&& (trimmed.starts_with("return ") || trimmed.starts_with("return("))
{
continue;
}
// Skip assigned promises that are later returned
if has_promise || calls_async {
if let Some(var_name) = extract_assignment_target(trimmed) {
if is_returned_in_scope(&lines, i, var_name) {
continue;
}
}
}
if has_promise || calls_async {
// Check surrounding context for error handling.
// Use a wider window to find try/catch inside the function body.
let start = i.saturating_sub(20);
let end = (i + 20).min(lines.len());
let context = lines[start..end].join(" ");
let has_catch = context.contains(".catch")
|| context.contains("catch (")
|| context.contains("catch(");
// Look for try { anywhere in the preceding 20 lines (function body)
let in_try = lines[start..i]
.iter()
.any(|l| l.contains("try {") || l.contains("try{"));
let has_finally = context.contains(".finally");
// Check for two-arg .then(success, error) — handles rejections.
// Scope to the current line only so a two-arg .then() on a
// different promise chain in the context window can't suppress this.
let has_two_arg_then = if let Some(then_idx) = trimmed.find(".then(") {
let after = &trimmed[then_idx + 6..];
let mut found_comma = false;
let mut depth = 0i32;
for ch in after.chars() {
match ch {
'(' => depth += 1,
')' if depth == 0 => break,
')' => depth -= 1,
',' if depth == 0 => {
found_comma = true;
break;
}
_ => {}
}
}
found_comma
} else {
false
};
if has_catch || in_try || has_two_arg_then {
continue;
}
// Only flag .json() if it's clearly promise-chained (e.g. fetch().json())
// Standalone .json() calls (like JSON parsing) should not be flagged
if (!has_promise
|| (line.contains(".json()")
&& !line.contains("fetch(")
&& !line.contains(".then(")
&& !line.contains("axios.")))
&& !calls_async
{
continue;
}
// Analyze context
let is_critical = Self::is_critical_context(line, &context);
let containing_func =
graph.find_function_at(&path_str, (i + 1) as u32).map(|f| {
let callers = graph
.get_callers(f.qn(crate::graph::interner::global_interner()))
.len();
(
f.node_name(crate::graph::interner::global_interner())
.to_string(),
callers,
)
});
// Calculate severity
let severity = if is_critical {
Severity::High // Critical path without error handling
} else {
Severity::Medium
};
// Build notes
let mut notes = Vec::new();
if is_critical {
notes.push("⚠️ In critical path (auth/payment/user)".to_string());
}
if calls_async {
notes.push(
"🔍 Calls async function without await or .catch".to_string(),
);
}
if let Some((func_name, callers)) = containing_func {
notes.push(format!(
"📦 In function: `{}` ({} callers)",
func_name, callers
));
}
if has_finally {
notes.push("✓ Has .finally() but no .catch()".to_string());
}
let context_notes = if notes.is_empty() {
String::new()
} else {
format!("\n\n**Analysis:**\n{}", notes.join("\n"))
};
findings.push(Finding {
id: String::new(),
detector: "UnhandledPromiseDetector".to_string(),
severity,
title: if calls_async {
"Async function called without error handling".to_string()
} else {
"Promise without .catch()".to_string()
},
description: format!(
"Promise rejection may go unhandled, causing silent failures or crashes.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some(
"Options:\n\n\
**1. Add .catch():**\n\
```javascript\n\
fetchData()\n\
.then(data => process(data))\n\
.catch(err => console.error('Failed:', err));\n\
```\n\n\
**2. Use try/catch with await:**\n\
```javascript\n\
try {\n\
const data = await fetchData();\n\
process(data);\n\
} catch (err) {\n\
console.error('Failed:', err);\n\
}\n\
```".to_string()
),
estimated_effort: Some("5 minutes".to_string()),
category: Some("error-handling".to_string()),
cwe_id: Some("CWE-755".to_string()),
why_it_matters: Some(
"Unhandled promise rejections can crash Node.js (--unhandled-rejections=strict) \
or cause silent failures that are hard to debug.".to_string()
),
..Default::default()
});
}
}
}
}
info!(
"UnhandledPromiseDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}
impl crate::detectors::RegisteredDetector for UnhandledPromiseDetector {
fn create(init: &crate::detectors::DetectorInit) -> std::sync::Arc<dyn Detector> {
std::sync::Arc::new(Self::new(init.repo_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::builder::GraphBuilder;
#[test]
fn test_detects_promise_without_catch() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("service.js", "async function handleRequest() {\n const x = 1;\n fetch(\"/api/data\").then(res => res.json());\n return x;\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect promise .then() without .catch()"
);
}
#[test]
fn test_no_finding_for_returned_promise() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"db.ts",
"async function deleteDb() {\n return new Promise((resolve, reject) => {\n const req = indexedDB.deleteDatabase('mydb');\n req.onsuccess = () => resolve(undefined);\n req.onerror = () => reject(req.error);\n });\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"Returned Promise should not be flagged, got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_returned_async_call() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"api.ts",
"async function getUser(id: string) {\n return fetch(`/api/users/${id}`);\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"Returned async call should not be flagged"
);
}
#[test]
fn test_no_finding_for_assigned_and_returned() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"store.ts",
"async function saveData(data: any) {\n const result = fetch('/api/save', { method: 'POST' });\n return result;\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"Assigned-then-returned promise should not be flagged"
);
}
#[test]
fn test_no_finding_for_void_promise() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"fire.ts",
"async function init() {\n void fetch('/api/ping');\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"void-prefixed promise should not be flagged"
);
}
#[test]
fn test_no_finding_for_two_arg_then() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"handler.js",
"async function load() {\n fetch('/data').then(res => res.json(), err => console.error(err));\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(findings.is_empty(), "Two-arg .then() should not be flagged");
}
#[test]
fn test_still_flags_fire_and_forget() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"bad.js",
"async function doStuff() {\n fetch('/api/data').then(res => res.json());\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
!findings.is_empty(),
"Fire-and-forget .then() without .catch() should be flagged"
);
}
#[test]
fn test_still_flags_then_without_catch() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"single_arg.js",
"async function load() {\n fetch('/data').then(res => process(res));\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
!findings.is_empty(),
".then() with one arg and no .catch() should be flagged"
);
}
#[test]
fn test_still_flags_arrow_fire_and_forget() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"loop.js",
"async function processAll(items) {\n items.forEach(item => fetch(`/api/${item}`).then(r => r.json()));\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
!findings.is_empty(),
"Arrow fire-and-forget should still be flagged"
);
}
#[test]
fn test_no_finding_when_catch_present() {
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&store, vec![
("service_good.js", "async function handleRequest() {\n fetch(\"/api/data\")\n .then(res => res.json())\n .catch(err => console.error(err));\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should not flag promise with .catch(), got: {:?}",
findings
);
}
#[test]
fn repro_fp7_export_async_function_declaration() {
// Real FP: export async function putLocalEnvelopes(...) flagged as
// "async function called without error handling" because declaration
// skip missed `export async function` and the function name matched
// the async_funcs call check.
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"lib/db.ts",
"export async function putLocalEnvelopes(\n recordId: string,\n envelopes: Array<{ user_id: string; sealed_key: Uint8Array }>\n): Promise<void> {\n const db = await openDatabase();\n await db.put(\"envelopes\", { recordId, envelopes });\n}\n\nexport async function getLocalEnvelopes(\n recordId: string\n): Promise<Array<{ user_id: string; sealed_key: Uint8Array }>> {\n const db = await openDatabase();\n const entry = await db.get(\"envelopes\", recordId);\n return entry?.envelopes ?? [];\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"export async function declarations should NOT be flagged, got: {:?}",
findings
.iter()
.map(|f| format!("{} (line {})", f.title, f.line_start.unwrap_or(0)))
.collect::<Vec<_>>()
);
}
#[test]
fn repro_fp_string_literal_contents() {
// PR #87 FP: string literal containing "Promise" / "new Promise" / ".then("
// in a generated changelog file should NOT be flagged. The detector
// matches PROMISE_PATTERN against raw line text, so any data file with
// those substrings inside string literals trips it.
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"changelog.generated.ts",
"export const changelog = [\n {\n commitSha: '29a22a4',\n description: 'fix(unhandled_promise): refine new Promise detection .then(x) heuristics',\n },\n];\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"string literal containing 'new Promise' / '.then(' should NOT be flagged, got: {:?}",
findings
.iter()
.map(|f| format!("{} (line {})", f.title, f.line_start.unwrap_or(0)))
.collect::<Vec<_>>()
);
}
#[test]
fn repro_fp9_return_new_promise_in_sync_function() {
// Real FP: `return new Promise(...)` in a non-async function.
// Return exemption should still apply — caller handles the promise.
let store = GraphBuilder::new().freeze();
let detector = UnhandledPromiseDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![(
"lib/db.ts",
"function awaitDeleteDatabase(name: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const req = indexedDB.deleteDatabase(name);\n req.onsuccess = () => resolve();\n req.onerror = () => reject(req.error);\n req.onblocked = () => resolve();\n });\n}\n",
)],
);
let findings = detector.detect(&ctx).unwrap();
assert!(
findings.is_empty(),
"return new Promise() should NOT be flagged, got: {:?}",
findings
.iter()
.map(|f| format!("{} (line {})", f.title, f.line_start.unwrap_or(0)))
.collect::<Vec<_>>()
);
}
}