use crate::args::{Cli, ContextPropagationMode};
use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
use crate::index_discovery::find_nearest_index;
use crate::output::OutputStreams;
use anyhow::{Context, Result};
use serde::Serialize;
use sqry_db::queries::context_propagation::{
ContextLeak, ContextLeakSet, ContextMode, ContextModeFilter, ContextPropagationKey,
ContextPropagationQuery, ContextScope,
};
use sqry_db::queries::dispatch::make_query_db_cold;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const EXIT_NO_INDEX: i32 = 3;
const EXIT_INVALID_ARG: i32 = 2;
#[derive(Debug, Clone, Serialize)]
pub struct ContextLeakHit {
pub caller: String,
pub callee: String,
pub mode: String,
pub caller_file: String,
pub call_site: CallSiteSpan,
pub caller_ctx_param: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CallSiteSpan {
pub file: String,
pub start_line: u32,
pub start_column: u32,
pub end_line: u32,
pub end_column: u32,
}
pub fn run_context_propagation(
cli: &Cli,
path: Option<&str>,
scope: &str,
mode: ContextPropagationMode,
limit: usize,
) -> Result<()> {
let mut streams = OutputStreams::new();
let search_path = path.map_or_else(
|| std::env::current_dir().unwrap_or_default(),
PathBuf::from,
);
let Some(location) = find_nearest_index(&search_path) else {
let _ = streams.write_diagnostic(
"No .sqry-index found. Run 'sqry index' first to build the graph index.",
);
std::process::exit(EXIT_NO_INDEX);
};
let config = GraphLoadConfig::default();
let graph = load_unified_graph_for_cli(&location.index_root, &config, cli, no_op_reporter())
.context("failed to load graph; run 'sqry index' to rebuild")?;
let snapshot = Arc::new(graph.snapshot());
let scope_value = match parse_scope(scope, &location.index_root, &snapshot) {
Ok(parsed) => parsed,
Err(ScopeError::InvalidSyntax(msg)) => {
let _ = streams.write_diagnostic(&format!("invalid --scope value: {msg}"));
std::process::exit(EXIT_INVALID_ARG);
}
Err(ScopeError::FileNotInIndex(p)) => {
return emit_results(&mut streams, cli.json, &[], &p, mode);
}
};
let key = ContextPropagationKey {
scope: scope_value,
mode: mode.into(),
};
let db = make_query_db_cold(Arc::clone(&snapshot), &location.index_root);
let leak_set: Arc<ContextLeakSet> = db.get::<ContextPropagationQuery>(&key);
let hits: Vec<ContextLeakHit> = leak_set
.leaks
.iter()
.take(limit)
.map(|leak| leak_to_hit(&snapshot, leak))
.collect();
emit_results(&mut streams, cli.json, &hits, scope, mode)
}
enum ScopeError {
InvalidSyntax(String),
FileNotInIndex(String),
}
fn parse_scope(
raw: &str,
workspace_root: &Path,
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
) -> std::result::Result<ContextScope, ScopeError> {
if raw == "global" {
return Ok(ContextScope::Global);
}
if let Some(rest) = raw.strip_prefix("file:") {
if rest.is_empty() {
return Err(ScopeError::InvalidSyntax(
"file: prefix requires a path".to_string(),
));
}
let candidate = PathBuf::from(rest);
let absolute = if candidate.is_absolute() {
candidate
} else {
workspace_root.join(&candidate)
};
let canonical = absolute.canonicalize().unwrap_or(absolute);
let resolved = snapshot.files().iter().find_map(|(fid, registered)| {
let r: &std::path::Path = registered.as_ref();
let r_canon = r.canonicalize().unwrap_or_else(|_| r.to_path_buf());
if r == canonical.as_path() || r_canon == canonical {
Some(fid)
} else {
None
}
});
return match resolved {
Some(fid) => Ok(ContextScope::File(fid)),
None => Err(ScopeError::FileNotInIndex(rest.to_string())),
};
}
Err(ScopeError::InvalidSyntax(format!(
"expected 'global' or 'file:<path>', got '{raw}'"
)))
}
fn leak_to_hit(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
leak: &ContextLeak,
) -> ContextLeakHit {
let caller = node_label(snapshot, leak.caller);
let callee = node_label(snapshot, leak.callee);
let caller_file = snapshot
.get_node(leak.caller)
.and_then(|entry| snapshot.files().resolve(entry.file))
.map(|p| p.display().to_string())
.unwrap_or_default();
let caller_ctx_param = leak
.caller_ctx_param
.map(|nid| node_label(snapshot, nid))
.filter(|s| !s.is_empty());
ContextLeakHit {
caller,
callee,
mode: concrete_mode_label(leak.mode),
caller_file: caller_file.clone(),
call_site: CallSiteSpan {
file: caller_file,
start_line: span_line_to_u32(leak.call_span.start.line) + 1,
start_column: usize_to_u32(leak.call_span.start.column),
end_line: span_line_to_u32(leak.call_span.end.line) + 1,
end_column: usize_to_u32(leak.call_span.end.column),
},
caller_ctx_param,
}
}
fn span_line_to_u32(v: usize) -> u32 {
u32::try_from(v).unwrap_or(u32::MAX - 1)
}
fn usize_to_u32(v: usize) -> u32 {
u32::try_from(v).unwrap_or(u32::MAX)
}
fn node_label(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
node: sqry_core::graph::unified::node::NodeId,
) -> String {
let Some(entry) = snapshot.get_node(node) else {
return String::new();
};
if let Some(sid) = entry.qualified_name
&& let Some(qualified) = snapshot.strings().resolve(sid)
{
return qualified.to_string();
}
snapshot
.strings()
.resolve(entry.name)
.map(|s| s.to_string())
.unwrap_or_default()
}
fn concrete_mode_label(mode: ContextMode) -> String {
match mode {
ContextMode::BreakSite => "break_site",
ContextMode::UnthreadedGoroutine => "unthreaded_goroutine",
ContextMode::HttpHandlerLeak => "http_handler_leak",
}
.to_string()
}
fn mode_label(mode: ContextPropagationMode) -> &'static str {
match mode {
ContextPropagationMode::All => "all",
ContextPropagationMode::BreakSite => "break_site",
ContextPropagationMode::UnthreadedGoroutine => "unthreaded_goroutine",
ContextPropagationMode::HttpHandlerLeak => "http_handler_leak",
}
}
fn emit_results(
streams: &mut OutputStreams,
json: bool,
hits: &[ContextLeakHit],
scope: &str,
mode: ContextPropagationMode,
) -> Result<()> {
if json {
let payload = serde_json::to_string_pretty(&hits)
.context("serializing context-propagation hits as JSON")?;
streams.write_result(&payload)?;
} else if hits.is_empty() {
streams.write_result(&format!(
"no context-propagation leaks (scope={scope}, mode={mode})",
mode = mode_label(mode),
))?;
} else {
for hit in hits {
streams.write_result(&format!(
"{caller} -> {callee} [{mode}]",
caller = hit.caller,
callee = hit.callee,
mode = hit.mode,
))?;
streams.write_result(&format!(
" call site: {file}:{line}:{col}",
file = hit.call_site.file,
line = hit.call_site.start_line,
col = hit.call_site.start_column,
))?;
if let Some(param) = &hit.caller_ctx_param {
streams.write_result(&format!(" caller param: {param}"))?;
}
}
}
let _ = scope;
let _ = ContextModeFilter::from(mode); Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_value_enum_round_trips_to_filter() {
for (cli_mode, expected) in [
(ContextPropagationMode::All, ContextModeFilter::All),
(
ContextPropagationMode::BreakSite,
ContextModeFilter::BreakSite,
),
(
ContextPropagationMode::UnthreadedGoroutine,
ContextModeFilter::UnthreadedGoroutine,
),
(
ContextPropagationMode::HttpHandlerLeak,
ContextModeFilter::HttpHandlerLeak,
),
] {
let got: ContextModeFilter = cli_mode.into();
assert_eq!(got, expected, "{cli_mode:?} maps incorrectly");
}
}
#[test]
fn span_helpers_saturate_on_overflow() {
assert_eq!(span_line_to_u32(0), 0);
assert_eq!(span_line_to_u32(42), 42);
assert_eq!(span_line_to_u32(usize::MAX), u32::MAX - 1);
assert_eq!(usize_to_u32(0), 0);
assert_eq!(usize_to_u32(7), 7);
assert_eq!(usize_to_u32(usize::MAX), u32::MAX);
}
#[test]
fn mode_label_matches_documented_spelling() {
assert_eq!(mode_label(ContextPropagationMode::All), "all");
assert_eq!(mode_label(ContextPropagationMode::BreakSite), "break_site");
assert_eq!(
mode_label(ContextPropagationMode::UnthreadedGoroutine),
"unthreaded_goroutine"
);
assert_eq!(
mode_label(ContextPropagationMode::HttpHandlerLeak),
"http_handler_leak"
);
}
}