Skip to main content

sqry_db/queries/
unused.rs

1//! Unused / dead-code detection derived queries.
2//!
3//! Migrates `sqry-core::query::executor::graph_unused::{find_unused_nodes,
4//! compute_reachable_set_graph, is_node_unused}` into cache-aware queries
5//! that can be shared between the `sqry-db` planner, the MCP handlers (once
6//! DB15+ wires them), and future analysis callers.
7//!
8//! # Pipeline
9//!
10//! ```text
11//!   EntryPointsQuery   ──▶  (Arc<HashSet<NodeId>>)
12//!          │
13//!          ▼
14//!   ReachableFromEntryPointsQuery  ──▶  (Arc<HashSet<NodeId>>)
15//!          │
16//!          ▼
17//!   UnusedQuery / IsNodeUnusedQuery
18//! ```
19//!
20//! # Entry-point classification
21//!
22//! A node is an entry point when *any* of the following is true:
23//! - Its visibility is `public` / `pub`
24//! - Its short name is `main`, begins with `test_`, or ends with `_test`
25//! - Its [`NodeKind`] is [`NodeKind::Test`] or [`NodeKind::Export`]
26//!
27//! This mirrors [`sqry_core::query::executor::graph_unused`]'s
28//! `is_entry_point_node` exactly so migration preserves the legacy set of
29//! unused-free symbols.
30//!
31//! # Reachability edges
32//!
33//! The traversal follows `Calls`, `References`, `Imports`, `Inherits`,
34//! `Implements`, and `TypeOf` edges — identical to the legacy
35//! `is_reachability_edge` predicate.
36
37use std::collections::HashSet;
38use std::sync::Arc;
39
40use sqry_core::graph::unified::concurrent::GraphSnapshot;
41use sqry_core::graph::unified::edge::kind::EdgeKind;
42use sqry_core::graph::unified::node::id::NodeId;
43use sqry_core::graph::unified::node::kind::NodeKind;
44use sqry_core::graph::unified::storage::arena::NodeEntry;
45use sqry_core::query::UnusedScope;
46
47use crate::QueryDb;
48use crate::dependency::record_file_dep;
49use crate::query::DerivedQuery;
50
51// ============================================================================
52// EntryPointsQuery
53// ============================================================================
54
55/// Set of node IDs that qualify as reachability roots.
56///
57/// # Invalidation
58///
59/// `TRACKS_METADATA_REVISION = true`: visibility, kind, and node name are
60/// all stored on `NodeEntry`, which mutates with metadata revisions rather
61/// than edge revisions. We also track edges at `TRACKS_EDGE_REVISION = true`
62/// because edge additions can introduce new `Test`/`Export` nodes.
63pub struct EntryPointsQuery;
64
65impl DerivedQuery for EntryPointsQuery {
66    type Key = ();
67    type Value = Arc<HashSet<NodeId>>;
68    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::ENTRY_POINTS;
69    const TRACKS_EDGE_REVISION: bool = true;
70    const TRACKS_METADATA_REVISION: bool = true;
71
72    fn execute(_key: &(), _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<HashSet<NodeId>> {
73        for (fid, _) in snapshot.file_segments().iter() {
74            record_file_dep(fid);
75        }
76        let mut entry_points = HashSet::new();
77        for (node_id, entry) in snapshot.nodes().iter() {
78            // Gate 0d iter-2 fix: skip unified losers from
79            // `EntryPointsQuery`. Losers are inert duplicates; their
80            // winner carries the real visibility/name. See
81            // `NodeEntry::is_unified_loser`.
82            if entry.is_unified_loser() {
83                continue;
84            }
85            if is_entry_point(snapshot, entry) {
86                entry_points.insert(node_id);
87            }
88        }
89        Arc::new(entry_points)
90    }
91}
92
93fn is_entry_point(snapshot: &GraphSnapshot, entry: &NodeEntry) -> bool {
94    let is_public = entry
95        .visibility
96        .and_then(|id| snapshot.strings().resolve(id))
97        .is_some_and(|v| {
98            let s = v.as_ref();
99            s == "public" || s == "pub"
100        });
101    let is_main_or_test = snapshot.strings().resolve(entry.name).is_some_and(|name| {
102        let n = name.as_ref();
103        n == "main" || n.starts_with("test_") || n.ends_with("_test")
104    });
105    let is_export = matches!(entry.kind, NodeKind::Export);
106    let is_test_node = matches!(entry.kind, NodeKind::Test);
107    is_public || is_main_or_test || is_export || is_test_node
108}
109
110// ============================================================================
111// ReachableFromEntryPointsQuery
112// ============================================================================
113
114/// Set of nodes reachable from the entry-point set by following reachability
115/// edges (`Calls`, `References`, `Imports`, `Inherits`, `Implements`,
116/// `TypeOf`).
117///
118/// `TRACKS_EDGE_REVISION = true`: any edge change redraws the reachable set.
119pub struct ReachableFromEntryPointsQuery;
120
121impl DerivedQuery for ReachableFromEntryPointsQuery {
122    type Key = ();
123    type Value = Arc<HashSet<NodeId>>;
124    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::REACHABLE_FROM_ENTRY_POINTS;
125    const TRACKS_EDGE_REVISION: bool = true;
126    const TRACKS_METADATA_REVISION: bool = true;
127
128    fn execute(_key: &(), db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<HashSet<NodeId>> {
129        for (fid, _) in snapshot.file_segments().iter() {
130            record_file_dep(fid);
131        }
132        let entry_points = db.get::<EntryPointsQuery>(&());
133        let mut reachable: HashSet<NodeId> = entry_points.as_ref().clone();
134        let mut worklist: Vec<NodeId> = reachable.iter().copied().collect();
135        while let Some(node_id) = worklist.pop() {
136            for edge in &snapshot.edges().edges_from(node_id) {
137                if is_reachability_edge(&edge.kind) && reachable.insert(edge.target) {
138                    worklist.push(edge.target);
139                }
140            }
141        }
142        Arc::new(reachable)
143    }
144}
145
146fn is_reachability_edge(kind: &EdgeKind) -> bool {
147    matches!(
148        kind,
149        EdgeKind::Calls { .. }
150            | EdgeKind::References
151            | EdgeKind::Imports { .. }
152            | EdgeKind::Inherits
153            | EdgeKind::Implements
154            | EdgeKind::TypeOf { .. }
155    )
156}
157
158// ============================================================================
159// UnusedQuery
160// ============================================================================
161
162// PN3 cold-start persistence: UnusedKey and IsNodeUnusedKey are serialized via
163// postcard at cache-insert time. UnusedScope gains Serialize/Deserialize in
164// sqry-core/src/query/unused_config.rs (PN3 SERDE_DERIVES). NodeId already
165// derives Serialize/Deserialize from sqry-core.
166
167/// Type alias for the value produced by [`UnusedQuery`].
168/// `Arc` is serde-transparent when the workspace `serde` `rc` feature is enabled.
169pub type UnusedValue = Arc<Vec<NodeId>>;
170
171/// Type alias for the value produced by [`IsNodeUnusedQuery`].
172pub type IsNodeUnusedValue = bool;
173
174/// Cache key for [`UnusedQuery`].
175#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
176pub struct UnusedKey {
177    /// Scope filter (All / Public / Private / Function / Struct).
178    pub scope: UnusedScope,
179    /// Maximum number of unused nodes to return.
180    pub max_results: usize,
181}
182
183/// Returns the sorted list of unused nodes matching the scope filter.
184///
185/// A node is unused when:
186/// 1. It matches the [`UnusedKey::scope`] filter.
187/// 2. It is not an entry point (`main`, test, export, or `NodeKind::Test`).
188/// 3. It is not reachable from any entry point.
189pub struct UnusedQuery;
190
191impl DerivedQuery for UnusedQuery {
192    type Key = UnusedKey;
193    type Value = Arc<Vec<NodeId>>;
194    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::UNUSED;
195    const TRACKS_EDGE_REVISION: bool = true;
196    const TRACKS_METADATA_REVISION: bool = true;
197
198    fn execute(key: &UnusedKey, db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
199        for (fid, _) in snapshot.file_segments().iter() {
200            record_file_dep(fid);
201        }
202        let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
203        let mut unused: Vec<NodeId> = Vec::new();
204        for (node_id, entry) in snapshot.nodes().iter() {
205            if unused.len() >= key.max_results {
206                break;
207            }
208            // Gate 0d iter-2 fix: never report a unified loser as
209            // "unused". See `NodeEntry::is_unified_loser`.
210            if entry.is_unified_loser() {
211                continue;
212            }
213            if !scope_matches(entry, snapshot, key.scope) {
214                continue;
215            }
216            if is_always_entry_point(snapshot, entry) {
217                continue;
218            }
219            if !reachable.contains(&node_id) {
220                unused.push(node_id);
221            }
222        }
223        unused.sort_unstable_by_key(|id| (id.index(), id.generation()));
224        Arc::new(unused)
225    }
226}
227
228fn scope_matches(entry: &NodeEntry, snapshot: &GraphSnapshot, scope: UnusedScope) -> bool {
229    match scope {
230        UnusedScope::All => true,
231        UnusedScope::Public => entry
232            .visibility
233            .and_then(|id| snapshot.strings().resolve(id))
234            .is_some_and(|v| {
235                let s = v.as_ref();
236                s == "public" || s == "pub"
237            }),
238        UnusedScope::Private => {
239            let vis = entry
240                .visibility
241                .and_then(|id| snapshot.strings().resolve(id));
242            vis.is_none()
243                || vis.is_some_and(|v| {
244                    let s = v.as_ref();
245                    s != "public" && s != "pub"
246                })
247        }
248        UnusedScope::Function => matches!(entry.kind, NodeKind::Function | NodeKind::Method),
249        UnusedScope::Struct => matches!(entry.kind, NodeKind::Struct | NodeKind::Class),
250    }
251}
252
253/// Entry points that should never be marked unused regardless of scope.
254/// Mirrors the secondary entry-point check in
255/// [`sqry_core::query::executor::graph_unused::is_node_unused`].
256fn is_always_entry_point(snapshot: &GraphSnapshot, entry: &NodeEntry) -> bool {
257    let is_main_or_test = snapshot.strings().resolve(entry.name).is_some_and(|name| {
258        let n = name.as_ref();
259        n == "main" || n.starts_with("test_") || n.ends_with("_test")
260    });
261    let is_export = matches!(entry.kind, NodeKind::Export);
262    let is_test_node = matches!(entry.kind, NodeKind::Test);
263    is_main_or_test || is_export || is_test_node
264}
265
266// ============================================================================
267// IsNodeUnusedQuery
268// ============================================================================
269
270/// Cache key for [`IsNodeUnusedQuery`].
271#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
272pub struct IsNodeUnusedKey {
273    /// The node to check.
274    pub node_id: NodeId,
275    /// Scope filter to apply.
276    pub scope: UnusedScope,
277}
278
279/// Returns `true` when the given node is unused per the rules in
280/// [`UnusedQuery`]'s documentation. Used by the MCP `find_unused` single-node
281/// tool and the CLI `sqry unused` check mode.
282pub struct IsNodeUnusedQuery;
283
284impl DerivedQuery for IsNodeUnusedQuery {
285    type Key = IsNodeUnusedKey;
286    type Value = bool;
287    const QUERY_TYPE_ID: u32 = crate::queries::type_ids::IS_NODE_UNUSED;
288    const TRACKS_EDGE_REVISION: bool = true;
289    const TRACKS_METADATA_REVISION: bool = true;
290
291    fn execute(key: &IsNodeUnusedKey, db: &QueryDb, snapshot: &GraphSnapshot) -> bool {
292        let Some(entry) = snapshot.nodes().get(key.node_id) else {
293            return false;
294        };
295        record_file_dep(entry.file);
296        if !scope_matches(entry, snapshot, key.scope) {
297            return false;
298        }
299        if is_always_entry_point(snapshot, entry) {
300            return false;
301        }
302        let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
303        !reachable.contains(&key.node_id)
304    }
305}
306
307// ============================================================================
308// PN3 serde roundtrip tests
309// ============================================================================
310
311#[cfg(test)]
312mod serde_roundtrip {
313    use super::*;
314    use postcard::{from_bytes, to_allocvec};
315    use sqry_core::graph::unified::node::id::NodeId;
316    use sqry_core::query::UnusedScope;
317
318    #[test]
319    fn unused_key_roundtrip() {
320        let original = UnusedKey {
321            scope: UnusedScope::Public,
322            max_results: 250,
323        };
324        let bytes = to_allocvec(&original).expect("serialize failed");
325        let decoded: UnusedKey = from_bytes(&bytes).expect("deserialize failed");
326        assert_eq!(decoded, original);
327    }
328
329    #[test]
330    fn unused_key_all_scopes_roundtrip() {
331        for scope in [
332            UnusedScope::All,
333            UnusedScope::Public,
334            UnusedScope::Private,
335            UnusedScope::Function,
336            UnusedScope::Struct,
337        ] {
338            let original = UnusedKey {
339                scope,
340                max_results: 100,
341            };
342            let bytes = to_allocvec(&original).expect("serialize failed");
343            let decoded: UnusedKey = from_bytes(&bytes).expect("deserialize failed");
344            assert_eq!(decoded, original, "scope={scope:?}");
345        }
346    }
347
348    #[test]
349    fn is_node_unused_key_roundtrip() {
350        let original = IsNodeUnusedKey {
351            node_id: NodeId::new(99, 3),
352            scope: UnusedScope::Function,
353        };
354        let bytes = to_allocvec(&original).expect("serialize failed");
355        let decoded: IsNodeUnusedKey = from_bytes(&bytes).expect("deserialize failed");
356        assert_eq!(decoded, original);
357    }
358
359    #[test]
360    fn unused_value_roundtrip() {
361        // UnusedValue = Arc<Vec<NodeId>>
362        let original: UnusedValue = Arc::new(vec![
363            NodeId::new(1, 1),
364            NodeId::new(2, 1),
365            NodeId::new(3, 1),
366        ]);
367        let bytes = to_allocvec(&original).expect("serialize failed");
368        let decoded: UnusedValue = from_bytes(&bytes).expect("deserialize failed");
369        assert_eq!(decoded.as_ref(), original.as_ref());
370    }
371
372    #[test]
373    fn is_node_unused_value_roundtrip() {
374        // IsNodeUnusedValue = bool
375        for val in [true, false] {
376            let bytes = to_allocvec(&val).expect("serialize failed");
377            let decoded: IsNodeUnusedValue = from_bytes(&bytes).expect("deserialize failed");
378            assert_eq!(decoded, val);
379        }
380    }
381}
382
383// ============================================================================
384// Tests
385// ============================================================================
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::QueryDbConfig;
391    use sqry_core::graph::unified::concurrent::CodeGraph;
392    use std::path::Path;
393
394    fn alloc_fn_with_vis(graph: &mut CodeGraph, name: &str, vis: Option<&str>) -> NodeId {
395        let file = graph.files_mut().register(Path::new("main.rs")).unwrap();
396        let name_id = graph.strings_mut().intern(name).unwrap();
397        let mut entry =
398            NodeEntry::new(NodeKind::Function, name_id, file).with_qualified_name(name_id);
399        if let Some(v) = vis {
400            let vid = graph.strings_mut().intern(v).unwrap();
401            entry = entry.with_visibility(vid);
402        }
403        graph.nodes_mut().alloc(entry).unwrap()
404    }
405
406    fn add_call(graph: &mut CodeGraph, src: NodeId, tgt: NodeId) {
407        let file = graph.nodes().get(src).unwrap().file;
408        graph.edges_mut().add_edge(
409            src,
410            tgt,
411            EdgeKind::Calls {
412                argument_count: 0,
413                is_async: false,
414            },
415            file,
416        );
417    }
418
419    fn build_db(graph: CodeGraph) -> QueryDb {
420        let snapshot = Arc::new(graph.snapshot());
421        let mut db = QueryDb::new(snapshot, QueryDbConfig::default());
422        db.register::<EntryPointsQuery>();
423        db.register::<ReachableFromEntryPointsQuery>();
424        db.register::<UnusedQuery>();
425        db.register::<IsNodeUnusedQuery>();
426        db
427    }
428
429    #[test]
430    fn entry_points_include_main_test_public_export() {
431        let mut graph = CodeGraph::new();
432        let main = alloc_fn_with_vis(&mut graph, "main", None);
433        let pub_fn = alloc_fn_with_vis(&mut graph, "exported_api", Some("public"));
434        let test_fn = alloc_fn_with_vis(&mut graph, "test_thing", None);
435        let private_fn = alloc_fn_with_vis(&mut graph, "helper", None);
436
437        let db = build_db(graph);
438        let entry_points = db.get::<EntryPointsQuery>(&());
439        assert!(entry_points.contains(&main));
440        assert!(entry_points.contains(&pub_fn));
441        assert!(entry_points.contains(&test_fn));
442        assert!(!entry_points.contains(&private_fn));
443    }
444
445    #[test]
446    fn unused_query_reports_unreachable_private_symbols() {
447        let mut graph = CodeGraph::new();
448        let main = alloc_fn_with_vis(&mut graph, "main", None);
449        let used = alloc_fn_with_vis(&mut graph, "used_helper", None);
450        let unused = alloc_fn_with_vis(&mut graph, "unused_helper", None);
451        add_call(&mut graph, main, used);
452
453        let db = build_db(graph);
454        let key = UnusedKey {
455            scope: UnusedScope::All,
456            max_results: 100,
457        };
458        let result = db.get::<UnusedQuery>(&key);
459        assert_eq!(result.as_ref(), &vec![unused]);
460    }
461
462    #[test]
463    fn is_node_unused_honours_scope_filter() {
464        let mut graph = CodeGraph::new();
465        let main = alloc_fn_with_vis(&mut graph, "main", None);
466        let unused = alloc_fn_with_vis(&mut graph, "ghost", None);
467        let _ = main;
468
469        let db = build_db(graph);
470        let all_key = IsNodeUnusedKey {
471            node_id: unused,
472            scope: UnusedScope::All,
473        };
474        assert!(db.get::<IsNodeUnusedQuery>(&all_key));
475        let struct_key = IsNodeUnusedKey {
476            node_id: unused,
477            scope: UnusedScope::Struct,
478        };
479        assert!(!db.get::<IsNodeUnusedQuery>(&struct_key));
480    }
481
482    #[test]
483    fn reachable_set_follows_reachability_edges() {
484        let mut graph = CodeGraph::new();
485        let main = alloc_fn_with_vis(&mut graph, "main", None);
486        let mid = alloc_fn_with_vis(&mut graph, "mid", None);
487        let deep = alloc_fn_with_vis(&mut graph, "deep", None);
488        add_call(&mut graph, main, mid);
489        add_call(&mut graph, mid, deep);
490
491        let db = build_db(graph);
492        let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
493        assert!(reachable.contains(&main));
494        assert!(reachable.contains(&mid));
495        assert!(reachable.contains(&deep));
496    }
497
498    /// Gate 0d iter-2 regression: `EntryPointsQuery` and
499    /// `UnusedQuery` MUST skip Phase 4c-prime unified losers. We
500    /// construct the end-state `merge_node_into` leaves in the arena
501    /// (name == INVALID + content-addressable fields cleared)
502    /// directly, because the merge helper is `pub(crate)` inside
503    /// sqry-core and not reachable from sqry-db tests.
504    #[test]
505    fn entry_points_and_unused_exclude_unified_losers() {
506        use sqry_core::graph::unified::string::StringId;
507
508        let mut graph = CodeGraph::new();
509        let main = alloc_fn_with_vis(&mut graph, "main", None);
510        let _ = main;
511
512        // Build a winner (public) + loser pair sharing a qualified
513        // name in different files.
514        let file_a = graph.files_mut().register(Path::new("a.rs")).unwrap();
515        let file_b = graph.files_mut().register(Path::new("b.rs")).unwrap();
516        let name_id = graph.strings_mut().intern("shared").unwrap();
517        let qn_id = graph.strings_mut().intern("mod::shared").unwrap();
518        let pub_id = graph.strings_mut().intern("public").unwrap();
519
520        let (winner, loser): (NodeId, NodeId) = {
521            let arena = graph.nodes_mut();
522            let mut w = NodeEntry::new(NodeKind::Function, name_id, file_a).with_visibility(pub_id);
523            w.qualified_name = Some(qn_id);
524            let w_id = arena.alloc(w).unwrap();
525            let mut l = NodeEntry::new(NodeKind::Function, name_id, file_b).with_visibility(pub_id);
526            l.qualified_name = Some(qn_id);
527            let l_id = arena.alloc(l).unwrap();
528            (w_id, l_id)
529        };
530        graph.files_mut().record_node(file_a, winner);
531        graph.files_mut().record_node(file_b, loser);
532
533        // Simulate the end-state `merge_node_into` leaves in the
534        // arena for a loser. This matches the clearing contract in
535        // `sqry-core/src/graph/unified/build/unification.rs`.
536        {
537            let arena = graph.nodes_mut();
538            let l_mut = arena.get_mut(loser).expect("loser present");
539            l_mut.name = StringId::INVALID;
540            l_mut.qualified_name = None;
541            l_mut.signature = None;
542            l_mut.body_hash = None;
543            l_mut.doc = None;
544            l_mut.visibility = None;
545        }
546        graph.rebuild_indices();
547
548        let db = build_db(graph);
549        let entry_points = db.get::<EntryPointsQuery>(&());
550        assert!(
551            !entry_points.contains(&loser),
552            "EntryPointsQuery leaked unified loser"
553        );
554        // Note: the winner lost its visibility metadata when we
555        // simulated unification on the loser (the winner is
556        // distinct), so the winner still carries "public" and is
557        // reported as an entry point.
558        assert!(
559            entry_points.contains(&winner),
560            "EntryPointsQuery must still see the winner"
561        );
562
563        let unused_key = UnusedKey {
564            scope: UnusedScope::All,
565            max_results: 100,
566        };
567        let unused = db.get::<UnusedQuery>(&unused_key);
568        assert!(!unused.contains(&loser), "UnusedQuery leaked unified loser");
569    }
570}