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