1use crate::args::{Cli, ContextPropagationMode};
18use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
19use crate::index_discovery::find_nearest_index;
20use crate::output::OutputStreams;
21use anyhow::{Context, Result};
22use serde::Serialize;
23use sqry_db::queries::context_propagation::{
24 ContextLeak, ContextLeakSet, ContextMode, ContextModeFilter, ContextPropagationKey,
25 ContextPropagationQuery, ContextScope,
26};
27use sqry_db::queries::dispatch::make_query_db_cold;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30
31const EXIT_NO_INDEX: i32 = 3;
34
35const EXIT_INVALID_ARG: i32 = 2;
38
39#[derive(Debug, Clone, Serialize)]
42pub struct ContextLeakHit {
43 pub caller: String,
46 pub callee: String,
48 pub mode: String,
50 pub caller_file: String,
52 pub call_site: CallSiteSpan,
54 pub caller_ctx_param: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct CallSiteSpan {
63 pub file: String,
64 pub start_line: u32,
65 pub start_column: u32,
66 pub end_line: u32,
67 pub end_column: u32,
68}
69
70pub fn run_context_propagation(
81 cli: &Cli,
82 path: Option<&str>,
83 scope: &str,
84 mode: ContextPropagationMode,
85 limit: usize,
86) -> Result<()> {
87 let mut streams = OutputStreams::new();
88
89 let search_path = path.map_or_else(
90 || std::env::current_dir().unwrap_or_default(),
91 PathBuf::from,
92 );
93
94 let Some(location) = find_nearest_index(&search_path) else {
95 let _ = streams.write_diagnostic(
96 "No .sqry-index found. Run 'sqry index' first to build the graph index.",
97 );
98 std::process::exit(EXIT_NO_INDEX);
99 };
100
101 let config = GraphLoadConfig::default();
102 let graph = load_unified_graph_for_cli(&location.index_root, &config, cli, no_op_reporter())
103 .context("failed to load graph; run 'sqry index' to rebuild")?;
104
105 let snapshot = Arc::new(graph.snapshot());
106
107 let scope_value = match parse_scope(scope, &location.index_root, &snapshot) {
108 Ok(parsed) => parsed,
109 Err(ScopeError::InvalidSyntax(msg)) => {
110 let _ = streams.write_diagnostic(&format!("invalid --scope value: {msg}"));
111 std::process::exit(EXIT_INVALID_ARG);
112 }
113 Err(ScopeError::FileNotInIndex(p)) => {
114 return emit_results(&mut streams, cli.json, &[], &p, mode);
118 }
119 };
120
121 let key = ContextPropagationKey {
122 scope: scope_value,
123 mode: mode.into(),
124 };
125 let db = make_query_db_cold(Arc::clone(&snapshot), &location.index_root);
126 let leak_set: Arc<ContextLeakSet> = db.get::<ContextPropagationQuery>(&key);
127
128 let hits: Vec<ContextLeakHit> = leak_set
129 .leaks
130 .iter()
131 .take(limit)
132 .map(|leak| leak_to_hit(&snapshot, leak))
133 .collect();
134
135 emit_results(&mut streams, cli.json, &hits, scope, mode)
136}
137
138enum ScopeError {
139 InvalidSyntax(String),
140 FileNotInIndex(String),
141}
142
143fn parse_scope(
144 raw: &str,
145 workspace_root: &Path,
146 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
147) -> std::result::Result<ContextScope, ScopeError> {
148 if raw == "global" {
149 return Ok(ContextScope::Global);
150 }
151 if let Some(rest) = raw.strip_prefix("file:") {
152 if rest.is_empty() {
153 return Err(ScopeError::InvalidSyntax(
154 "file: prefix requires a path".to_string(),
155 ));
156 }
157 let candidate = PathBuf::from(rest);
158 let absolute = if candidate.is_absolute() {
159 candidate
160 } else {
161 workspace_root.join(&candidate)
162 };
163 let canonical = absolute.canonicalize().unwrap_or(absolute);
168 let resolved = snapshot.files().iter().find_map(|(fid, registered)| {
169 let r: &std::path::Path = registered.as_ref();
170 let r_canon = r.canonicalize().unwrap_or_else(|_| r.to_path_buf());
171 if r == canonical.as_path() || r_canon == canonical {
172 Some(fid)
173 } else {
174 None
175 }
176 });
177 return match resolved {
178 Some(fid) => Ok(ContextScope::File(fid)),
179 None => Err(ScopeError::FileNotInIndex(rest.to_string())),
180 };
181 }
182 Err(ScopeError::InvalidSyntax(format!(
183 "expected 'global' or 'file:<path>', got '{raw}'"
184 )))
185}
186
187fn leak_to_hit(
188 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
189 leak: &ContextLeak,
190) -> ContextLeakHit {
191 let caller = node_label(snapshot, leak.caller);
192 let callee = node_label(snapshot, leak.callee);
193 let caller_file = snapshot
194 .get_node(leak.caller)
195 .and_then(|entry| snapshot.files().resolve(entry.file))
196 .map(|p| p.display().to_string())
197 .unwrap_or_default();
198 let caller_ctx_param = leak
199 .caller_ctx_param
200 .map(|nid| node_label(snapshot, nid))
201 .filter(|s| !s.is_empty());
202 ContextLeakHit {
203 caller,
204 callee,
205 mode: concrete_mode_label(leak.mode),
206 caller_file: caller_file.clone(),
207 call_site: CallSiteSpan {
208 file: caller_file,
209 start_line: span_line_to_u32(leak.call_span.start.line) + 1,
212 start_column: usize_to_u32(leak.call_span.start.column),
213 end_line: span_line_to_u32(leak.call_span.end.line) + 1,
214 end_column: usize_to_u32(leak.call_span.end.column),
215 },
216 caller_ctx_param,
217 }
218}
219
220fn span_line_to_u32(v: usize) -> u32 {
221 u32::try_from(v).unwrap_or(u32::MAX - 1)
222}
223
224fn usize_to_u32(v: usize) -> u32 {
225 u32::try_from(v).unwrap_or(u32::MAX)
226}
227
228fn node_label(
229 snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
230 node: sqry_core::graph::unified::node::NodeId,
231) -> String {
232 let Some(entry) = snapshot.get_node(node) else {
233 return String::new();
234 };
235 if let Some(sid) = entry.qualified_name
236 && let Some(qualified) = snapshot.strings().resolve(sid)
237 {
238 return qualified.to_string();
239 }
240 snapshot
241 .strings()
242 .resolve(entry.name)
243 .map(|s| s.to_string())
244 .unwrap_or_default()
245}
246
247fn concrete_mode_label(mode: ContextMode) -> String {
248 match mode {
249 ContextMode::BreakSite => "break_site",
250 ContextMode::UnthreadedGoroutine => "unthreaded_goroutine",
251 ContextMode::HttpHandlerLeak => "http_handler_leak",
252 }
253 .to_string()
254}
255
256fn mode_label(mode: ContextPropagationMode) -> &'static str {
257 match mode {
258 ContextPropagationMode::All => "all",
259 ContextPropagationMode::BreakSite => "break_site",
260 ContextPropagationMode::UnthreadedGoroutine => "unthreaded_goroutine",
261 ContextPropagationMode::HttpHandlerLeak => "http_handler_leak",
262 }
263}
264
265fn emit_results(
266 streams: &mut OutputStreams,
267 json: bool,
268 hits: &[ContextLeakHit],
269 scope: &str,
270 mode: ContextPropagationMode,
271) -> Result<()> {
272 if json {
273 let payload = serde_json::to_string_pretty(&hits)
274 .context("serializing context-propagation hits as JSON")?;
275 streams.write_result(&payload)?;
276 } else if hits.is_empty() {
277 streams.write_result(&format!(
278 "no context-propagation leaks (scope={scope}, mode={mode})",
279 mode = mode_label(mode),
280 ))?;
281 } else {
282 for hit in hits {
283 streams.write_result(&format!(
284 "{caller} -> {callee} [{mode}]",
285 caller = hit.caller,
286 callee = hit.callee,
287 mode = hit.mode,
288 ))?;
289 streams.write_result(&format!(
290 " call site: {file}:{line}:{col}",
291 file = hit.call_site.file,
292 line = hit.call_site.start_line,
293 col = hit.call_site.start_column,
294 ))?;
295 if let Some(param) = &hit.caller_ctx_param {
296 streams.write_result(&format!(" caller param: {param}"))?;
297 }
298 }
299 }
300 let _ = scope;
301 let _ = ContextModeFilter::from(mode); Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn mode_value_enum_round_trips_to_filter() {
311 for (cli_mode, expected) in [
313 (ContextPropagationMode::All, ContextModeFilter::All),
314 (
315 ContextPropagationMode::BreakSite,
316 ContextModeFilter::BreakSite,
317 ),
318 (
319 ContextPropagationMode::UnthreadedGoroutine,
320 ContextModeFilter::UnthreadedGoroutine,
321 ),
322 (
323 ContextPropagationMode::HttpHandlerLeak,
324 ContextModeFilter::HttpHandlerLeak,
325 ),
326 ] {
327 let got: ContextModeFilter = cli_mode.into();
328 assert_eq!(got, expected, "{cli_mode:?} maps incorrectly");
329 }
330 }
331
332 #[test]
333 fn span_helpers_saturate_on_overflow() {
334 assert_eq!(span_line_to_u32(0), 0);
339 assert_eq!(span_line_to_u32(42), 42);
340 assert_eq!(span_line_to_u32(usize::MAX), u32::MAX - 1);
341 assert_eq!(usize_to_u32(0), 0);
342 assert_eq!(usize_to_u32(7), 7);
343 assert_eq!(usize_to_u32(usize::MAX), u32::MAX);
344 }
345
346 #[test]
347 fn mode_label_matches_documented_spelling() {
348 assert_eq!(mode_label(ContextPropagationMode::All), "all");
352 assert_eq!(mode_label(ContextPropagationMode::BreakSite), "break_site");
353 assert_eq!(
354 mode_label(ContextPropagationMode::UnthreadedGoroutine),
355 "unthreaded_goroutine"
356 );
357 assert_eq!(
358 mode_label(ContextPropagationMode::HttpHandlerLeak),
359 "http_handler_leak"
360 );
361 }
362}