Skip to main content

sqry_core/query/executor/
graph_eval.rs

1//! Graph-native predicate evaluation.
2//!
3//! This module provides graph-native predicate evaluation for queries against
4//! `CodeGraph`, bypassing the legacy index path.
5//!
6//! # Design (v6 - CodeGraph-Native Query Executor)
7//!
8//! Key semantics preserved from the legacy index path:
9//! - `name:` uses `segments_match` for qualified name suffix matching
10//! - `kind:`, `lang:`, `visibility:`, `scope.*` are **case-sensitive**
11//! - `imports:` per-node — matches a node iff its own outgoing `Imports`
12//!   edge target/alias/wildcard matches, or it is an `Import` node whose
13//!   text matches. Aligned with `sqry-db::queries::ImportsQuery` per the
14//!   Phase N "Unified Surface Contract" (planner-IR is canonical, all
15//!   transports mirror it). The previous file-scoped semantic was retired
16//!   in DB15 to remove the cross-engine divergence flagged by Codex's
17//!   DB14 review.
18//! - `references:` includes `References` + `Calls` + `Imports` + `FfiCall` edges
19//! - `callers:` checks OUTGOING edges (find nodes that call X)
20//! - `callees:` checks INCOMING edges (find nodes called by X)
21//! - Relation predicates use `segments_match` for qualified names
22//!
23//! # `returns:` evaluation
24//!
25//! `returns:<TypeName>` is evaluated edge-based, mirroring the planner's
26//! `node_returns_type` (see `sqry_db::planner::execute::node_returns_type`):
27//! the candidate node's outgoing edges are scanned for
28//! `EdgeKind::TypeOf { context: Some(TypeOfContext::Return), .. }`, and the
29//! target node's interned primary name is byte-exact-compared (case-sensitive)
30//! to the predicate value. Substring/regex semantics are out of scope and may
31//! land later as a distinct `returns~` operator. The legacy
32//! `NodeEntry.signature.contains(...)` substring path was retired in favour of
33//! this contract because it produced false positives whenever the requested
34//! type name occurred anywhere in the function signature text.
35//!
36//! # Limitations (v1)
37//!
38//! The following predicates are **NOT SUPPORTED** in graph backend v1:
39//! - Plugin fields - requires metadata `HashMap` not in `NodeEntry`
40//! - Numeric operators - requires metadata values
41//!
42//! **Supported boolean predicates**: `async:true`, `static:true`
43
44use crate::graph::unified::FileId;
45use crate::graph::unified::concurrent::CodeGraph;
46use crate::graph::unified::edge::kind::TypeOfContext;
47use crate::graph::unified::edge::{EdgeKind, StoreEdgeRef};
48use crate::graph::unified::node::{NodeId, NodeKind};
49use crate::graph::unified::resolution::{
50    canonicalize_graph_qualified_name, display_graph_qualified_name,
51};
52use crate::graph::unified::storage::arena::NodeEntry;
53use crate::plugin::PluginManager;
54use crate::query::name_matching::segments_match;
55use crate::query::regex_cache::{CompiledRegex, get_or_compile_regex};
56use crate::query::types::{Condition, Expr, JoinEdgeKind, JoinExpr, Operator, Value};
57use anyhow::{Result, anyhow};
58use std::collections::{HashMap, HashSet};
59use std::path::Path;
60
61/// Try `re.is_match(text)`, logging a warning and returning `false` if the
62/// regex engine hits its backtrack limit.  This prevents silent failures
63/// while keeping the predicate-evaluation chain infallible.
64fn regex_is_match(re: &CompiledRegex, text: &str) -> bool {
65    match re.is_match(text) {
66        Ok(b) => b,
67        Err(e) => {
68            log::warn!("regex match aborted (backtrack limit?): {e}");
69            false
70        }
71    }
72}
73use std::sync::Arc;
74
75/// Cache of precomputed subquery result sets, keyed by `(span.start, span.end)`.
76///
77/// Subquery expressions from the AST are evaluated once against all graph nodes
78/// and their results cached here. During per-node evaluation, matchers look up
79/// the cache instead of re-evaluating the full graph. This reduces subquery cost
80/// from O(N^2) to O(N) where N is the number of graph nodes.
81type SubqueryCache = HashMap<(usize, usize), Arc<HashSet<NodeId>>>;
82
83/// Context for graph-native predicate evaluation.
84///
85/// Encapsulates all dependencies needed for evaluating predicates against
86/// a `CodeGraph`. The context includes precomputed caches for performance.
87pub struct GraphEvalContext<'a> {
88    /// Reference to the code graph
89    pub graph: &'a CodeGraph,
90    /// Plugin manager for detecting plugin fields
91    pub plugin_manager: &'a PluginManager,
92    /// Workspace root for relative path resolution in `path:` predicates
93    pub workspace_root: Option<&'a Path>,
94    /// If true, disable parallel execution
95    pub disable_parallel: bool,
96    /// Precomputed subquery result sets, keyed by `(span.start, span.end)`.
97    /// Populated by `precompute_subqueries()` before the per-node evaluation loop.
98    pub subquery_cache: SubqueryCache,
99}
100
101impl<'a> GraphEvalContext<'a> {
102    /// Creates a new evaluation context.
103    #[must_use]
104    pub fn new(graph: &'a CodeGraph, plugin_manager: &'a PluginManager) -> Self {
105        Self {
106            graph,
107            plugin_manager,
108            workspace_root: None,
109            disable_parallel: false,
110            subquery_cache: HashMap::new(),
111        }
112    }
113
114    /// Sets the workspace root for relative path resolution.
115    #[must_use]
116    pub fn with_workspace_root(mut self, root: &'a Path) -> Self {
117        self.workspace_root = Some(root);
118        self
119    }
120
121    /// Disables parallel execution.
122    #[must_use]
123    pub fn with_parallel_disabled(mut self, disabled: bool) -> Self {
124        self.disable_parallel = disabled;
125        self
126    }
127
128    /// Precomputes all subquery result sets from the expression tree.
129    ///
130    /// This must be called before `evaluate_all` to avoid O(N^2) behavior
131    /// where each subquery is re-evaluated for every candidate node.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if subquery evaluation fails.
136    pub fn precompute_subqueries(&mut self, expr: &Expr) -> Result<()> {
137        let mut subquery_exprs = Vec::new();
138        collect_subquery_exprs(expr, &mut subquery_exprs);
139
140        for (span_key, inner_expr) in subquery_exprs {
141            if !self.subquery_cache.contains_key(&span_key) {
142                let result_set = evaluate_subquery(self, inner_expr)?;
143                self.subquery_cache.insert(span_key, Arc::new(result_set));
144            }
145        }
146        Ok(())
147    }
148}
149
150/// Collects all `Value::Subquery(inner)` expressions from the AST for precomputation.
151///
152/// Returns `(span_key, &Expr)` pairs where `span_key` is `(span.start, span.end)`.
153///
154/// Uses **post-order** traversal: nested (inner) subqueries appear before their
155/// enclosing (outer) subqueries. This ensures `precompute_subqueries()` evaluates
156/// dependencies first, so outer subquery evaluation can find inner results in the cache.
157fn collect_subquery_exprs<'a>(expr: &'a Expr, out: &mut Vec<((usize, usize), &'a Expr)>) {
158    match expr {
159        Expr::Condition(cond) => {
160            if let Value::Subquery(inner) = &cond.value {
161                // Post-order: recurse into nested subqueries FIRST
162                collect_subquery_exprs(inner, out);
163                // Then record this (outer) subquery
164                out.push(((cond.span.start, cond.span.end), inner));
165            }
166        }
167        Expr::And(operands) | Expr::Or(operands) => {
168            for op in operands {
169                collect_subquery_exprs(op, out);
170            }
171        }
172        Expr::Not(inner) => collect_subquery_exprs(inner, out),
173        Expr::Join(join) => {
174            collect_subquery_exprs(&join.left, out);
175            collect_subquery_exprs(&join.right, out);
176        }
177    }
178}
179
180/// Evaluates query against all nodes, returning matching `NodeIds`.
181///
182/// # Errors
183///
184/// Returns an error if predicate evaluation fails (e.g., unsupported predicates).
185pub fn evaluate_all(ctx: &mut GraphEvalContext, expr: &Expr) -> Result<Vec<NodeId>> {
186    // Precompute all subquery result sets before the per-node evaluation loop.
187    // This is critical for performance: without precomputation, each subquery
188    // matcher would re-evaluate the full graph for every candidate node (O(N^2)).
189    ctx.precompute_subqueries(expr)?;
190
191    let arena = ctx.graph.nodes();
192
193    // Create recursion guard
194    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
195    let expr_depth = recursion_limits.effective_expr_depth()?;
196    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
197
198    if ctx.disable_parallel {
199        // Sequential evaluation
200        let mut matches = Vec::new();
201        for (id, entry) in arena.iter() {
202            // Skip Phase 4c-prime unified-away losers — they remain in
203            // the arena as inert duplicates so CSR row_ptr sizing stays
204            // stable, but publish-visible query evaluation must not
205            // surface them (Gate 0d iter-1 blocker). See
206            // `NodeEntry::is_unified_loser`.
207            if entry.is_unified_loser() {
208                continue;
209            }
210            if evaluate_node(ctx, id, expr, &mut guard)? {
211                matches.push(id);
212            }
213        }
214        Ok(matches)
215    } else {
216        // Parallel evaluation - each thread needs its own guard
217        use rayon::prelude::*;
218
219        let node_ids: Vec<_> = arena
220            .iter()
221            .filter(|(_id, entry)| !entry.is_unified_loser())
222            .map(|(id, _)| id)
223            .collect();
224        let results: Vec<Result<Option<NodeId>>> = node_ids
225            .into_par_iter()
226            .map(|id| {
227                let mut thread_guard = crate::query::security::RecursionGuard::new(expr_depth)?;
228                evaluate_node(ctx, id, expr, &mut thread_guard)
229                    .map(|m| if m { Some(id) } else { None })
230            })
231            .collect();
232
233        // Check for any errors and collect matches
234        let mut matches = Vec::new();
235        for result in results {
236            if let Some(id) = result? {
237                matches.push(id);
238            }
239        }
240        Ok(matches)
241    }
242}
243
244/// Evaluates a single node against an expression.
245///
246/// # Errors
247///
248/// Returns an error for unsupported predicates or if recursion depth exceeds the guard's limit.
249pub fn evaluate_node(
250    ctx: &GraphEvalContext,
251    node_id: NodeId,
252    expr: &Expr,
253    guard: &mut crate::query::security::RecursionGuard,
254) -> Result<bool> {
255    guard.enter()?;
256
257    let result = match expr {
258        Expr::Condition(cond) => evaluate_condition(ctx, node_id, cond),
259        Expr::And(operands) => {
260            for operand in operands {
261                if !evaluate_node(ctx, node_id, operand, guard)? {
262                    guard.exit();
263                    return Ok(false);
264                }
265            }
266            Ok(true)
267        }
268        Expr::Or(operands) => {
269            for operand in operands {
270                if evaluate_node(ctx, node_id, operand, guard)? {
271                    guard.exit();
272                    return Ok(true);
273                }
274            }
275            Ok(false)
276        }
277        Expr::Not(inner) => Ok(!evaluate_node(ctx, node_id, inner, guard)?),
278        Expr::Join(_) => {
279            // Join expressions are evaluated at a higher level (execute_join),
280            // not per-node. If we reach here, it's a programming error.
281            Err(anyhow::anyhow!(
282                "Join expressions cannot be evaluated per-node; use execute_join instead"
283            ))
284        }
285    };
286
287    guard.exit();
288    result
289}
290
291fn evaluate_condition(ctx: &GraphEvalContext, node_id: NodeId, cond: &Condition) -> Result<bool> {
292    let Some(entry) = ctx.graph.nodes().get(node_id) else {
293        return Ok(false);
294    };
295
296    match cond.field.as_str() {
297        "kind" => Ok(match_kind(ctx, entry, &cond.operator, &cond.value)),
298        "name" => Ok(match_name(ctx, entry, &cond.operator, &cond.value)),
299        "path" => Ok(match_path(ctx, entry, &cond.operator, &cond.value)),
300        "lang" | "language" => Ok(match_lang(ctx, entry, &cond.operator, &cond.value)),
301        "visibility" => Ok(match_visibility(ctx, entry, &cond.operator, &cond.value)),
302        "async" => Ok(match_async(entry, &cond.operator, &cond.value)),
303        "static" => Ok(match_static(entry, &cond.operator, &cond.value)),
304        "callers" => {
305            if matches!(cond.value, Value::Subquery(_)) {
306                let key = (cond.span.start, cond.span.end);
307                let cached = ctx.subquery_cache.get(&key).cloned();
308                match_callers_subquery(ctx, node_id, cached.as_deref())
309            } else {
310                Ok(match_callers(ctx, node_id, &cond.value))
311            }
312        }
313        "callees" => {
314            if matches!(cond.value, Value::Subquery(_)) {
315                let key = (cond.span.start, cond.span.end);
316                let cached = ctx.subquery_cache.get(&key).cloned();
317                match_callees_subquery(ctx, node_id, cached.as_deref())
318            } else {
319                Ok(match_callees(ctx, node_id, &cond.value))
320            }
321        }
322        "imports" => {
323            if matches!(cond.value, Value::Subquery(_)) {
324                let key = (cond.span.start, cond.span.end);
325                let cached = ctx.subquery_cache.get(&key).cloned();
326                match_imports_subquery(ctx, node_id, cached.as_deref())
327            } else {
328                Ok(match_imports(ctx, node_id, &cond.value))
329            }
330        }
331        "exports" => {
332            if matches!(cond.value, Value::Subquery(_)) {
333                let key = (cond.span.start, cond.span.end);
334                let cached = ctx.subquery_cache.get(&key).cloned();
335                match_exports_subquery(ctx, node_id, cached.as_deref())
336            } else {
337                Ok(match_exports(ctx, node_id, &cond.value))
338            }
339        }
340        "references" => {
341            if matches!(cond.value, Value::Subquery(_)) {
342                let key = (cond.span.start, cond.span.end);
343                let cached = ctx.subquery_cache.get(&key).cloned();
344                match_references_subquery(ctx, node_id, cached.as_deref())
345            } else {
346                Ok(match_references(ctx, node_id, &cond.operator, &cond.value))
347            }
348        }
349        "impl" | "implements" => {
350            if matches!(cond.value, Value::Subquery(_)) {
351                let key = (cond.span.start, cond.span.end);
352                let cached = ctx.subquery_cache.get(&key).cloned();
353                match_implements_subquery(ctx, node_id, cached.as_deref())
354            } else {
355                Ok(match_implements(ctx, node_id, &cond.value))
356            }
357        }
358        field if field.starts_with("scope.") => Ok(match_scope(
359            ctx,
360            node_id,
361            field,
362            &cond.operator,
363            &cond.value,
364        )),
365        "returns" => Ok(match_returns(
366            ctx,
367            node_id,
368            entry,
369            &cond.operator,
370            &cond.value,
371        )),
372        field if is_plugin_field(ctx, field) => Err(anyhow!(
373            "Plugin field '{field}' requires metadata not available in graph backend"
374        )),
375        _ => Ok(false), // Unknown field
376    }
377}
378
379/// Checks if a field is a plugin-specific field.
380fn is_plugin_field(ctx: &GraphEvalContext, field: &str) -> bool {
381    // Check plugin registry for field descriptors
382    let is_registered_field = ctx
383        .plugin_manager
384        .plugins()
385        .iter()
386        .flat_map(|plugin| plugin.fields().iter())
387        .any(|descriptor| descriptor.name == field);
388
389    if is_registered_field {
390        return true;
391    }
392
393    // Fallback static list (for when registry not fully wired)
394    // Note: "async" and "static" are now handled natively via `NodeEntry` flags
395    matches!(
396        field,
397        "abstract" | "final" | "generic" | "parameters" | "arity"
398    )
399}
400
401// ============================================================================
402// Kind predicate (with regex + synonyms)
403// ============================================================================
404
405/// Maps synonyms to canonical kind names (case-sensitive).
406///
407/// Per v6 spec, kind: comparisons are case-sensitive.
408/// Only exact matches for known synonyms are normalized.
409fn normalize_kind(kind: &str) -> &str {
410    match kind {
411        // Rust/Go synonyms (case-sensitive)
412        "trait" => "interface", // Rust trait = interface
413        "impl" => "implementation",
414        // Property synonyms
415        "field" => "property",
416        // Module synonyms
417        "namespace" => "module",
418        // Component synonyms
419        "element" => "component",
420        // CSS/Style synonyms
421        "style" => "style_rule",
422        "at_rule" => "style_at_rule",
423        "css_var" | "custom_property" => "style_variable",
424        // No lowercasing - return as-is for case-sensitive comparison
425        _ => kind,
426    }
427}
428
429fn match_kind(
430    _ctx: &GraphEvalContext,
431    entry: &NodeEntry,
432    operator: &Operator,
433    value: &Value,
434) -> bool {
435    let actual = entry.kind.as_str();
436
437    match (operator, value) {
438        (Operator::Equal, Value::String(expected)) => {
439            let normalized_expected = normalize_kind(expected);
440            let normalized_actual = normalize_kind(actual);
441            normalized_actual == normalized_expected
442        }
443        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
444            &regex_val.pattern,
445            regex_val.flags.case_insensitive,
446            regex_val.flags.multiline,
447            regex_val.flags.dot_all,
448        )
449        .map(|re| regex_is_match(&re, actual))
450        .unwrap_or(false),
451        _ => false,
452    }
453}
454
455// ============================================================================
456// Name predicate (EXACT match for equality, regex supported)
457// ============================================================================
458
459fn match_name(
460    ctx: &GraphEvalContext,
461    entry: &NodeEntry,
462    operator: &Operator,
463    value: &Value,
464) -> bool {
465    match (operator, value) {
466        // Use segments_match for qualified name matching:
467        // - "connect" matches "database::connect" (suffix match)
468        // - "foo::bar" matches "baz::foo::bar" (suffix match)
469        // - "database::connect" matches "database::connect" (exact match)
470        (Operator::Equal, Value::String(expected)) => {
471            entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
472                language_aware_segments_match(ctx.graph, entry.file, candidate, expected)
473            })
474        }
475        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
476            &regex_val.pattern,
477            regex_val.flags.case_insensitive,
478            regex_val.flags.multiline,
479            regex_val.flags.dot_all,
480        )
481        .map(|re| {
482            entry_query_texts(ctx.graph, entry)
483                .iter()
484                .any(|candidate| regex_is_match(&re, candidate))
485        })
486        .unwrap_or(false),
487        _ => false,
488    }
489}
490
491// ============================================================================
492// Path predicate (glob + regex)
493// ============================================================================
494
495/// Check if a pattern is relative (doesn't start with `/`).
496fn is_relative_pattern(pattern: &str) -> bool {
497    !pattern.starts_with('/')
498}
499
500fn match_path(
501    ctx: &GraphEvalContext,
502    entry: &NodeEntry,
503    operator: &Operator,
504    value: &Value,
505) -> bool {
506    let Some(file_path) = ctx.graph.files().resolve(entry.file) else {
507        return false;
508    };
509
510    match (operator, value) {
511        (Operator::Equal, Value::String(pattern)) => {
512            // Only strip workspace_root for RELATIVE patterns (parity with legacy index)
513            let match_path = if is_relative_pattern(pattern) {
514                if let Some(root) = ctx.workspace_root {
515                    file_path
516                        .strip_prefix(root)
517                        .map_or_else(|_| file_path.to_path_buf(), std::path::Path::to_path_buf)
518                } else {
519                    file_path.to_path_buf()
520                }
521            } else {
522                // Absolute pattern: match against full path
523                file_path.to_path_buf()
524            };
525            globset::Glob::new(pattern)
526                .map(|g| g.compile_matcher().is_match(&match_path))
527                .unwrap_or(false)
528        }
529        (Operator::Regex, Value::Regex(regex_val)) => {
530            // For regex, always use full path (matches legacy index behavior)
531            get_or_compile_regex(
532                &regex_val.pattern,
533                regex_val.flags.case_insensitive,
534                regex_val.flags.multiline,
535                regex_val.flags.dot_all,
536            )
537            .map(|re| regex_is_match(&re, file_path.to_string_lossy().as_ref()))
538            .unwrap_or(false)
539        }
540        _ => false,
541    }
542}
543
544// ============================================================================
545// Language predicate (from FILE REGISTRY, not NodeEntry)
546// ============================================================================
547
548/// Convert Language enum to canonical string for parity with legacy index.
549///
550/// Legacy index uses canonical names like "javascript", "typescript", etc.
551/// `Language::Display` uses short forms like "js", "ts".
552/// This function provides the canonical mapping for query parity.
553fn language_to_canonical(lang: crate::graph::node::Language) -> &'static str {
554    use crate::graph::node::Language;
555    match lang {
556        Language::C => "c",
557        Language::Cpp => "cpp",
558        Language::CSharp => "csharp",
559        Language::Css => "css",
560        Language::JavaScript => "javascript",
561        Language::Python => "python",
562        Language::TypeScript => "typescript",
563        Language::Rust => "rust",
564        Language::Go => "go",
565        Language::Java => "java",
566        Language::Ruby => "ruby",
567        Language::Php => "php",
568        Language::Swift => "swift",
569        Language::Kotlin => "kotlin",
570        Language::Scala => "scala",
571        Language::Sql => "sql",
572        Language::Dart => "dart",
573        Language::Lua => "lua",
574        Language::Perl => "perl",
575        Language::Shell => "shell",
576        Language::Groovy => "groovy",
577        Language::Elixir => "elixir",
578        Language::R => "r",
579        Language::Haskell => "haskell",
580        Language::Html => "html",
581        Language::Svelte => "svelte",
582        Language::Vue => "vue",
583        Language::Zig => "zig",
584        Language::Terraform => "terraform",
585        Language::Puppet => "puppet",
586        Language::Pulumi => "pulumi",
587        Language::Http => "http",
588        Language::Plsql => "plsql",
589        Language::Apex => "apex",
590        Language::Abap => "abap",
591        Language::ServiceNow => "servicenow",
592        Language::Json => "json",
593    }
594}
595
596fn match_lang(
597    ctx: &GraphEvalContext,
598    entry: &NodeEntry,
599    operator: &Operator,
600    value: &Value,
601) -> bool {
602    // Get language from file registry - NodeEntry has no language field
603    let Some(lang) = ctx.graph.files().language_for_file(entry.file) else {
604        return false;
605    };
606
607    // Use canonical language names for parity with legacy index
608    let actual = language_to_canonical(lang);
609
610    // Support both equality and regex operators
611    match (operator, value) {
612        (Operator::Equal, Value::String(expected)) => actual == expected,
613        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
614            &rv.pattern,
615            rv.flags.case_insensitive,
616            rv.flags.multiline,
617            rv.flags.dot_all,
618        )
619        .map(|re| regex_is_match(&re, actual))
620        .unwrap_or(false),
621        _ => false,
622    }
623}
624
625// ============================================================================
626// Visibility predicate
627// ============================================================================
628
629fn match_visibility(
630    ctx: &GraphEvalContext,
631    entry: &NodeEntry,
632    operator: &Operator,
633    value: &Value,
634) -> bool {
635    let Some(expected) = value.as_string() else {
636        return false;
637    };
638
639    let normalized_expected = if expected == "pub" {
640        "public"
641    } else {
642        expected
643    };
644
645    let Some(vis_id) = entry.visibility else {
646        // No visibility = private by default
647        return match operator {
648            Operator::Equal => normalized_expected == "private",
649            _ => false,
650        };
651    };
652
653    let Some(actual) = ctx.graph.strings().resolve(vis_id) else {
654        return false;
655    };
656    let normalized_actual = if actual.as_ref().starts_with("pub") {
657        "public"
658    } else {
659        actual.as_ref()
660    };
661
662    // Case-sensitive comparison
663    match operator {
664        Operator::Equal => normalized_actual == normalized_expected,
665        _ => false,
666    }
667}
668
669// ============================================================================
670// Returns predicate
671// ============================================================================
672
673/// Match `returns:<TypeName>` predicate via edge-based, byte-exact evaluation.
674///
675/// Walks every outgoing edge from `node_id` and checks for the first
676/// `EdgeKind::TypeOf { context: Some(TypeOfContext::Return), .. }` whose
677/// target node's interned primary name equals the predicate value
678/// byte-exactly (case-sensitive). Returns `false` if the candidate has no
679/// `Return`-context type edges, or if every such edge targets a different
680/// name.
681///
682/// This mirrors `sqry_db::planner::execute::node_returns_type` exactly so
683/// that the legacy graph-query backend and the planner produce identical
684/// results for `returns:` predicates. The previous substring path against
685/// `NodeEntry.signature` was retired because it produced false positives
686/// whenever the requested type name occurred anywhere in the function
687/// signature text (e.g. `returns:error` matched any signature mentioning
688/// `error` in a parameter or doc).
689///
690/// Substring/regex semantics are out of scope here and may land later as a
691/// distinct `returns~` operator; only `Operator::Equal` is honoured.
692///
693/// The `entry.kind` guard for `Function`/`Method` is retained as a cheap
694/// fast-path early-out: only callable nodes can plausibly have a
695/// `TypeOf{Return}` outgoing edge, so non-callable candidates can be
696/// rejected without touching the edge store. The planner does not need
697/// this guard because its dispatch surface keys on a different shape.
698fn match_returns(
699    ctx: &GraphEvalContext,
700    node_id: NodeId,
701    entry: &NodeEntry,
702    operator: &Operator,
703    value: &Value,
704) -> bool {
705    let Some(expected) = value.as_string() else {
706        return false;
707    };
708
709    // Only functions and methods can have a return type.  This is a fast
710    // early-out — see the doc comment above for the rationale.
711    if !matches!(entry.kind, NodeKind::Function | NodeKind::Method) {
712        return false;
713    }
714
715    if !matches!(operator, Operator::Equal) {
716        return false;
717    }
718
719    let nodes = ctx.graph.nodes();
720    let strings = ctx.graph.strings();
721    for edge in ctx.graph.edges().edges_from(node_id) {
722        if !matches!(
723            edge.kind,
724            EdgeKind::TypeOf {
725                context: Some(TypeOfContext::Return),
726                ..
727            }
728        ) {
729            continue;
730        }
731        let Some(target_entry) = nodes.get(edge.target) else {
732            continue;
733        };
734        if let Some(name) = strings.resolve(target_entry.name)
735            && name.as_ref() == expected
736        {
737            return true;
738        }
739    }
740    false
741}
742
743// ============================================================================
744// Boolean predicates (async, static)
745// ============================================================================
746
747/// Match `async:true` or `async:false` predicate.
748///
749/// Checks the `is_async` flag on `NodeEntry`.
750/// Handles both Boolean values (from parser) and String values ("true"/"false").
751fn match_async(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
752    let expected = value_to_bool(value);
753    let Some(expected) = expected else {
754        return false;
755    };
756
757    match operator {
758        Operator::Equal => entry.is_async == expected,
759        _ => false,
760    }
761}
762
763/// Match `static:true` or `static:false` predicate.
764///
765/// Checks the `is_static` flag on `NodeEntry`.
766/// Handles both Boolean values (from parser) and String values ("true"/"false").
767fn match_static(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
768    let expected = value_to_bool(value);
769    let Some(expected) = expected else {
770        return false;
771    };
772
773    match operator {
774        Operator::Equal => entry.is_static == expected,
775        _ => false,
776    }
777}
778
779/// Convert a Value to a boolean.
780///
781/// Handles:
782/// - `Value::Boolean(b)` → `Some(b)`
783/// - `Value::String("true"|"yes"|"1")` → `Some(true)`
784/// - `Value::String("false"|"no"|"0")` → `Some(false)`
785/// - Other values → `None`
786fn value_to_bool(value: &Value) -> Option<bool> {
787    match value {
788        Value::Boolean(b) => Some(*b),
789        Value::String(s) => match s.to_lowercase().as_str() {
790            "true" | "yes" | "1" => Some(true),
791            "false" | "no" | "0" => Some(false),
792            _ => None,
793        },
794        _ => None,
795    }
796}
797
798// ============================================================================
799// Relation predicates (CORRECTED DIRECTION)
800// ============================================================================
801
802/// `callers:X` - find symbols that CALL X
803///
804/// When evaluating node Y: does Y call X? → Check Y's OUTGOING edges
805///
806/// For dynamically-typed languages (Lua, Python, Ruby, etc.), method calls like
807/// `target:method()` create edges to `receiver::method` where `receiver` is the
808/// variable name, not the class name. To handle this, when querying for a qualified
809/// name like `Class::method`, we also match call edges to `X::method` where X is
810/// any receiver, as long as a symbol `Class::method` exists in the graph.
811fn match_callers(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
812    let Some(target_name) = value.as_string() else {
813        return false;
814    };
815
816    // Extract the method name part for fallback matching in dynamic languages
817    // e.g., "Player::takeDamage" -> "takeDamage"
818    let method_part = extract_method_name(target_name);
819
820    // Check OUTGOING Calls edges from this node
821    for edge in ctx.graph.edges().edges_from(node_id) {
822        if let EdgeKind::Calls { .. } = &edge.kind
823            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
824        {
825            let callee_names = entry_query_texts(ctx.graph, target_entry);
826
827            if callee_names.iter().any(|callee_name| {
828                language_aware_segments_match(
829                    ctx.graph,
830                    target_entry.file,
831                    callee_name,
832                    target_name,
833                )
834            }) {
835                return true;
836            }
837
838            // For dynamic languages: if target_name is qualified (e.g., "Player::takeDamage")
839            // and callee is also qualified (e.g., "target::takeDamage"), match on method part
840            // This handles cases where the receiver type isn't known at call time
841            if let Some(method) = &method_part
842                && callee_names
843                    .iter()
844                    .filter_map(|callee_name| extract_method_name(callee_name))
845                    .any(|callee_method| method == &callee_method)
846            {
847                return true;
848            }
849        }
850    }
851    false
852}
853
854/// Extract the method name from a qualified name.
855/// e.g., "`Player::takeDamage`" -> Some("takeDamage")
856/// e.g., "takeDamage" -> None (no separator, not qualified)
857#[must_use]
858pub fn extract_method_name(qualified: &str) -> Option<String> {
859    // Look for separator from the end
860    for sep in ["::", ".", "#", ":", "/"] {
861        if let Some(pos) = qualified.rfind(sep) {
862            let method = &qualified[pos + sep.len()..];
863            if !method.is_empty() {
864                return Some(method.to_string());
865            }
866        }
867    }
868    None
869}
870
871/// `callees:X` - find symbols that ARE CALLED BY X
872///
873/// When evaluating node Y: does X call Y? → Check Y's INCOMING edges for source=X
874fn match_callees(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
875    let Some(caller_name) = value.as_string() else {
876        return false;
877    };
878
879    // Check INCOMING Calls edges to this node
880    for edge in ctx.graph.edges().edges_to(node_id) {
881        if let EdgeKind::Calls { .. } = &edge.kind
882            && let Some(source_entry) = ctx.graph.nodes().get(edge.source)
883            && entry_query_texts(ctx.graph, source_entry)
884                .iter()
885                .any(|source_name| {
886                    language_aware_segments_match(
887                        ctx.graph,
888                        source_entry.file,
889                        source_name,
890                        caller_name,
891                    )
892                })
893        {
894            return true;
895        }
896    }
897    false
898}
899
900/// `imports:X` — per-node match.
901///
902/// A node matches iff:
903/// 1. It is itself an `Import` node whose name (or alias text) matches `X`, or
904/// 2. It has at least one outgoing `Imports` edge whose target name, alias,
905///    or wildcard flag matches `X` (see [`import_edge_matches`]).
906///
907/// This is the planner-canonical semantic per
908/// `docs/development/phase-n-structural-semantics/02_DESIGN.md` §6 — every
909/// transport (planner, MCP, CLI, LSP) shares it. The previous file-scoped
910/// behavior ("every node in a file that imports X matches") was retired in
911/// DB15 to remove the cross-engine divergence flagged by Codex's DB14
912/// review. Matches the set returned by
913/// [`sqry_db::queries::ImportsQuery`](https://docs.rs/sqry-db) for the same
914/// key.
915fn match_imports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
916    let Some(target_module) = value.as_string() else {
917        return false;
918    };
919
920    let Some(entry) = ctx.graph.nodes().get(node_id) else {
921        return false;
922    };
923
924    if entry.kind == NodeKind::Import && import_entry_matches(ctx.graph, entry, target_module) {
925        return true;
926    }
927
928    for edge in ctx.graph.edges().edges_from(node_id) {
929        if import_edge_matches(ctx.graph, &edge, target_module) {
930            return true;
931        }
932    }
933    false
934}
935
936/// Returns `true` when the given `Imports` edge imports something matching
937/// `target_module`, checking the target node text, the edge's alias, and the
938/// wildcard flag. Shared with [`crate::graph::unified::concurrent::GraphSnapshot`]
939/// consumers (including `sqry-db::queries::relation`) that need the same
940/// semantics as the graph-native `imports:` predicate.
941#[must_use]
942pub fn import_edge_matches<G: crate::graph::unified::concurrent::GraphAccess>(
943    graph: &G,
944    edge: &StoreEdgeRef,
945    target_module: &str,
946) -> bool {
947    let EdgeKind::Imports { alias, is_wildcard } = &edge.kind else {
948        return false;
949    };
950
951    // Check target node text across simple, canonical, and native-display forms.
952    let target_match = graph
953        .nodes()
954        .get(edge.target)
955        .is_some_and(|entry| import_entry_matches(graph, entry, target_module));
956
957    // Check alias
958    let alias_match = alias
959        .and_then(|sid| graph.strings().resolve(sid))
960        .is_some_and(|alias_str| {
961            graph.nodes().get(edge.source).is_some_and(|entry| {
962                import_text_matches(graph, entry.file, alias_str.as_ref(), target_module)
963            })
964        });
965
966    // Check wildcard
967    let wildcard_match = *is_wildcard && target_module == "*";
968
969    target_match || alias_match || wildcard_match
970}
971
972/// Substring-based text match for `imports:` semantics, with language-aware
973/// canonicalization fallback when the file's language maps the input module
974/// path into graph-internal `::` form.
975#[must_use]
976pub fn import_text_matches<G: crate::graph::unified::concurrent::GraphAccess>(
977    graph: &G,
978    file_id: FileId,
979    candidate: &str,
980    target_module: &str,
981) -> bool {
982    if candidate.contains(target_module) {
983        return true;
984    }
985
986    graph
987        .files()
988        .language_for_file(file_id)
989        .is_some_and(|language| {
990            let canonical_target = canonicalize_graph_qualified_name(language, target_module);
991            canonical_target != target_module && candidate.contains(&canonical_target)
992        })
993}
994
995/// Matches an Import/candidate node entry against a target module using
996/// the shared `imports:` substring + canonicalization semantics.
997#[must_use]
998pub fn import_entry_matches<G: crate::graph::unified::concurrent::GraphAccess>(
999    graph: &G,
1000    entry: &NodeEntry,
1001    target_module: &str,
1002) -> bool {
1003    entry_query_texts(graph, entry)
1004        .iter()
1005        .any(|candidate| import_text_matches(graph, entry.file, candidate, target_module))
1006}
1007
1008/// Segment-aware name equality with a language-specific canonicalization
1009/// fallback. First tries a direct [`segments_match`]; if that fails, the
1010/// file's language (if any) is consulted to canonicalize `expected` into
1011/// graph-internal `::` form before retrying.
1012#[must_use]
1013pub fn language_aware_segments_match<G: crate::graph::unified::concurrent::GraphAccess>(
1014    graph: &G,
1015    file_id: FileId,
1016    candidate: &str,
1017    expected: &str,
1018) -> bool {
1019    if segments_match(candidate, expected) {
1020        return true;
1021    }
1022
1023    graph
1024        .files()
1025        .language_for_file(file_id)
1026        .is_some_and(|language| {
1027            let canonical_expected = canonicalize_graph_qualified_name(language, expected);
1028            canonical_expected != expected && segments_match(candidate, &canonical_expected)
1029        })
1030}
1031
1032fn push_unique_query_text(texts: &mut Vec<String>, candidate: impl Into<String>) {
1033    let candidate = candidate.into();
1034    if !texts.iter().any(|existing| existing == &candidate) {
1035        texts.push(candidate);
1036    }
1037}
1038
1039/// Collects every string form that can satisfy a name query for the given
1040/// node entry: the interned name, the qualified name, and (when a language
1041/// is recorded) the display-qualified name produced by
1042/// [`display_graph_qualified_name`]. Duplicates are dropped so that relation
1043/// matchers do not re-check equivalent forms.
1044#[must_use]
1045pub fn entry_query_texts<G: crate::graph::unified::concurrent::GraphAccess>(
1046    graph: &G,
1047    entry: &NodeEntry,
1048) -> Vec<String> {
1049    let mut texts = Vec::with_capacity(3);
1050
1051    if let Some(name) = graph.strings().resolve(entry.name) {
1052        push_unique_query_text(&mut texts, name.to_string());
1053    }
1054
1055    if let Some(qualified) = entry
1056        .qualified_name
1057        .and_then(|qualified_name_id| graph.strings().resolve(qualified_name_id))
1058    {
1059        push_unique_query_text(&mut texts, qualified.to_string());
1060
1061        if let Some(language) = graph.files().language_for_file(entry.file) {
1062            push_unique_query_text(
1063                &mut texts,
1064                display_graph_qualified_name(
1065                    language,
1066                    qualified.as_ref(),
1067                    entry.kind,
1068                    entry.is_static,
1069                ),
1070            );
1071        }
1072    }
1073
1074    texts
1075}
1076
1077/// `exports:X` - find symbols that export X
1078///
1079/// File scope filtering: Only considers export edges where both source and
1080/// target are in the same file as the node being evaluated. This prevents
1081/// re-export edges from causing symbols in other files to match.
1082fn match_exports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1083    let Some(target_name) = value.as_string() else {
1084        return false;
1085    };
1086
1087    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1088        return false;
1089    };
1090    let node_file = entry.file;
1091
1092    if !entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
1093        language_aware_segments_match(ctx.graph, entry.file, candidate, target_name)
1094    }) {
1095        return false;
1096    }
1097
1098    let edges = ctx.graph.edges();
1099
1100    // Check OUTGOING export edges: this node exports something
1101    for edge in edges.edges_from(node_id) {
1102        if let EdgeKind::Exports { .. } = &edge.kind {
1103            // File scope filtering: export edge must be in same file
1104            if let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1105                && target_entry.file == node_file
1106            {
1107                return true;
1108            }
1109        }
1110    }
1111
1112    // Check INCOMING export edges: something exports this node
1113    for edge in edges.edges_to(node_id) {
1114        if let EdgeKind::Exports { .. } = &edge.kind {
1115            // File scope filtering: export edge source must be in same file
1116            if let Some(source_entry) = ctx.graph.nodes().get(edge.source)
1117                && source_entry.file == node_file
1118            {
1119                return true;
1120            }
1121        }
1122    }
1123
1124    false
1125}
1126
1127/// `references:X` - find symbols named X that have references TO them.
1128///
1129/// Current behavior: references include `Calls`, `Imports`, `FfiCall`, and `References` edges.
1130fn match_references(
1131    ctx: &GraphEvalContext,
1132    node_id: NodeId,
1133    operator: &Operator,
1134    value: &Value,
1135) -> bool {
1136    // First check if the symbol name matches the target
1137    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1138        return false;
1139    };
1140
1141    let name_matches = match (operator, value) {
1142        (Operator::Equal, Value::String(target)) => entry_query_texts(ctx.graph, entry)
1143            .iter()
1144            .any(|candidate| candidate == target || candidate.ends_with(&format!("::{target}"))),
1145        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1146            &rv.pattern,
1147            rv.flags.case_insensitive,
1148            rv.flags.multiline,
1149            rv.flags.dot_all,
1150        )
1151        .map(|re| {
1152            entry_query_texts(ctx.graph, entry)
1153                .iter()
1154                .any(|candidate| regex_is_match(&re, candidate))
1155        })
1156        .unwrap_or(false),
1157        _ => false,
1158    };
1159
1160    if !name_matches {
1161        return false;
1162    }
1163
1164    // Check if the symbol has references TO it (incoming edges)
1165    // Current behavior: References, Calls, Imports, AND FfiCall all count as references
1166    for edge in ctx.graph.edges().edges_to(node_id) {
1167        let is_reference = matches!(
1168            &edge.kind,
1169            EdgeKind::References
1170                | EdgeKind::Calls { .. }
1171                | EdgeKind::Imports { .. }
1172                | EdgeKind::FfiCall { .. }
1173        );
1174        if is_reference {
1175            return true;
1176        }
1177    }
1178
1179    false
1180}
1181
1182/// `implements:X` - find symbols that implement interface/trait X
1183fn match_implements(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1184    let Some(trait_name) = value.as_string() else {
1185        return false;
1186    };
1187
1188    for edge in ctx.graph.edges().edges_from(node_id) {
1189        if let EdgeKind::Implements = &edge.kind
1190            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1191            && entry_query_texts(ctx.graph, target_entry)
1192                .iter()
1193                .any(|name| {
1194                    language_aware_segments_match(ctx.graph, target_entry.file, name, trait_name)
1195                })
1196        {
1197            return true;
1198        }
1199    }
1200    false
1201}
1202
1203// ============================================================================
1204// Scope predicates (with regex support)
1205// ============================================================================
1206
1207/// Convert `NodeKind` to scope type string for predicate matching.
1208///
1209/// This mapping provides parity with legacy index scope predicates:
1210/// - Trait → interface
1211/// - Test → function
1212/// - Service → class
1213/// - etc.
1214fn node_kind_to_scope_type(kind: NodeKind) -> &'static str {
1215    match kind {
1216        NodeKind::Function | NodeKind::Test => "function",
1217        NodeKind::Method => "method",
1218        NodeKind::Class | NodeKind::Service => "class",
1219        NodeKind::Interface | NodeKind::Trait => "interface",
1220        NodeKind::Struct => "struct",
1221        NodeKind::Enum => "enum",
1222        NodeKind::Module => "module",
1223        NodeKind::Macro => "macro",
1224        NodeKind::Component => "component",
1225        NodeKind::Resource | NodeKind::Endpoint => "resource",
1226        // Non-container types
1227        NodeKind::Variable => "variable",
1228        NodeKind::Constant => "constant",
1229        NodeKind::Type => "type",
1230        NodeKind::EnumVariant => "enumvariant",
1231        NodeKind::Import => "import",
1232        NodeKind::Export => "export",
1233        NodeKind::CallSite => "callsite",
1234        NodeKind::Parameter => "parameter",
1235        NodeKind::Property => "property",
1236        NodeKind::StyleRule => "style_rule",
1237        NodeKind::StyleAtRule => "style_at_rule",
1238        NodeKind::StyleVariable => "style_variable",
1239        NodeKind::Lifetime => "lifetime",
1240        NodeKind::TypeParameter => "type_parameter",
1241        NodeKind::Annotation => "annotation",
1242        NodeKind::AnnotationValue => "annotation_value",
1243        NodeKind::LambdaTarget => "lambda_target",
1244        NodeKind::JavaModule => "java_module",
1245        NodeKind::EnumConstant => "enum_constant",
1246        NodeKind::Other => "other",
1247    }
1248}
1249
1250fn match_scope(
1251    ctx: &GraphEvalContext,
1252    node_id: NodeId,
1253    field: &str,
1254    operator: &Operator,
1255    value: &Value,
1256) -> bool {
1257    let scope_part = field.strip_prefix("scope.").unwrap_or("");
1258    match scope_part {
1259        "type" => match_scope_type(ctx, node_id, operator, value),
1260        "name" => match_scope_name(ctx, node_id, operator, value),
1261        "parent" => match_scope_parent_name(ctx, node_id, operator, value),
1262        "ancestor" => match_scope_ancestor_name(ctx, node_id, operator, value),
1263        _ => false,
1264    }
1265}
1266
1267fn match_scope_type(
1268    ctx: &GraphEvalContext,
1269    node_id: NodeId,
1270    operator: &Operator,
1271    value: &Value,
1272) -> bool {
1273    for edge in ctx.graph.edges().edges_to(node_id) {
1274        if let EdgeKind::Contains = &edge.kind
1275            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1276        {
1277            // Use mapped scope type for parity with legacy index
1278            let scope_type = node_kind_to_scope_type(parent.kind);
1279            return match (operator, value) {
1280                // Case-sensitive comparison
1281                (Operator::Equal, Value::String(exp)) => scope_type == exp,
1282                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1283                    &rv.pattern,
1284                    rv.flags.case_insensitive,
1285                    rv.flags.multiline,
1286                    rv.flags.dot_all,
1287                )
1288                .map(|re| regex_is_match(&re, scope_type))
1289                .unwrap_or(false),
1290                _ => false,
1291            };
1292        }
1293    }
1294    false
1295}
1296
1297fn match_scope_name(
1298    ctx: &GraphEvalContext,
1299    node_id: NodeId,
1300    operator: &Operator,
1301    value: &Value,
1302) -> bool {
1303    for edge in ctx.graph.edges().edges_to(node_id) {
1304        if let EdgeKind::Contains = &edge.kind
1305            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1306            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1307        {
1308            return match (operator, value) {
1309                // Use segments_match for qualified name suffix matching
1310                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1311                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1312                    &rv.pattern,
1313                    rv.flags.case_insensitive,
1314                    rv.flags.multiline,
1315                    rv.flags.dot_all,
1316                )
1317                .map(|re| regex_is_match(&re, &name))
1318                .unwrap_or(false),
1319                _ => false,
1320            };
1321        }
1322    }
1323    false
1324}
1325
1326/// `scope.parent:X` - find nodes with immediate parent NAMED X.
1327///
1328/// Uses `segments_match` for qualified name suffix matching (same as name: predicate).
1329fn match_scope_parent_name(
1330    ctx: &GraphEvalContext,
1331    node_id: NodeId,
1332    operator: &Operator,
1333    value: &Value,
1334) -> bool {
1335    for edge in ctx.graph.edges().edges_to(node_id) {
1336        if let EdgeKind::Contains = &edge.kind
1337            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1338            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1339        {
1340            return match (operator, value) {
1341                // Use segments_match for qualified name suffix matching
1342                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1343                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1344                    &rv.pattern,
1345                    rv.flags.case_insensitive,
1346                    rv.flags.multiline,
1347                    rv.flags.dot_all,
1348                )
1349                .map(|re| regex_is_match(&re, &name))
1350                .unwrap_or(false),
1351                _ => false,
1352            };
1353        }
1354    }
1355    false
1356}
1357
1358/// `scope.ancestor:X` - find nodes with any ancestor NAMED X.
1359///
1360/// CYCLE PROTECTION: Uses visited set to prevent infinite loops.
1361/// Uses `segments_match` for qualified name suffix matching.
1362fn match_scope_ancestor_name(
1363    ctx: &GraphEvalContext,
1364    node_id: NodeId,
1365    operator: &Operator,
1366    value: &Value,
1367) -> bool {
1368    let mut current = node_id;
1369    let mut visited = HashSet::new();
1370    visited.insert(node_id);
1371
1372    loop {
1373        let mut found_parent = false;
1374        for edge in ctx.graph.edges().edges_to(current) {
1375            if let EdgeKind::Contains = &edge.kind {
1376                // CYCLE PROTECTION: Skip if we've already visited this node
1377                if visited.contains(&edge.source) {
1378                    continue;
1379                }
1380                visited.insert(edge.source);
1381
1382                found_parent = true;
1383                current = edge.source;
1384                if let Some(parent) = ctx.graph.nodes().get(current)
1385                    && let Some(name) = ctx.graph.strings().resolve(parent.name)
1386                {
1387                    let matches = match (operator, value) {
1388                        // Use segments_match for qualified name suffix matching
1389                        (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1390                        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1391                            &rv.pattern,
1392                            rv.flags.case_insensitive,
1393                            rv.flags.multiline,
1394                            rv.flags.dot_all,
1395                        )
1396                        .map(|re| regex_is_match(&re, &name))
1397                        .unwrap_or(false),
1398                        _ => false,
1399                    };
1400                    if matches {
1401                        return true;
1402                    }
1403                }
1404                break;
1405            }
1406        }
1407        if !found_parent {
1408            break;
1409        }
1410    }
1411    false
1412}
1413
1414// ============================================================================
1415// Subquery evaluation
1416// ============================================================================
1417
1418/// Evaluate an expression against all nodes and return the set of matching node IDs.
1419///
1420/// Used for subquery evaluation: `callers:(kind:function AND async:true)`.
1421///
1422/// # Errors
1423///
1424/// Returns an error if predicate evaluation fails.
1425pub fn evaluate_subquery(ctx: &GraphEvalContext, expr: &Expr) -> Result<HashSet<NodeId>> {
1426    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
1427    let expr_depth = recursion_limits.effective_expr_depth()?;
1428    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
1429
1430    let arena = ctx.graph.nodes();
1431    let mut matches = HashSet::new();
1432    for (id, _) in arena.iter() {
1433        if evaluate_node(ctx, id, expr, &mut guard)? {
1434            matches.insert(id);
1435        }
1436    }
1437    Ok(matches)
1438}
1439
1440/// Match callers using a precomputed subquery result set.
1441///
1442/// Checks if any of this node's outgoing Calls edges target a node in the
1443/// precomputed set. Returns an error if the subquery was not precomputed
1444/// (indicates a bug in `precompute_subqueries`).
1445fn match_callers_subquery(
1446    ctx: &GraphEvalContext,
1447    node_id: NodeId,
1448    subquery_matches: Option<&HashSet<NodeId>>,
1449) -> Result<bool> {
1450    let Some(matches) = subquery_matches else {
1451        return Err(anyhow!(
1452            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1453        ));
1454    };
1455    for edge in ctx.graph.edges().edges_from(node_id) {
1456        if let EdgeKind::Calls { .. } = &edge.kind
1457            && matches.contains(&edge.target)
1458        {
1459            return Ok(true);
1460        }
1461    }
1462    Ok(false)
1463}
1464
1465/// Match callees using a precomputed subquery result set.
1466fn match_callees_subquery(
1467    ctx: &GraphEvalContext,
1468    node_id: NodeId,
1469    subquery_matches: Option<&HashSet<NodeId>>,
1470) -> Result<bool> {
1471    let Some(matches) = subquery_matches else {
1472        return Err(anyhow!(
1473            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1474        ));
1475    };
1476    for edge in ctx.graph.edges().edges_to(node_id) {
1477        if let EdgeKind::Calls { .. } = &edge.kind
1478            && matches.contains(&edge.source)
1479        {
1480            return Ok(true);
1481        }
1482    }
1483    Ok(false)
1484}
1485
1486/// Match imports using a precomputed subquery result set.
1487///
1488/// Per-node semantic (DB15): returns true iff this specific node has an
1489/// outgoing `Imports` edge whose target is in the precomputed subquery set.
1490/// Aligned with [`match_imports`] and `sqry_db::queries::ImportsQuery`.
1491fn match_imports_subquery(
1492    ctx: &GraphEvalContext,
1493    node_id: NodeId,
1494    subquery_matches: Option<&HashSet<NodeId>>,
1495) -> Result<bool> {
1496    let Some(matches) = subquery_matches else {
1497        return Err(anyhow!(
1498            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1499        ));
1500    };
1501    for edge in ctx.graph.edges().edges_from(node_id) {
1502        if let EdgeKind::Imports { .. } = &edge.kind
1503            && matches.contains(&edge.target)
1504        {
1505            return Ok(true);
1506        }
1507    }
1508    Ok(false)
1509}
1510
1511/// Match exports using a precomputed subquery result set.
1512fn match_exports_subquery(
1513    ctx: &GraphEvalContext,
1514    node_id: NodeId,
1515    subquery_matches: Option<&HashSet<NodeId>>,
1516) -> Result<bool> {
1517    let Some(matches) = subquery_matches else {
1518        return Err(anyhow!(
1519            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1520        ));
1521    };
1522    for edge in ctx.graph.edges().edges_from(node_id) {
1523        if let EdgeKind::Exports { .. } = &edge.kind
1524            && matches.contains(&edge.target)
1525        {
1526            return Ok(true);
1527        }
1528    }
1529    Ok(false)
1530}
1531
1532/// Match implements using a precomputed subquery result set.
1533fn match_implements_subquery(
1534    ctx: &GraphEvalContext,
1535    node_id: NodeId,
1536    subquery_matches: Option<&HashSet<NodeId>>,
1537) -> Result<bool> {
1538    let Some(matches) = subquery_matches else {
1539        return Err(anyhow!(
1540            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1541        ));
1542    };
1543    for edge in ctx.graph.edges().edges_from(node_id) {
1544        if let EdgeKind::Implements = &edge.kind
1545            && matches.contains(&edge.target)
1546        {
1547            return Ok(true);
1548        }
1549    }
1550    Ok(false)
1551}
1552
1553/// Match references using a precomputed subquery result set.
1554fn match_references_subquery(
1555    ctx: &GraphEvalContext,
1556    node_id: NodeId,
1557    subquery_matches: Option<&HashSet<NodeId>>,
1558) -> Result<bool> {
1559    let Some(matches) = subquery_matches else {
1560        return Err(anyhow!(
1561            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1562        ));
1563    };
1564    for edge in ctx.graph.edges().edges_to(node_id) {
1565        let is_reference = matches!(
1566            &edge.kind,
1567            EdgeKind::References
1568                | EdgeKind::Calls { .. }
1569                | EdgeKind::Imports { .. }
1570                | EdgeKind::FfiCall { .. }
1571        );
1572        if is_reference && matches.contains(&edge.source) {
1573            return Ok(true);
1574        }
1575    }
1576    Ok(false)
1577}
1578
1579// ============================================================================
1580// Join evaluation
1581// ============================================================================
1582
1583/// Evaluate a join expression, returning matched (left, right) node pairs.
1584///
1585/// For `(kind:function AND lang:rust) CALLS (kind:function AND lang:python)`:
1586/// 1. Evaluate LHS query → set of matching nodes
1587/// 2. Evaluate RHS query → set of matching nodes
1588/// 3. For each LHS node, check edges of the specified kind
1589/// 4. If edge target is in RHS set → add (lhs, rhs) pair
1590///
1591/// # Errors
1592///
1593/// Returns an error if subquery evaluation or edge traversal fails.
1594pub fn evaluate_join(
1595    ctx: &GraphEvalContext,
1596    join: &JoinExpr,
1597    max_results: Option<usize>,
1598) -> Result<JoinEvalResult> {
1599    let lhs_matches = evaluate_subquery(ctx, &join.left)?;
1600    let rhs_matches = evaluate_subquery(ctx, &join.right)?;
1601    let cap = max_results.unwrap_or(DEFAULT_JOIN_RESULT_CAP);
1602
1603    let mut pairs = Vec::new();
1604    let mut truncated = false;
1605    'outer: for &lhs_id in &lhs_matches {
1606        for edge in ctx.graph.edges().edges_from(lhs_id) {
1607            if edge_matches_join_kind(&edge.kind, &join.edge) && rhs_matches.contains(&edge.target)
1608            {
1609                pairs.push((lhs_id, edge.target));
1610                if pairs.len() >= cap {
1611                    truncated = true;
1612                    break 'outer;
1613                }
1614            }
1615        }
1616    }
1617    Ok(JoinEvalResult { pairs, truncated })
1618}
1619
1620/// Result of a join evaluation including truncation metadata.
1621pub struct JoinEvalResult {
1622    /// The matched (source, target) node ID pairs.
1623    pub pairs: Vec<(NodeId, NodeId)>,
1624    /// Whether the result set was truncated by the result cap.
1625    pub truncated: bool,
1626}
1627
1628/// Default result cap for join queries.
1629///
1630/// Prevents unbounded memory growth when a join produces a large number of matching pairs.
1631const DEFAULT_JOIN_RESULT_CAP: usize = 10_000;
1632
1633/// Check if an edge kind matches a join edge kind.
1634fn edge_matches_join_kind(edge_kind: &EdgeKind, join_kind: &JoinEdgeKind) -> bool {
1635    match join_kind {
1636        JoinEdgeKind::Calls => matches!(edge_kind, EdgeKind::Calls { .. }),
1637        JoinEdgeKind::Imports => matches!(edge_kind, EdgeKind::Imports { .. }),
1638        JoinEdgeKind::Inherits => matches!(edge_kind, EdgeKind::Inherits),
1639        JoinEdgeKind::Implements => matches!(edge_kind, EdgeKind::Implements),
1640    }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645    use super::*;
1646    use crate::graph::node::Language;
1647    use crate::query::types::{Condition, Field, Span};
1648    use std::path::Path;
1649
1650    #[test]
1651    fn test_import_text_matches_canonicalized_qualified_imports() {
1652        let mut graph = CodeGraph::new();
1653        let file_id = graph
1654            .files_mut()
1655            .register(Path::new("src/FileProcessor.cs"))
1656            .unwrap();
1657        assert!(graph.files_mut().set_language(file_id, Language::CSharp));
1658
1659        assert!(import_text_matches(
1660            &graph,
1661            file_id,
1662            "System::IO",
1663            "System.IO"
1664        ));
1665        assert!(import_text_matches(
1666            &graph,
1667            file_id,
1668            "System::Collections::Generic",
1669            "System.Collections.Generic"
1670        ));
1671        assert!(!import_text_matches(
1672            &graph,
1673            file_id,
1674            "System::Text",
1675            "System.IO"
1676        ));
1677    }
1678
1679    #[test]
1680    fn test_language_aware_segments_match_supports_ruby_method_separators() {
1681        let mut graph = CodeGraph::new();
1682        let file_id = graph
1683            .files_mut()
1684            .register(Path::new("app/models/user.rb"))
1685            .unwrap();
1686        assert!(graph.files_mut().set_language(file_id, Language::Ruby));
1687
1688        assert!(language_aware_segments_match(
1689            &graph,
1690            file_id,
1691            "Admin::Users::Controller::show",
1692            "Admin::Users::Controller#show"
1693        ));
1694        assert!(language_aware_segments_match(
1695            &graph,
1696            file_id,
1697            "Admin::Users::Controller::show",
1698            "show"
1699        ));
1700        assert!(!language_aware_segments_match(
1701            &graph,
1702            file_id,
1703            "Admin::Users::Controller::index",
1704            "Admin::Users::Controller#show"
1705        ));
1706    }
1707
1708    #[test]
1709    fn test_normalize_kind() {
1710        // Case-sensitive synonyms
1711        assert_eq!(normalize_kind("trait"), "interface");
1712        assert_eq!(normalize_kind("TRAIT"), "TRAIT"); // Case-sensitive: not a synonym
1713        assert_eq!(normalize_kind("field"), "property");
1714        assert_eq!(normalize_kind("namespace"), "module");
1715        assert_eq!(normalize_kind("function"), "function"); // unchanged
1716    }
1717
1718    #[test]
1719    fn test_graph_eval_context_builder() {
1720        let graph = CodeGraph::new();
1721        let pm = PluginManager::new();
1722        let ctx = GraphEvalContext::new(&graph, &pm)
1723            .with_workspace_root(Path::new("/test"))
1724            .with_parallel_disabled(true);
1725
1726        assert!(ctx.disable_parallel);
1727        assert_eq!(ctx.workspace_root, Some(Path::new("/test")));
1728    }
1729
1730    // ================================================================
1731    // collect_subquery_exprs tests
1732    // ================================================================
1733
1734    /// Helper: build `field:(inner_expr)` as a Condition with `Value::Subquery`.
1735    fn subquery_condition(field: &str, inner: Expr, start: usize, end: usize) -> Expr {
1736        Expr::Condition(Condition {
1737            field: Field(field.to_string()),
1738            operator: Operator::Equal,
1739            value: Value::Subquery(Box::new(inner)),
1740            span: Span::with_position(start, end, 1, start + 1),
1741        })
1742    }
1743
1744    /// Helper: build a simple `kind:function` condition.
1745    fn kind_condition(kind: &str) -> Expr {
1746        Expr::Condition(Condition {
1747            field: Field("kind".to_string()),
1748            operator: Operator::Equal,
1749            value: Value::String(kind.to_string()),
1750            span: Span::default(),
1751        })
1752    }
1753
1754    #[test]
1755    fn test_collect_subquery_exprs_post_order_depth_2() {
1756        // Build: callers:(callees:(kind:function))
1757        // Inner subquery: callees:(kind:function) at span (20, 40)
1758        // Outer subquery: callers:(...) at span (0, 50)
1759        let inner_subquery = subquery_condition("callees", kind_condition("function"), 20, 40);
1760        let outer_subquery = subquery_condition("callers", inner_subquery, 0, 50);
1761
1762        let mut out = Vec::new();
1763        collect_subquery_exprs(&outer_subquery, &mut out);
1764
1765        // Post-order: inner appears before outer
1766        assert_eq!(
1767            out.len(),
1768            2,
1769            "should collect both inner and outer subqueries"
1770        );
1771        assert_eq!(out[0].0, (20, 40), "inner subquery span should come first");
1772        assert_eq!(out[1].0, (0, 50), "outer subquery span should come second");
1773    }
1774
1775    #[test]
1776    fn test_collect_subquery_exprs_post_order_depth_3() {
1777        // Build: callers:(callees:(imports:(kind:function)))
1778        let innermost = subquery_condition("imports", kind_condition("function"), 30, 50);
1779        let middle = subquery_condition("callees", innermost, 15, 55);
1780        let outer = subquery_condition("callers", middle, 0, 60);
1781
1782        let mut out = Vec::new();
1783        collect_subquery_exprs(&outer, &mut out);
1784
1785        assert_eq!(out.len(), 3, "should collect all three nested subqueries");
1786        assert_eq!(out[0].0, (30, 50), "innermost should come first");
1787        assert_eq!(out[1].0, (15, 55), "middle should come second");
1788        assert_eq!(out[2].0, (0, 60), "outer should come last");
1789    }
1790
1791    #[test]
1792    fn test_collect_subquery_exprs_and_or_branches() {
1793        // Build: callers:(kind:function) AND callees:(kind:method)
1794        let left = subquery_condition("callers", kind_condition("function"), 0, 25);
1795        let right = subquery_condition("callees", kind_condition("method"), 30, 55);
1796        let expr = Expr::And(vec![left, right]);
1797
1798        let mut out = Vec::new();
1799        collect_subquery_exprs(&expr, &mut out);
1800
1801        assert_eq!(out.len(), 2, "should collect subqueries from both branches");
1802        assert_eq!(out[0].0, (0, 25), "left branch subquery");
1803        assert_eq!(out[1].0, (30, 55), "right branch subquery");
1804    }
1805
1806    #[test]
1807    fn test_collect_subquery_exprs_no_subqueries() {
1808        // Simple condition with no subqueries: kind:function
1809        let expr = kind_condition("function");
1810
1811        let mut out = Vec::new();
1812        collect_subquery_exprs(&expr, &mut out);
1813
1814        assert!(
1815            out.is_empty(),
1816            "should collect nothing for plain conditions"
1817        );
1818    }
1819
1820    // ================================================================
1821    // FfiCall edge in references/referenced_by predicates
1822    // ================================================================
1823
1824    use crate::graph::unified::edge::{BidirectionalEdgeStore, FfiConvention};
1825    use crate::graph::unified::storage::{
1826        AuxiliaryIndices, FileRegistry, NodeArena, StringInterner,
1827    };
1828
1829    /// Build a `CodeGraph` with: `caller --FfiCall(C)--> target`
1830    fn build_ffi_graph() -> (CodeGraph, NodeId, NodeId) {
1831        let mut arena = NodeArena::new();
1832        let edges = BidirectionalEdgeStore::new();
1833        let mut strings = StringInterner::new();
1834        let mut files = FileRegistry::new();
1835        let mut indices = AuxiliaryIndices::new();
1836
1837        let caller_name = strings.intern("caller_fn").unwrap();
1838        let target_name = strings.intern("ffi_target").unwrap();
1839        let file_id = files.register(Path::new("test.r")).unwrap();
1840
1841        let caller_id = arena
1842            .alloc(NodeEntry {
1843                kind: NodeKind::Function,
1844                name: caller_name,
1845                file: file_id,
1846                start_byte: 0,
1847                end_byte: 100,
1848                start_line: 1,
1849                start_column: 0,
1850                end_line: 5,
1851                end_column: 0,
1852                signature: None,
1853                doc: None,
1854                qualified_name: None,
1855                visibility: None,
1856                is_async: false,
1857                is_static: false,
1858                is_unsafe: false,
1859                body_hash: None,
1860            })
1861            .unwrap();
1862
1863        let target_id = arena
1864            .alloc(NodeEntry {
1865                kind: NodeKind::Function,
1866                name: target_name,
1867                file: file_id,
1868                start_byte: 200,
1869                end_byte: 300,
1870                start_line: 10,
1871                start_column: 0,
1872                end_line: 15,
1873                end_column: 0,
1874                signature: None,
1875                doc: None,
1876                qualified_name: None,
1877                visibility: None,
1878                is_async: false,
1879                is_static: false,
1880                is_unsafe: false,
1881                body_hash: None,
1882            })
1883            .unwrap();
1884
1885        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
1886        indices.add(target_id, NodeKind::Function, target_name, None, file_id);
1887
1888        edges.add_edge(
1889            caller_id,
1890            target_id,
1891            EdgeKind::FfiCall {
1892                convention: FfiConvention::C,
1893            },
1894            file_id,
1895        );
1896
1897        let graph = CodeGraph::from_components(
1898            arena,
1899            edges,
1900            strings,
1901            files,
1902            indices,
1903            crate::graph::unified::NodeMetadataStore::new(),
1904        );
1905        (graph, caller_id, target_id)
1906    }
1907
1908    #[test]
1909    fn test_ffi_call_edge_in_references_predicate() {
1910        let (graph, _caller_id, target_id) = build_ffi_graph();
1911        let pm = PluginManager::new();
1912        let ctx = GraphEvalContext::new(&graph, &pm);
1913
1914        // `references:ffi_target` should match because there is an FfiCall edge to it
1915        let result = match_references(
1916            &ctx,
1917            target_id,
1918            &Operator::Equal,
1919            &Value::String("ffi_target".to_string()),
1920        );
1921        assert!(result, "references: predicate should match FfiCall edges");
1922    }
1923
1924    #[test]
1925    fn test_ffi_call_edge_in_references_subquery() {
1926        let (graph, caller_id, target_id) = build_ffi_graph();
1927        let pm = PluginManager::new();
1928        let ctx = GraphEvalContext::new(&graph, &pm);
1929
1930        // Simulate a subquery that matched the caller node
1931        let mut subquery_results = HashSet::new();
1932        subquery_results.insert(caller_id);
1933
1934        // match_references_subquery checks incoming edges to target_id
1935        // and verifies the source is in the subquery result set
1936        let result = match_references_subquery(&ctx, target_id, Some(&subquery_results)).unwrap();
1937        assert!(
1938            result,
1939            "references subquery should match FfiCall edge sources"
1940        );
1941    }
1942
1943    // ================================================================
1944    // returns: predicate (edge-based, byte-exact)
1945    // ================================================================
1946
1947    /// Build a `CodeGraph` with two functions and an `error` type node:
1948    /// - `returner_fn` --TypeOf{Return}--> `error`
1949    /// - `plain_fn` (no outgoing TypeOf edges)
1950    ///
1951    /// Returns `(graph, returner_id, plain_id, error_type_id)`.
1952    fn build_returns_graph() -> (CodeGraph, NodeId, NodeId, NodeId) {
1953        let mut arena = NodeArena::new();
1954        let edges = BidirectionalEdgeStore::new();
1955        let mut strings = StringInterner::new();
1956        let mut files = FileRegistry::new();
1957        let mut indices = AuxiliaryIndices::new();
1958
1959        let returner_name = strings.intern("returner_fn").unwrap();
1960        let plain_name = strings.intern("plain_fn").unwrap();
1961        let error_name = strings.intern("error").unwrap();
1962        let file_id = files.register(Path::new("test.go")).unwrap();
1963
1964        let returner_id = arena
1965            .alloc(NodeEntry {
1966                kind: NodeKind::Function,
1967                name: returner_name,
1968                file: file_id,
1969                start_byte: 0,
1970                end_byte: 100,
1971                start_line: 1,
1972                start_column: 0,
1973                end_line: 5,
1974                end_column: 0,
1975                signature: None,
1976                doc: None,
1977                qualified_name: None,
1978                visibility: None,
1979                is_async: false,
1980                is_static: false,
1981                is_unsafe: false,
1982                body_hash: None,
1983            })
1984            .unwrap();
1985
1986        let plain_id = arena
1987            .alloc(NodeEntry {
1988                kind: NodeKind::Function,
1989                name: plain_name,
1990                file: file_id,
1991                start_byte: 200,
1992                end_byte: 300,
1993                start_line: 10,
1994                start_column: 0,
1995                end_line: 15,
1996                end_column: 0,
1997                signature: None,
1998                doc: None,
1999                qualified_name: None,
2000                visibility: None,
2001                is_async: false,
2002                is_static: false,
2003                is_unsafe: false,
2004                body_hash: None,
2005            })
2006            .unwrap();
2007
2008        let error_type_id = arena
2009            .alloc(NodeEntry {
2010                kind: NodeKind::Type,
2011                name: error_name,
2012                file: file_id,
2013                start_byte: 400,
2014                end_byte: 410,
2015                start_line: 20,
2016                start_column: 0,
2017                end_line: 20,
2018                end_column: 10,
2019                signature: None,
2020                doc: None,
2021                qualified_name: None,
2022                visibility: None,
2023                is_async: false,
2024                is_static: false,
2025                is_unsafe: false,
2026                body_hash: None,
2027            })
2028            .unwrap();
2029
2030        indices.add(
2031            returner_id,
2032            NodeKind::Function,
2033            returner_name,
2034            None,
2035            file_id,
2036        );
2037        indices.add(plain_id, NodeKind::Function, plain_name, None, file_id);
2038        indices.add(error_type_id, NodeKind::Type, error_name, None, file_id);
2039
2040        edges.add_edge(
2041            returner_id,
2042            error_type_id,
2043            EdgeKind::TypeOf {
2044                context: Some(TypeOfContext::Return),
2045                index: None,
2046                name: None,
2047            },
2048            file_id,
2049        );
2050
2051        let graph = CodeGraph::from_components(
2052            arena,
2053            edges,
2054            strings,
2055            files,
2056            indices,
2057            crate::graph::unified::NodeMetadataStore::new(),
2058        );
2059        (graph, returner_id, plain_id, error_type_id)
2060    }
2061
2062    #[test]
2063    fn test_match_returns_byte_exact_hit() {
2064        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2065        let pm = PluginManager::new();
2066        let ctx = GraphEvalContext::new(&graph, &pm);
2067        let entry = graph.nodes().get(returner_id).expect("returner exists");
2068
2069        // returns:error against the function with a TypeOf{Return} edge to
2070        // the `error` type node should match (byte-exact).
2071        assert!(match_returns(
2072            &ctx,
2073            returner_id,
2074            entry,
2075            &Operator::Equal,
2076            &Value::String("error".to_string()),
2077        ));
2078    }
2079
2080    #[test]
2081    fn test_match_returns_no_edges_misses() {
2082        let (graph, _returner_id, plain_id, _error_id) = build_returns_graph();
2083        let pm = PluginManager::new();
2084        let ctx = GraphEvalContext::new(&graph, &pm);
2085        let entry = graph.nodes().get(plain_id).expect("plain_fn exists");
2086
2087        // returns:error against a function with no TypeOf{Return} edges
2088        // must NOT match (the legacy substring path would have to be
2089        // entirely off this code path for this to hold).
2090        assert!(!match_returns(
2091            &ctx,
2092            plain_id,
2093            entry,
2094            &Operator::Equal,
2095            &Value::String("error".to_string()),
2096        ));
2097    }
2098
2099    #[test]
2100    fn test_match_returns_byte_exact_miss_on_different_target_name() {
2101        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2102        let pm = PluginManager::new();
2103        let ctx = GraphEvalContext::new(&graph, &pm);
2104        let entry = graph.nodes().get(returner_id).expect("returner exists");
2105
2106        // returns:Error (capitalised) must NOT match `error` — byte-exact
2107        // is case-sensitive.  This is the property the previous
2108        // signature.contains substring path failed to enforce.
2109        assert!(!match_returns(
2110            &ctx,
2111            returner_id,
2112            entry,
2113            &Operator::Equal,
2114            &Value::String("Error".to_string()),
2115        ));
2116    }
2117
2118    #[test]
2119    fn test_match_returns_rejects_non_callable_kinds() {
2120        let (graph, _returner_id, _plain_id, error_id) = build_returns_graph();
2121        let pm = PluginManager::new();
2122        let ctx = GraphEvalContext::new(&graph, &pm);
2123        // The `error` node is a Type, not a Function/Method, so the
2124        // fast-path early-out should reject it without consulting edges.
2125        let entry = graph.nodes().get(error_id).expect("error type exists");
2126
2127        assert!(!match_returns(
2128            &ctx,
2129            error_id,
2130            entry,
2131            &Operator::Equal,
2132            &Value::String("error".to_string()),
2133        ));
2134    }
2135}