Skip to main content

sqry_cli/commands/
context_propagation.rs

1//! `sqry context-propagation` — Go context-propagation analysis CLI (T3.7).
2//!
3//! Surfaces `context.Context` plumbing breaks classified by
4//! `sqry_db::queries::context_propagation::ContextPropagationQuery`:
5//!
6//! - `BreakSite` — sync caller has a `context.Context` parameter, callee
7//!   accepts `context.Context`, but the call passes zero context args.
8//! - `UnthreadedGoroutine` — `go callee(...)` with a ctx-accepting callee.
9//! - `HttpHandlerLeak` — caller is `func(http.ResponseWriter, *http.Request)`
10//!   and the callee is ctx-accepting, but the call drops `r.Context()`.
11//!
12//! This CLI mirrors the MCP `context_propagation` tool from Cluster G; both
13//! route through `sqry_db::queries::dispatch::make_query_db_cold` so the
14//! per-publish cache contract from CLAUDE.md §"Persistence (V10)" is
15//! preserved.
16
17use 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
31/// Exit code returned when no `.sqry-index` is discoverable. Documented in
32/// `CLI_INTEGRATION.md` §1.3 as part of the public surface contract.
33const EXIT_NO_INDEX: i32 = 3;
34
35/// Exit code for invalid `--scope` parsing (clap handles invalid `--mode`
36/// at the parser layer via `ValueEnum`).
37const EXIT_INVALID_ARG: i32 = 2;
38
39/// One JSON row produced by `--output json` (default `cli.json`). Matches
40/// the schema enumerated in `CLI_INTEGRATION.md` §1.3.
41#[derive(Debug, Clone, Serialize)]
42pub struct ContextLeakHit {
43    /// `caller_node_id` qualified or short name (matches the `caller`
44    /// field expected by JSON consumers).
45    pub caller: String,
46    /// `callee_node_id` qualified or short name.
47    pub callee: String,
48    /// `break_site` | `unthreaded_goroutine` | `http_handler_leak`.
49    pub mode: String,
50    /// File holding the caller function.
51    pub caller_file: String,
52    /// Call-site span coordinates (1-based lines, byte columns).
53    pub call_site: CallSiteSpan,
54    /// Caller's `ctx` parameter name when present; `None` for
55    /// HTTP-handler + unthreaded-goroutine leak modes where the caller
56    /// does not own a `context.Context` parameter directly.
57    pub caller_ctx_param: Option<String>,
58}
59
60/// Call-site coordinates in 1-based lines + 0-based byte columns.
61#[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
70/// Entry point for the `sqry context-propagation` subcommand.
71///
72/// # Errors
73///
74/// Returns `Err` if the graph fails to load (corrupt snapshot, unreadable
75/// `.sqry-index`, plugin-incompatible). `--scope file:<bad-path>` and the
76/// missing-index case do NOT return `Err`; they call `std::process::exit`
77/// with the documented exit code so the CLI honours the public exit-code
78/// contract in `CLI_INTEGRATION.md` §1.3 before `main.rs`'s anyhow→exit
79/// mapping can re-classify the error.
80pub 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            // Matches the MCP tool's short-circuit: non-resolvable file
115            // returns an empty leak set, exit 0. Print zero leaks in the
116            // appropriate output mode and return.
117            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        // Walk the snapshot's FileRegistry and look for a byte-for-byte
164        // match on the registered path. We don't canonicalise — the
165        // registry's path is what the index recorded at build time, and
166        // re-canonicalising can drift if the workspace was moved.
167        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            // tree-sitter Point::line is 0-based. Surface 1-based for
210            // IDE-friendly jump-to.
211            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); // assertion-by-construction
302    Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn mode_value_enum_round_trips_to_filter() {
311        // T3.7 Cluster G-ext smoke: every CLI mode maps to its db filter.
312        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        // tree-sitter Point fields are usize; sqry exposes 1-based u32
335        // lines for IDE consumers. Asserting saturation behaviour pins
336        // the contract that wildly large line numbers (from a corrupt
337        // tree, etc.) downgrade to `u32::MAX - 1` rather than overflow.
338        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        // Pins the public CLI label spellings used in text-mode output
349        // and in the diagnostic emitted by `emit_results` when zero leaks
350        // surface. Renaming any of these is a CLI_INTEGRATION.md delta.
351        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}