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        // Phase A C indirect-call precision (U18.1): predicate parity with the
684        // planner surface (`sqry_query`). NodeEntry has no `id` field in the
685        // public arena API — anchor `NodeId` is the `node_id` parameter to this
686        // function. `address_taken` / `callsite_promiscuous` are O(1) flag
687        // lookups against `ctx.graph.macro_metadata()`; `resolved_via` walks
688        // outgoing Calls edges anchored at `node_id`. See Phase A 02_DESIGN §11
689        // and 03_IMPLEMENTATION_PLAN §"U18.1".
690        "address_taken" => Ok(match_address_taken(
691            ctx,
692            node_id,
693            &cond.operator,
694            &cond.value,
695        )),
696        "callsite_promiscuous" => Ok(match_callsite_promiscuous(
697            ctx,
698            node_id,
699            &cond.operator,
700            &cond.value,
701        )),
702        "resolved_via" => Ok(match_resolved_via(ctx, node_id, &cond.value)),
703        field if is_plugin_field(ctx, field) => Err(anyhow!(
704            "Plugin field '{field}' requires metadata not available in graph backend"
705        )),
706        _ => Ok(false), // Unknown field
707    }
708}
709
710/// Checks if a field is a plugin-specific field.
711fn is_plugin_field(ctx: &GraphEvalContext, field: &str) -> bool {
712    // Check plugin registry for field descriptors
713    let is_registered_field = ctx
714        .plugin_manager
715        .plugins()
716        .iter()
717        .flat_map(|plugin| plugin.fields().iter())
718        .any(|descriptor| descriptor.name == field);
719
720    if is_registered_field {
721        return true;
722    }
723
724    // Fallback static list (for when registry not fully wired)
725    // Note: "async" and "static" are now handled natively via `NodeEntry` flags
726    matches!(
727        field,
728        "abstract" | "final" | "generic" | "parameters" | "arity"
729    )
730}
731
732// ============================================================================
733// Kind predicate (with regex + synonyms)
734// ============================================================================
735
736/// Maps synonyms to canonical kind names (case-sensitive).
737///
738/// Per v6 spec, kind: comparisons are case-sensitive.
739/// Only exact matches for known synonyms are normalized.
740fn normalize_kind(kind: &str) -> &str {
741    match kind {
742        // Rust/Go synonyms (case-sensitive)
743        "trait" => "interface", // Rust trait = interface
744        "impl" => "implementation",
745        // Property synonyms
746        "field" => "property",
747        // Module synonyms
748        "namespace" => "module",
749        // Component synonyms
750        "element" => "component",
751        // CSS/Style synonyms
752        "style" => "style_rule",
753        "at_rule" => "style_at_rule",
754        "css_var" | "custom_property" => "style_variable",
755        // No lowercasing - return as-is for case-sensitive comparison
756        _ => kind,
757    }
758}
759
760fn match_kind(
761    _ctx: &GraphEvalContext,
762    entry: &NodeEntry,
763    operator: &Operator,
764    value: &Value,
765) -> bool {
766    let actual = entry.kind.as_str();
767
768    match (operator, value) {
769        (Operator::Equal, Value::String(expected)) => {
770            let normalized_expected = normalize_kind(expected);
771            let normalized_actual = normalize_kind(actual);
772            normalized_actual == normalized_expected
773        }
774        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
775            &regex_val.pattern,
776            regex_val.flags.case_insensitive,
777            regex_val.flags.multiline,
778            regex_val.flags.dot_all,
779        )
780        .map(|re| regex_is_match(&re, actual))
781        .unwrap_or(false),
782        _ => false,
783    }
784}
785
786// ============================================================================
787// Name predicate (EXACT match for equality, regex supported)
788// ============================================================================
789
790fn match_name(
791    ctx: &GraphEvalContext,
792    entry: &NodeEntry,
793    operator: &Operator,
794    value: &Value,
795) -> bool {
796    match (operator, value) {
797        // Use segments_match for qualified name matching:
798        // - "connect" matches "database::connect" (suffix match)
799        // - "foo::bar" matches "baz::foo::bar" (suffix match)
800        // - "database::connect" matches "database::connect" (exact match)
801        (Operator::Equal, Value::String(expected)) => {
802            entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
803                language_aware_segments_match(ctx.graph, entry.file, candidate, expected)
804            })
805        }
806        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
807            &regex_val.pattern,
808            regex_val.flags.case_insensitive,
809            regex_val.flags.multiline,
810            regex_val.flags.dot_all,
811        )
812        .map(|re| {
813            entry_query_texts(ctx.graph, entry)
814                .iter()
815                .any(|candidate| regex_is_match(&re, candidate))
816        })
817        .unwrap_or(false),
818        _ => false,
819    }
820}
821
822// ============================================================================
823// Path predicate (glob + regex)
824// ============================================================================
825
826/// Check if a pattern is relative (doesn't start with `/`).
827fn is_relative_pattern(pattern: &str) -> bool {
828    !pattern.starts_with('/')
829}
830
831fn match_path(
832    ctx: &GraphEvalContext,
833    entry: &NodeEntry,
834    operator: &Operator,
835    value: &Value,
836) -> bool {
837    let Some(file_path) = ctx.graph.files().resolve(entry.file) else {
838        return false;
839    };
840
841    match (operator, value) {
842        (Operator::Equal, Value::String(pattern)) => {
843            // Only strip workspace_root for RELATIVE patterns (parity with legacy index)
844            let match_path = if is_relative_pattern(pattern) {
845                if let Some(root) = ctx.workspace_root {
846                    file_path
847                        .strip_prefix(root)
848                        .map_or_else(|_| file_path.to_path_buf(), std::path::Path::to_path_buf)
849                } else {
850                    file_path.to_path_buf()
851                }
852            } else {
853                // Absolute pattern: match against full path
854                file_path.to_path_buf()
855            };
856            globset::Glob::new(pattern)
857                .map(|g| g.compile_matcher().is_match(&match_path))
858                .unwrap_or(false)
859        }
860        (Operator::Regex, Value::Regex(regex_val)) => {
861            // For regex, always use full path (matches legacy index behavior)
862            get_or_compile_regex(
863                &regex_val.pattern,
864                regex_val.flags.case_insensitive,
865                regex_val.flags.multiline,
866                regex_val.flags.dot_all,
867            )
868            .map(|re| regex_is_match(&re, file_path.to_string_lossy().as_ref()))
869            .unwrap_or(false)
870        }
871        _ => false,
872    }
873}
874
875// ============================================================================
876// Language predicate (from FILE REGISTRY, not NodeEntry)
877// ============================================================================
878
879/// Convert Language enum to canonical string for parity with legacy index.
880///
881/// Legacy index uses canonical names like "javascript", "typescript", etc.
882/// `Language::Display` uses short forms like "js", "ts".
883/// This function provides the canonical mapping for query parity.
884fn language_to_canonical(lang: crate::graph::node::Language) -> &'static str {
885    use crate::graph::node::Language;
886    match lang {
887        Language::C => "c",
888        Language::Cpp => "cpp",
889        Language::CSharp => "csharp",
890        Language::Css => "css",
891        Language::JavaScript => "javascript",
892        Language::Python => "python",
893        Language::TypeScript => "typescript",
894        Language::Rust => "rust",
895        Language::Go => "go",
896        Language::Java => "java",
897        Language::Ruby => "ruby",
898        Language::Php => "php",
899        Language::Swift => "swift",
900        Language::Kotlin => "kotlin",
901        Language::Scala => "scala",
902        Language::Sql => "sql",
903        Language::Dart => "dart",
904        Language::Lua => "lua",
905        Language::Perl => "perl",
906        Language::Shell => "shell",
907        Language::Groovy => "groovy",
908        Language::Elixir => "elixir",
909        Language::R => "r",
910        Language::Haskell => "haskell",
911        Language::Html => "html",
912        Language::Svelte => "svelte",
913        Language::Vue => "vue",
914        Language::Zig => "zig",
915        Language::Terraform => "terraform",
916        Language::Puppet => "puppet",
917        Language::Pulumi => "pulumi",
918        Language::Http => "http",
919        Language::Plsql => "plsql",
920        Language::Apex => "apex",
921        Language::Abap => "abap",
922        Language::ServiceNow => "servicenow",
923        Language::Json => "json",
924    }
925}
926
927fn match_lang(
928    ctx: &GraphEvalContext,
929    entry: &NodeEntry,
930    operator: &Operator,
931    value: &Value,
932) -> bool {
933    // Get language from file registry - NodeEntry has no language field
934    let Some(lang) = ctx.graph.files().language_for_file(entry.file) else {
935        return false;
936    };
937
938    // Use canonical language names for parity with legacy index
939    let actual = language_to_canonical(lang);
940
941    // Support both equality and regex operators
942    match (operator, value) {
943        (Operator::Equal, Value::String(expected)) => actual == expected,
944        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
945            &rv.pattern,
946            rv.flags.case_insensitive,
947            rv.flags.multiline,
948            rv.flags.dot_all,
949        )
950        .map(|re| regex_is_match(&re, actual))
951        .unwrap_or(false),
952        _ => false,
953    }
954}
955
956// ============================================================================
957// Visibility predicate
958// ============================================================================
959
960fn match_visibility(
961    ctx: &GraphEvalContext,
962    entry: &NodeEntry,
963    operator: &Operator,
964    value: &Value,
965) -> bool {
966    let Some(expected) = value.as_string() else {
967        return false;
968    };
969
970    let normalized_expected = if expected == "pub" {
971        "public"
972    } else {
973        expected
974    };
975
976    let Some(vis_id) = entry.visibility else {
977        // No visibility = private by default
978        return match operator {
979            Operator::Equal => normalized_expected == "private",
980            _ => false,
981        };
982    };
983
984    let Some(actual) = ctx.graph.strings().resolve(vis_id) else {
985        return false;
986    };
987    let normalized_actual = if actual.as_ref().starts_with("pub") {
988        "public"
989    } else {
990        actual.as_ref()
991    };
992
993    // Case-sensitive comparison
994    match operator {
995        Operator::Equal => normalized_actual == normalized_expected,
996        _ => false,
997    }
998}
999
1000// ============================================================================
1001// Returns predicate
1002// ============================================================================
1003
1004/// Match `returns:<TypeName>` predicate via edge-based, byte-exact evaluation.
1005///
1006/// Walks every outgoing edge from `node_id` and checks for the first
1007/// `EdgeKind::TypeOf { context: Some(TypeOfContext::Return), .. }` whose
1008/// target node's interned primary name equals the predicate value
1009/// byte-exactly (case-sensitive). Returns `false` if the candidate has no
1010/// `Return`-context type edges, or if every such edge targets a different
1011/// name.
1012///
1013/// This mirrors `sqry_db::planner::execute::node_returns_type` exactly so
1014/// that the legacy graph-query backend and the planner produce identical
1015/// results for `returns:` predicates. The previous substring path against
1016/// `NodeEntry.signature` was retired because it produced false positives
1017/// whenever the requested type name occurred anywhere in the function
1018/// signature text (e.g. `returns:error` matched any signature mentioning
1019/// `error` in a parameter or doc).
1020///
1021/// Substring/regex semantics are out of scope here and may land later as a
1022/// distinct `returns~` operator; only `Operator::Equal` is honoured.
1023///
1024/// The `entry.kind` guard for `Function`/`Method` is retained as a cheap
1025/// fast-path early-out: only callable nodes can plausibly have a
1026/// `TypeOf{Return}` outgoing edge, so non-callable candidates can be
1027/// rejected without touching the edge store. The planner does not need
1028/// this guard because its dispatch surface keys on a different shape.
1029fn match_returns(
1030    ctx: &GraphEvalContext,
1031    node_id: NodeId,
1032    entry: &NodeEntry,
1033    operator: &Operator,
1034    value: &Value,
1035) -> bool {
1036    let Some(expected) = value.as_string() else {
1037        return false;
1038    };
1039
1040    // Only functions and methods can have a return type.  This is a fast
1041    // early-out — see the doc comment above for the rationale.
1042    if !matches!(entry.kind, NodeKind::Function | NodeKind::Method) {
1043        return false;
1044    }
1045
1046    if !matches!(operator, Operator::Equal) {
1047        return false;
1048    }
1049
1050    let nodes = ctx.graph.nodes();
1051    let strings = ctx.graph.strings();
1052    for edge in ctx.graph.edges().edges_from(node_id) {
1053        if !matches!(
1054            edge.kind,
1055            EdgeKind::TypeOf {
1056                context: Some(TypeOfContext::Return),
1057                ..
1058            }
1059        ) {
1060            continue;
1061        }
1062        let Some(target_entry) = nodes.get(edge.target) else {
1063            continue;
1064        };
1065        if let Some(name) = strings.resolve(target_entry.name)
1066            && name.as_ref() == expected
1067        {
1068            return true;
1069        }
1070    }
1071    false
1072}
1073
1074// ============================================================================
1075// Boolean predicates (async, static)
1076// ============================================================================
1077
1078/// Match `async:true` or `async:false` predicate.
1079///
1080/// Checks the `is_async` flag on `NodeEntry`.
1081/// Handles both Boolean values (from parser) and String values ("true"/"false").
1082fn match_async(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
1083    let expected = value_to_bool(value);
1084    let Some(expected) = expected else {
1085        return false;
1086    };
1087
1088    match operator {
1089        Operator::Equal => entry.is_async == expected,
1090        _ => false,
1091    }
1092}
1093
1094/// Match `static:true` or `static:false` predicate.
1095///
1096/// Checks the `is_static` flag on `NodeEntry`.
1097/// Handles both Boolean values (from parser) and String values ("true"/"false").
1098fn match_static(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
1099    let expected = value_to_bool(value);
1100    let Some(expected) = expected else {
1101        return false;
1102    };
1103
1104    match operator {
1105        Operator::Equal => entry.is_static == expected,
1106        _ => false,
1107    }
1108}
1109
1110/// Convert a Value to a boolean.
1111///
1112/// Handles:
1113/// - `Value::Boolean(b)` → `Some(b)`
1114/// - `Value::String("true"|"yes"|"1")` → `Some(true)`
1115/// - `Value::String("false"|"no"|"0")` → `Some(false)`
1116/// - Other values → `None`
1117fn value_to_bool(value: &Value) -> Option<bool> {
1118    match value {
1119        Value::Boolean(b) => Some(*b),
1120        Value::String(s) => match s.to_lowercase().as_str() {
1121            "true" | "yes" | "1" => Some(true),
1122            "false" | "no" | "0" => Some(false),
1123            _ => None,
1124        },
1125        _ => None,
1126    }
1127}
1128
1129// ============================================================================
1130// Phase A C indirect-call precision (U18.1)
1131//
1132// Predicate parity with the planner surface (`sqry_query`). The planner-side
1133// queries live in `sqry-db/src/queries/{address_taken,callsite_promiscuous}.rs`
1134// and the planner-IR `resolved_via` site filter lives in
1135// `sqry-db/src/planner/ir.rs`. The helpers below give the **core-query**
1136// executor the same semantic answer. SPEC §3.1.3 + DESIGN §11 / §12.
1137// ============================================================================
1138
1139/// `address_taken:true` / `address_taken:false` — match nodes flagged by the
1140/// C plugin's address-taken classifier (DESIGN §6).
1141///
1142/// Backed by `NodeFlags::ADDRESS_TAKEN` via
1143/// [`crate::graph::unified::NodeMetadataStore::is_address_taken`]. O(1) hash
1144/// lookup against the per-snapshot metadata store. On non-C nodes the flag is
1145/// never set, so this predicate naturally short-circuits to `false`.
1146fn match_address_taken(
1147    ctx: &GraphEvalContext,
1148    node_id: NodeId,
1149    operator: &Operator,
1150    value: &Value,
1151) -> bool {
1152    let Some(expected) = value_to_bool(value) else {
1153        return false;
1154    };
1155    match operator {
1156        Operator::Equal => ctx.graph.macro_metadata().is_address_taken(node_id) == expected,
1157        _ => false,
1158    }
1159}
1160
1161/// `callsite_promiscuous:true` / `callsite_promiscuous:false` — match nodes
1162/// flagged by the C indirect-call resolver as containing at least one
1163/// callsite that exceeded the per-callsite cardinality cap (DESIGN §5.2).
1164///
1165/// Backed by `NodeFlags::CALLSITE_PROMISCUOUS` via
1166/// [`crate::graph::unified::NodeMetadataStore::is_callsite_promiscuous`]. O(1)
1167/// hash lookup against the per-snapshot metadata store.
1168fn match_callsite_promiscuous(
1169    ctx: &GraphEvalContext,
1170    node_id: NodeId,
1171    operator: &Operator,
1172    value: &Value,
1173) -> bool {
1174    let Some(expected) = value_to_bool(value) else {
1175        return false;
1176    };
1177    match operator {
1178        Operator::Equal => ctx.graph.macro_metadata().is_callsite_promiscuous(node_id) == expected,
1179        _ => false,
1180    }
1181}
1182
1183/// `resolved_via:direct | type_match | binding_plane` — match nodes that have
1184/// at least one **outgoing** Calls edge with the requested resolution
1185/// provenance (DESIGN §6).
1186///
1187/// Direction note: we walk OUTGOING edges (`edges_from(node_id)`) — i.e.
1188/// "this function makes at least one call resolved via X". This mirrors
1189/// [`match_callers`]'s direction convention (callers walks OUTGOING Calls to
1190/// answer "does this node call the target?"), so a query like
1191/// `resolved_via:type_match AND callers:my_read` matches a caller whose
1192/// outgoing Calls edge to `my_read` was resolved by flat type matching.
1193///
1194/// Edge-level predicate — the field is `indexed: false` in the registry
1195/// because there is no flag-or-bitset shortcut: we must walk
1196/// `edges_from(node_id)` and inspect each `EdgeKind::Calls { resolved_via, ... }`.
1197fn match_resolved_via(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1198    let Some(want_str) = value.as_string() else {
1199        return false;
1200    };
1201    let want = match want_str {
1202        "direct" => crate::graph::unified::edge::ResolvedVia::Direct,
1203        "type_match" => crate::graph::unified::edge::ResolvedVia::TypeMatch,
1204        "binding_plane" => crate::graph::unified::edge::ResolvedVia::BindingPlane,
1205        "virtual_dispatch" => crate::graph::unified::edge::ResolvedVia::VirtualDispatch,
1206        "interface_dispatch" => crate::graph::unified::edge::ResolvedVia::InterfaceDispatch,
1207        "duck_typed" => crate::graph::unified::edge::ResolvedVia::DuckTyped,
1208        "structural" => crate::graph::unified::edge::ResolvedVia::Structural,
1209        "promiscuous_elided" => crate::graph::unified::edge::ResolvedVia::PromiscuousElided,
1210        _ => return false, // unknown enum value
1211    };
1212    for edge in ctx.graph.edges().edges_from(node_id) {
1213        if let EdgeKind::Calls { resolved_via, .. } = &edge.kind
1214            && *resolved_via == want
1215        {
1216            return true;
1217        }
1218    }
1219    false
1220}
1221
1222// ============================================================================
1223// Relation predicates (CORRECTED DIRECTION)
1224// ============================================================================
1225
1226/// `callers:X` - find symbols that CALL X
1227///
1228/// When evaluating node Y: does Y call X? → Check Y's OUTGOING edges
1229///
1230/// For dynamically-typed languages (Lua, Python, Ruby, etc.), method calls like
1231/// `target:method()` create edges to `receiver::method` where `receiver` is the
1232/// variable name, not the class name. To handle this, when querying for a qualified
1233/// name like `Class::method`, we also match call edges to `X::method` where X is
1234/// any receiver, as long as a symbol `Class::method` exists in the graph.
1235fn match_callers(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1236    let Some(target_name) = value.as_string() else {
1237        return false;
1238    };
1239
1240    // Extract the method name part for fallback matching in dynamic languages
1241    // e.g., "Player::takeDamage" -> "takeDamage"
1242    let method_part = extract_method_name(target_name);
1243
1244    // Check OUTGOING Calls edges from this node
1245    for edge in ctx.graph.edges().edges_from(node_id) {
1246        if let EdgeKind::Calls { .. } = &edge.kind
1247            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1248        {
1249            let callee_names = entry_query_texts(ctx.graph, target_entry);
1250
1251            if callee_names.iter().any(|callee_name| {
1252                language_aware_segments_match(
1253                    ctx.graph,
1254                    target_entry.file,
1255                    callee_name,
1256                    target_name,
1257                )
1258            }) {
1259                return true;
1260            }
1261
1262            // For dynamic languages: if target_name is qualified (e.g., "Player::takeDamage")
1263            // and callee is also qualified (e.g., "target::takeDamage"), match on method part
1264            // This handles cases where the receiver type isn't known at call time
1265            if let Some(method) = &method_part
1266                && callee_names
1267                    .iter()
1268                    .filter_map(|callee_name| extract_method_name(callee_name))
1269                    .any(|callee_method| method == &callee_method)
1270            {
1271                return true;
1272            }
1273        }
1274    }
1275    false
1276}
1277
1278/// Extract the method name from a qualified name.
1279/// e.g., "`Player::takeDamage`" -> Some("takeDamage")
1280/// e.g., "takeDamage" -> None (no separator, not qualified)
1281#[must_use]
1282pub fn extract_method_name(qualified: &str) -> Option<String> {
1283    // Look for separator from the end
1284    for sep in ["::", ".", "#", ":", "/"] {
1285        if let Some(pos) = qualified.rfind(sep) {
1286            let method = &qualified[pos + sep.len()..];
1287            if !method.is_empty() {
1288                return Some(method.to_string());
1289            }
1290        }
1291    }
1292    None
1293}
1294
1295/// `callees:X` - find symbols that ARE CALLED BY X
1296///
1297/// When evaluating node Y: does X call Y? → Check Y's INCOMING edges for source=X
1298fn match_callees(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1299    let Some(caller_name) = value.as_string() else {
1300        return false;
1301    };
1302
1303    // Check INCOMING Calls edges to this node
1304    for edge in ctx.graph.edges().edges_to(node_id) {
1305        if let EdgeKind::Calls { .. } = &edge.kind
1306            && let Some(source_entry) = ctx.graph.nodes().get(edge.source)
1307            && entry_query_texts(ctx.graph, source_entry)
1308                .iter()
1309                .any(|source_name| {
1310                    language_aware_segments_match(
1311                        ctx.graph,
1312                        source_entry.file,
1313                        source_name,
1314                        caller_name,
1315                    )
1316                })
1317        {
1318            return true;
1319        }
1320    }
1321    false
1322}
1323
1324/// `imports:X` — per-node match.
1325///
1326/// A node matches iff:
1327/// 1. It is itself an `Import` node whose name (or alias text) matches `X`, or
1328/// 2. It has at least one outgoing `Imports` edge whose target name, alias,
1329///    or wildcard flag matches `X` (see [`import_edge_matches`]).
1330///
1331/// This is the planner-canonical semantic per
1332/// `docs/development/phase-n-structural-semantics/02_DESIGN.md` §6 — every
1333/// transport (planner, MCP, CLI, LSP) shares it. The previous file-scoped
1334/// behavior ("every node in a file that imports X matches") was retired in
1335/// DB15 to remove the cross-engine divergence flagged by Codex's DB14
1336/// review. Matches the set returned by
1337/// [`sqry_db::queries::ImportsQuery`](https://docs.rs/sqry-db) for the same
1338/// key.
1339fn match_imports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1340    let Some(target_module) = value.as_string() else {
1341        return false;
1342    };
1343
1344    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1345        return false;
1346    };
1347
1348    if entry.kind == NodeKind::Import && import_entry_matches(ctx.graph, entry, target_module) {
1349        return true;
1350    }
1351
1352    for edge in ctx.graph.edges().edges_from(node_id) {
1353        if import_edge_matches(ctx.graph, &edge, target_module) {
1354            return true;
1355        }
1356    }
1357    false
1358}
1359
1360/// Returns `true` when the given `Imports` edge imports something matching
1361/// `target_module`, checking the target node text, the edge's alias, and the
1362/// wildcard flag. Shared with [`crate::graph::unified::concurrent::GraphSnapshot`]
1363/// consumers (including `sqry-db::queries::relation`) that need the same
1364/// semantics as the graph-native `imports:` predicate.
1365#[must_use]
1366pub fn import_edge_matches<G: crate::graph::unified::concurrent::GraphAccess>(
1367    graph: &G,
1368    edge: &StoreEdgeRef,
1369    target_module: &str,
1370) -> bool {
1371    let EdgeKind::Imports { alias, is_wildcard } = &edge.kind else {
1372        return false;
1373    };
1374
1375    // Check target node text across simple, canonical, and native-display forms.
1376    let target_match = graph
1377        .nodes()
1378        .get(edge.target)
1379        .is_some_and(|entry| import_entry_matches(graph, entry, target_module));
1380
1381    // Check alias
1382    let alias_match = alias
1383        .and_then(|sid| graph.strings().resolve(sid))
1384        .is_some_and(|alias_str| {
1385            graph.nodes().get(edge.source).is_some_and(|entry| {
1386                import_text_matches(graph, entry.file, alias_str.as_ref(), target_module)
1387            })
1388        });
1389
1390    // Check wildcard
1391    let wildcard_match = *is_wildcard && target_module == "*";
1392
1393    target_match || alias_match || wildcard_match
1394}
1395
1396/// Substring-based text match for `imports:` semantics, with language-aware
1397/// canonicalization fallback when the file's language maps the input module
1398/// path into graph-internal `::` form.
1399#[must_use]
1400pub fn import_text_matches<G: crate::graph::unified::concurrent::GraphAccess>(
1401    graph: &G,
1402    file_id: FileId,
1403    candidate: &str,
1404    target_module: &str,
1405) -> bool {
1406    if candidate.contains(target_module) {
1407        return true;
1408    }
1409
1410    graph
1411        .files()
1412        .language_for_file(file_id)
1413        .is_some_and(|language| {
1414            let canonical_target = canonicalize_graph_qualified_name(language, target_module);
1415            canonical_target != target_module && candidate.contains(&canonical_target)
1416        })
1417}
1418
1419/// Matches an Import/candidate node entry against a target module using
1420/// the shared `imports:` substring + canonicalization semantics.
1421#[must_use]
1422pub fn import_entry_matches<G: crate::graph::unified::concurrent::GraphAccess>(
1423    graph: &G,
1424    entry: &NodeEntry,
1425    target_module: &str,
1426) -> bool {
1427    entry_query_texts(graph, entry)
1428        .iter()
1429        .any(|candidate| import_text_matches(graph, entry.file, candidate, target_module))
1430}
1431
1432/// Segment-aware name equality with a language-specific canonicalization
1433/// fallback. First tries a direct [`segments_match`]; if that fails, the
1434/// file's language (if any) is consulted to canonicalize `expected` into
1435/// graph-internal `::` form before retrying.
1436#[must_use]
1437pub fn language_aware_segments_match<G: crate::graph::unified::concurrent::GraphAccess>(
1438    graph: &G,
1439    file_id: FileId,
1440    candidate: &str,
1441    expected: &str,
1442) -> bool {
1443    if segments_match(candidate, expected) {
1444        return true;
1445    }
1446
1447    graph
1448        .files()
1449        .language_for_file(file_id)
1450        .is_some_and(|language| {
1451            let canonical_expected = canonicalize_graph_qualified_name(language, expected);
1452            canonical_expected != expected && segments_match(candidate, &canonical_expected)
1453        })
1454}
1455
1456fn push_unique_query_text(texts: &mut Vec<String>, candidate: impl Into<String>) {
1457    let candidate = candidate.into();
1458    if !texts.iter().any(|existing| existing == &candidate) {
1459        texts.push(candidate);
1460    }
1461}
1462
1463/// Collects every string form that can satisfy a name query for the given
1464/// node entry: the interned name, the qualified name, and (when a language
1465/// is recorded) the display-qualified name produced by
1466/// [`display_graph_qualified_name`]. Duplicates are dropped so that relation
1467/// matchers do not re-check equivalent forms.
1468#[must_use]
1469pub fn entry_query_texts<G: crate::graph::unified::concurrent::GraphAccess>(
1470    graph: &G,
1471    entry: &NodeEntry,
1472) -> Vec<String> {
1473    let mut texts = Vec::with_capacity(3);
1474
1475    if let Some(name) = graph.strings().resolve(entry.name) {
1476        push_unique_query_text(&mut texts, name.to_string());
1477    }
1478
1479    if let Some(qualified) = entry
1480        .qualified_name
1481        .and_then(|qualified_name_id| graph.strings().resolve(qualified_name_id))
1482    {
1483        push_unique_query_text(&mut texts, qualified.to_string());
1484
1485        if let Some(language) = graph.files().language_for_file(entry.file) {
1486            push_unique_query_text(
1487                &mut texts,
1488                display_graph_qualified_name(
1489                    language,
1490                    qualified.as_ref(),
1491                    entry.kind,
1492                    entry.is_static,
1493                ),
1494            );
1495        }
1496    }
1497
1498    texts
1499}
1500
1501/// `exports:X` - find symbols that export X
1502///
1503/// File scope filtering: Only considers export edges where both source and
1504/// target are in the same file as the node being evaluated. This prevents
1505/// re-export edges from causing symbols in other files to match.
1506fn match_exports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1507    let Some(target_name) = value.as_string() else {
1508        return false;
1509    };
1510
1511    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1512        return false;
1513    };
1514    let node_file = entry.file;
1515
1516    if !entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
1517        language_aware_segments_match(ctx.graph, entry.file, candidate, target_name)
1518    }) {
1519        return false;
1520    }
1521
1522    let edges = ctx.graph.edges();
1523
1524    // Check OUTGOING export edges: this node exports something
1525    for edge in edges.edges_from(node_id) {
1526        if let EdgeKind::Exports { .. } = &edge.kind {
1527            // File scope filtering: export edge must be in same file
1528            if let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1529                && target_entry.file == node_file
1530            {
1531                return true;
1532            }
1533        }
1534    }
1535
1536    // Check INCOMING export edges: something exports this node
1537    for edge in edges.edges_to(node_id) {
1538        if let EdgeKind::Exports { .. } = &edge.kind {
1539            // File scope filtering: export edge source must be in same file
1540            if let Some(source_entry) = ctx.graph.nodes().get(edge.source)
1541                && source_entry.file == node_file
1542            {
1543                return true;
1544            }
1545        }
1546    }
1547
1548    false
1549}
1550
1551/// `references:X` - find symbols named X that have references TO them.
1552///
1553/// Current behavior: references include `Calls`, `Imports`, `FfiCall`, and `References` edges.
1554fn match_references(
1555    ctx: &GraphEvalContext,
1556    node_id: NodeId,
1557    operator: &Operator,
1558    value: &Value,
1559) -> bool {
1560    // First check if the symbol name matches the target
1561    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1562        return false;
1563    };
1564
1565    let name_matches = match (operator, value) {
1566        (Operator::Equal, Value::String(target)) => entry_query_texts(ctx.graph, entry)
1567            .iter()
1568            .any(|candidate| candidate == target || candidate.ends_with(&format!("::{target}"))),
1569        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1570            &rv.pattern,
1571            rv.flags.case_insensitive,
1572            rv.flags.multiline,
1573            rv.flags.dot_all,
1574        )
1575        .map(|re| {
1576            entry_query_texts(ctx.graph, entry)
1577                .iter()
1578                .any(|candidate| regex_is_match(&re, candidate))
1579        })
1580        .unwrap_or(false),
1581        _ => false,
1582    };
1583
1584    if !name_matches {
1585        return false;
1586    }
1587
1588    // Check if the symbol has references TO it (incoming edges)
1589    // Current behavior: References, Calls, Imports, AND FfiCall all count as references
1590    for edge in ctx.graph.edges().edges_to(node_id) {
1591        let is_reference = matches!(
1592            &edge.kind,
1593            EdgeKind::References
1594                | EdgeKind::Calls { .. }
1595                | EdgeKind::Imports { .. }
1596                | EdgeKind::FfiCall { .. }
1597        );
1598        if is_reference {
1599            return true;
1600        }
1601    }
1602
1603    false
1604}
1605
1606/// `implements:X` - find symbols that implement interface/trait X
1607fn match_implements(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1608    let Some(trait_name) = value.as_string() else {
1609        return false;
1610    };
1611
1612    for edge in ctx.graph.edges().edges_from(node_id) {
1613        if let EdgeKind::Implements = &edge.kind
1614            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1615            && entry_query_texts(ctx.graph, target_entry)
1616                .iter()
1617                .any(|name| {
1618                    language_aware_segments_match(ctx.graph, target_entry.file, name, trait_name)
1619                })
1620        {
1621            return true;
1622        }
1623    }
1624    false
1625}
1626
1627// ============================================================================
1628// Scope predicates (with regex support)
1629// ============================================================================
1630
1631/// Convert `NodeKind` to scope type string for predicate matching.
1632///
1633/// This mapping provides parity with legacy index scope predicates:
1634/// - Trait → interface
1635/// - Test → function
1636/// - Service → class
1637/// - etc.
1638fn node_kind_to_scope_type(kind: NodeKind) -> &'static str {
1639    match kind {
1640        NodeKind::Function | NodeKind::Test => "function",
1641        NodeKind::Method => "method",
1642        NodeKind::Class | NodeKind::Service => "class",
1643        NodeKind::Interface | NodeKind::Trait => "interface",
1644        NodeKind::Struct => "struct",
1645        NodeKind::Enum => "enum",
1646        NodeKind::Module => "module",
1647        NodeKind::Macro => "macro",
1648        NodeKind::Component => "component",
1649        NodeKind::Resource | NodeKind::Endpoint => "resource",
1650        // Non-container types
1651        NodeKind::Variable => "variable",
1652        NodeKind::Constant => "constant",
1653        NodeKind::Type => "type",
1654        NodeKind::EnumVariant => "enumvariant",
1655        NodeKind::Import => "import",
1656        NodeKind::Export => "export",
1657        NodeKind::CallSite => "callsite",
1658        NodeKind::Parameter => "parameter",
1659        NodeKind::Property => "property",
1660        NodeKind::StyleRule => "style_rule",
1661        NodeKind::StyleAtRule => "style_at_rule",
1662        NodeKind::StyleVariable => "style_variable",
1663        NodeKind::Lifetime => "lifetime",
1664        NodeKind::TypeParameter => "type_parameter",
1665        NodeKind::Annotation => "annotation",
1666        NodeKind::AnnotationValue => "annotation_value",
1667        NodeKind::LambdaTarget => "lambda_target",
1668        NodeKind::JavaModule => "java_module",
1669        NodeKind::EnumConstant => "enum_constant",
1670        NodeKind::Other => "other",
1671    }
1672}
1673
1674fn match_scope(
1675    ctx: &GraphEvalContext,
1676    node_id: NodeId,
1677    field: &str,
1678    operator: &Operator,
1679    value: &Value,
1680) -> bool {
1681    let scope_part = field.strip_prefix("scope.").unwrap_or("");
1682    match scope_part {
1683        "type" => match_scope_type(ctx, node_id, operator, value),
1684        "name" => match_scope_name(ctx, node_id, operator, value),
1685        "parent" => match_scope_parent_name(ctx, node_id, operator, value),
1686        "ancestor" => match_scope_ancestor_name(ctx, node_id, operator, value),
1687        _ => false,
1688    }
1689}
1690
1691fn match_scope_type(
1692    ctx: &GraphEvalContext,
1693    node_id: NodeId,
1694    operator: &Operator,
1695    value: &Value,
1696) -> bool {
1697    for edge in ctx.graph.edges().edges_to(node_id) {
1698        if let EdgeKind::Contains = &edge.kind
1699            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1700        {
1701            // Use mapped scope type for parity with legacy index
1702            let scope_type = node_kind_to_scope_type(parent.kind);
1703            return match (operator, value) {
1704                // Case-sensitive comparison
1705                (Operator::Equal, Value::String(exp)) => scope_type == exp,
1706                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1707                    &rv.pattern,
1708                    rv.flags.case_insensitive,
1709                    rv.flags.multiline,
1710                    rv.flags.dot_all,
1711                )
1712                .map(|re| regex_is_match(&re, scope_type))
1713                .unwrap_or(false),
1714                _ => false,
1715            };
1716        }
1717    }
1718    false
1719}
1720
1721fn match_scope_name(
1722    ctx: &GraphEvalContext,
1723    node_id: NodeId,
1724    operator: &Operator,
1725    value: &Value,
1726) -> bool {
1727    for edge in ctx.graph.edges().edges_to(node_id) {
1728        if let EdgeKind::Contains = &edge.kind
1729            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1730            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1731        {
1732            return match (operator, value) {
1733                // Use segments_match for qualified name suffix matching
1734                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1735                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1736                    &rv.pattern,
1737                    rv.flags.case_insensitive,
1738                    rv.flags.multiline,
1739                    rv.flags.dot_all,
1740                )
1741                .map(|re| regex_is_match(&re, &name))
1742                .unwrap_or(false),
1743                _ => false,
1744            };
1745        }
1746    }
1747    false
1748}
1749
1750/// `scope.parent:X` - find nodes with immediate parent NAMED X.
1751///
1752/// Uses `segments_match` for qualified name suffix matching (same as name: predicate).
1753fn match_scope_parent_name(
1754    ctx: &GraphEvalContext,
1755    node_id: NodeId,
1756    operator: &Operator,
1757    value: &Value,
1758) -> bool {
1759    for edge in ctx.graph.edges().edges_to(node_id) {
1760        if let EdgeKind::Contains = &edge.kind
1761            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1762            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1763        {
1764            return match (operator, value) {
1765                // Use segments_match for qualified name suffix matching
1766                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1767                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1768                    &rv.pattern,
1769                    rv.flags.case_insensitive,
1770                    rv.flags.multiline,
1771                    rv.flags.dot_all,
1772                )
1773                .map(|re| regex_is_match(&re, &name))
1774                .unwrap_or(false),
1775                _ => false,
1776            };
1777        }
1778    }
1779    false
1780}
1781
1782/// `scope.ancestor:X` - find nodes with any ancestor NAMED X.
1783///
1784/// CYCLE PROTECTION: Uses visited set to prevent infinite loops.
1785/// Uses `segments_match` for qualified name suffix matching.
1786fn match_scope_ancestor_name(
1787    ctx: &GraphEvalContext,
1788    node_id: NodeId,
1789    operator: &Operator,
1790    value: &Value,
1791) -> bool {
1792    let mut current = node_id;
1793    let mut visited = HashSet::new();
1794    visited.insert(node_id);
1795
1796    loop {
1797        let mut found_parent = false;
1798        for edge in ctx.graph.edges().edges_to(current) {
1799            if let EdgeKind::Contains = &edge.kind {
1800                // CYCLE PROTECTION: Skip if we've already visited this node
1801                if visited.contains(&edge.source) {
1802                    continue;
1803                }
1804                visited.insert(edge.source);
1805
1806                found_parent = true;
1807                current = edge.source;
1808                if let Some(parent) = ctx.graph.nodes().get(current)
1809                    && let Some(name) = ctx.graph.strings().resolve(parent.name)
1810                {
1811                    let matches = match (operator, value) {
1812                        // Use segments_match for qualified name suffix matching
1813                        (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1814                        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1815                            &rv.pattern,
1816                            rv.flags.case_insensitive,
1817                            rv.flags.multiline,
1818                            rv.flags.dot_all,
1819                        )
1820                        .map(|re| regex_is_match(&re, &name))
1821                        .unwrap_or(false),
1822                        _ => false,
1823                    };
1824                    if matches {
1825                        return true;
1826                    }
1827                }
1828                break;
1829            }
1830        }
1831        if !found_parent {
1832            break;
1833        }
1834    }
1835    false
1836}
1837
1838// ============================================================================
1839// Subquery evaluation
1840// ============================================================================
1841
1842/// Evaluate an expression against all nodes and return the set of matching node IDs.
1843///
1844/// Used for subquery evaluation: `callers:(kind:function AND async:true)`.
1845///
1846/// # Errors
1847///
1848/// Returns an error if predicate evaluation fails.
1849pub fn evaluate_subquery(ctx: &GraphEvalContext, expr: &Expr) -> Result<HashSet<NodeId>> {
1850    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
1851    let expr_depth = recursion_limits.effective_expr_depth()?;
1852    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
1853
1854    let arena = ctx.graph.nodes();
1855    let mut matches = HashSet::new();
1856    for (id, _) in arena.iter() {
1857        if evaluate_node(ctx, id, expr, &mut guard)? {
1858            matches.insert(id);
1859        }
1860    }
1861    Ok(matches)
1862}
1863
1864/// Match callers using a precomputed subquery result set.
1865///
1866/// Checks if any of this node's outgoing Calls edges target a node in the
1867/// precomputed set. Returns an error if the subquery was not precomputed
1868/// (indicates a bug in `precompute_subqueries`).
1869fn match_callers_subquery(
1870    ctx: &GraphEvalContext,
1871    node_id: NodeId,
1872    subquery_matches: Option<&HashSet<NodeId>>,
1873) -> Result<bool> {
1874    let Some(matches) = subquery_matches else {
1875        return Err(anyhow!(
1876            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1877        ));
1878    };
1879    for edge in ctx.graph.edges().edges_from(node_id) {
1880        if let EdgeKind::Calls { .. } = &edge.kind
1881            && matches.contains(&edge.target)
1882        {
1883            return Ok(true);
1884        }
1885    }
1886    Ok(false)
1887}
1888
1889/// Match callees using a precomputed subquery result set.
1890fn match_callees_subquery(
1891    ctx: &GraphEvalContext,
1892    node_id: NodeId,
1893    subquery_matches: Option<&HashSet<NodeId>>,
1894) -> Result<bool> {
1895    let Some(matches) = subquery_matches else {
1896        return Err(anyhow!(
1897            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1898        ));
1899    };
1900    for edge in ctx.graph.edges().edges_to(node_id) {
1901        if let EdgeKind::Calls { .. } = &edge.kind
1902            && matches.contains(&edge.source)
1903        {
1904            return Ok(true);
1905        }
1906    }
1907    Ok(false)
1908}
1909
1910/// Match imports using a precomputed subquery result set.
1911///
1912/// Per-node semantic (DB15): returns true iff this specific node has an
1913/// outgoing `Imports` edge whose target is in the precomputed subquery set.
1914/// Aligned with [`match_imports`] and `sqry_db::queries::ImportsQuery`.
1915fn match_imports_subquery(
1916    ctx: &GraphEvalContext,
1917    node_id: NodeId,
1918    subquery_matches: Option<&HashSet<NodeId>>,
1919) -> Result<bool> {
1920    let Some(matches) = subquery_matches else {
1921        return Err(anyhow!(
1922            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1923        ));
1924    };
1925    for edge in ctx.graph.edges().edges_from(node_id) {
1926        if let EdgeKind::Imports { .. } = &edge.kind
1927            && matches.contains(&edge.target)
1928        {
1929            return Ok(true);
1930        }
1931    }
1932    Ok(false)
1933}
1934
1935/// Match exports using a precomputed subquery result set.
1936fn match_exports_subquery(
1937    ctx: &GraphEvalContext,
1938    node_id: NodeId,
1939    subquery_matches: Option<&HashSet<NodeId>>,
1940) -> Result<bool> {
1941    let Some(matches) = subquery_matches else {
1942        return Err(anyhow!(
1943            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1944        ));
1945    };
1946    for edge in ctx.graph.edges().edges_from(node_id) {
1947        if let EdgeKind::Exports { .. } = &edge.kind
1948            && matches.contains(&edge.target)
1949        {
1950            return Ok(true);
1951        }
1952    }
1953    Ok(false)
1954}
1955
1956/// Match implements using a precomputed subquery result set.
1957fn match_implements_subquery(
1958    ctx: &GraphEvalContext,
1959    node_id: NodeId,
1960    subquery_matches: Option<&HashSet<NodeId>>,
1961) -> Result<bool> {
1962    let Some(matches) = subquery_matches else {
1963        return Err(anyhow!(
1964            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1965        ));
1966    };
1967    for edge in ctx.graph.edges().edges_from(node_id) {
1968        if let EdgeKind::Implements = &edge.kind
1969            && matches.contains(&edge.target)
1970        {
1971            return Ok(true);
1972        }
1973    }
1974    Ok(false)
1975}
1976
1977/// Match references using a precomputed subquery result set.
1978fn match_references_subquery(
1979    ctx: &GraphEvalContext,
1980    node_id: NodeId,
1981    subquery_matches: Option<&HashSet<NodeId>>,
1982) -> Result<bool> {
1983    let Some(matches) = subquery_matches else {
1984        return Err(anyhow!(
1985            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1986        ));
1987    };
1988    for edge in ctx.graph.edges().edges_to(node_id) {
1989        let is_reference = matches!(
1990            &edge.kind,
1991            EdgeKind::References
1992                | EdgeKind::Calls { .. }
1993                | EdgeKind::Imports { .. }
1994                | EdgeKind::FfiCall { .. }
1995        );
1996        if is_reference && matches.contains(&edge.source) {
1997            return Ok(true);
1998        }
1999    }
2000    Ok(false)
2001}
2002
2003// ============================================================================
2004// Join evaluation
2005// ============================================================================
2006
2007/// Evaluate a join expression, returning matched (left, right) node pairs.
2008///
2009/// For `(kind:function AND lang:rust) CALLS (kind:function AND lang:python)`:
2010/// 1. Evaluate LHS query → set of matching nodes
2011/// 2. Evaluate RHS query → set of matching nodes
2012/// 3. For each LHS node, check edges of the specified kind
2013/// 4. If edge target is in RHS set → add (lhs, rhs) pair
2014///
2015/// # Errors
2016///
2017/// Returns an error if subquery evaluation or edge traversal fails.
2018pub fn evaluate_join(
2019    ctx: &GraphEvalContext,
2020    join: &JoinExpr,
2021    max_results: Option<usize>,
2022) -> Result<JoinEvalResult> {
2023    let lhs_matches = evaluate_subquery(ctx, &join.left)?;
2024    let rhs_matches = evaluate_subquery(ctx, &join.right)?;
2025    let cap = max_results.unwrap_or(DEFAULT_JOIN_RESULT_CAP);
2026
2027    let mut pairs = Vec::new();
2028    let mut truncated = false;
2029    'outer: for &lhs_id in &lhs_matches {
2030        for edge in ctx.graph.edges().edges_from(lhs_id) {
2031            if edge_matches_join_kind(&edge.kind, &join.edge) && rhs_matches.contains(&edge.target)
2032            {
2033                pairs.push((lhs_id, edge.target));
2034                if pairs.len() >= cap {
2035                    truncated = true;
2036                    break 'outer;
2037                }
2038            }
2039        }
2040    }
2041    Ok(JoinEvalResult { pairs, truncated })
2042}
2043
2044/// Result of a join evaluation including truncation metadata.
2045pub struct JoinEvalResult {
2046    /// The matched (source, target) node ID pairs.
2047    pub pairs: Vec<(NodeId, NodeId)>,
2048    /// Whether the result set was truncated by the result cap.
2049    pub truncated: bool,
2050}
2051
2052/// Default result cap for join queries.
2053///
2054/// Prevents unbounded memory growth when a join produces a large number of matching pairs.
2055const DEFAULT_JOIN_RESULT_CAP: usize = 10_000;
2056
2057/// Check if an edge kind matches a join edge kind.
2058fn edge_matches_join_kind(edge_kind: &EdgeKind, join_kind: &JoinEdgeKind) -> bool {
2059    match join_kind {
2060        JoinEdgeKind::Calls => matches!(edge_kind, EdgeKind::Calls { .. }),
2061        JoinEdgeKind::Imports => matches!(edge_kind, EdgeKind::Imports { .. }),
2062        JoinEdgeKind::Inherits => matches!(edge_kind, EdgeKind::Inherits),
2063        JoinEdgeKind::Implements => matches!(edge_kind, EdgeKind::Implements),
2064    }
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::*;
2070    use crate::graph::node::Language;
2071    use crate::query::types::{Condition, Field, Span};
2072    use std::path::Path;
2073
2074    #[test]
2075    fn test_import_text_matches_canonicalized_qualified_imports() {
2076        let mut graph = CodeGraph::new();
2077        let file_id = graph
2078            .files_mut()
2079            .register(Path::new("src/FileProcessor.cs"))
2080            .unwrap();
2081        assert!(graph.files_mut().set_language(file_id, Language::CSharp));
2082
2083        assert!(import_text_matches(
2084            &graph,
2085            file_id,
2086            "System::IO",
2087            "System.IO"
2088        ));
2089        assert!(import_text_matches(
2090            &graph,
2091            file_id,
2092            "System::Collections::Generic",
2093            "System.Collections.Generic"
2094        ));
2095        assert!(!import_text_matches(
2096            &graph,
2097            file_id,
2098            "System::Text",
2099            "System.IO"
2100        ));
2101    }
2102
2103    #[test]
2104    fn test_language_aware_segments_match_supports_ruby_method_separators() {
2105        let mut graph = CodeGraph::new();
2106        let file_id = graph
2107            .files_mut()
2108            .register(Path::new("app/models/user.rb"))
2109            .unwrap();
2110        assert!(graph.files_mut().set_language(file_id, Language::Ruby));
2111
2112        assert!(language_aware_segments_match(
2113            &graph,
2114            file_id,
2115            "Admin::Users::Controller::show",
2116            "Admin::Users::Controller#show"
2117        ));
2118        assert!(language_aware_segments_match(
2119            &graph,
2120            file_id,
2121            "Admin::Users::Controller::show",
2122            "show"
2123        ));
2124        assert!(!language_aware_segments_match(
2125            &graph,
2126            file_id,
2127            "Admin::Users::Controller::index",
2128            "Admin::Users::Controller#show"
2129        ));
2130    }
2131
2132    #[test]
2133    fn test_normalize_kind() {
2134        // Case-sensitive synonyms
2135        assert_eq!(normalize_kind("trait"), "interface");
2136        assert_eq!(normalize_kind("TRAIT"), "TRAIT"); // Case-sensitive: not a synonym
2137        assert_eq!(normalize_kind("field"), "property");
2138        assert_eq!(normalize_kind("namespace"), "module");
2139        assert_eq!(normalize_kind("function"), "function"); // unchanged
2140    }
2141
2142    #[test]
2143    fn test_graph_eval_context_builder() {
2144        let graph = CodeGraph::new();
2145        let pm = PluginManager::new();
2146        let ctx = GraphEvalContext::new(&graph, &pm)
2147            .with_workspace_root(Path::new("/test"))
2148            .with_parallel_disabled(true);
2149
2150        assert!(ctx.disable_parallel);
2151        assert_eq!(ctx.workspace_root, Some(Path::new("/test")));
2152    }
2153
2154    // ================================================================
2155    // collect_subquery_exprs tests
2156    // ================================================================
2157
2158    /// Helper: build `field:(inner_expr)` as a Condition with `Value::Subquery`.
2159    fn subquery_condition(field: &str, inner: Expr, start: usize, end: usize) -> Expr {
2160        Expr::Condition(Condition {
2161            field: Field(field.to_string()),
2162            operator: Operator::Equal,
2163            value: Value::Subquery(Box::new(inner)),
2164            span: Span::with_position(start, end, 1, start + 1),
2165        })
2166    }
2167
2168    /// Helper: build a simple `kind:function` condition.
2169    fn kind_condition(kind: &str) -> Expr {
2170        Expr::Condition(Condition {
2171            field: Field("kind".to_string()),
2172            operator: Operator::Equal,
2173            value: Value::String(kind.to_string()),
2174            span: Span::default(),
2175        })
2176    }
2177
2178    #[test]
2179    fn test_collect_subquery_exprs_post_order_depth_2() {
2180        // Build: callers:(callees:(kind:function))
2181        // Inner subquery: callees:(kind:function) at span (20, 40)
2182        // Outer subquery: callers:(...) at span (0, 50)
2183        let inner_subquery = subquery_condition("callees", kind_condition("function"), 20, 40);
2184        let outer_subquery = subquery_condition("callers", inner_subquery, 0, 50);
2185
2186        let mut out = Vec::new();
2187        collect_subquery_exprs(&outer_subquery, &mut out);
2188
2189        // Post-order: inner appears before outer
2190        assert_eq!(
2191            out.len(),
2192            2,
2193            "should collect both inner and outer subqueries"
2194        );
2195        assert_eq!(out[0].0, (20, 40), "inner subquery span should come first");
2196        assert_eq!(out[1].0, (0, 50), "outer subquery span should come second");
2197    }
2198
2199    #[test]
2200    fn test_collect_subquery_exprs_post_order_depth_3() {
2201        // Build: callers:(callees:(imports:(kind:function)))
2202        let innermost = subquery_condition("imports", kind_condition("function"), 30, 50);
2203        let middle = subquery_condition("callees", innermost, 15, 55);
2204        let outer = subquery_condition("callers", middle, 0, 60);
2205
2206        let mut out = Vec::new();
2207        collect_subquery_exprs(&outer, &mut out);
2208
2209        assert_eq!(out.len(), 3, "should collect all three nested subqueries");
2210        assert_eq!(out[0].0, (30, 50), "innermost should come first");
2211        assert_eq!(out[1].0, (15, 55), "middle should come second");
2212        assert_eq!(out[2].0, (0, 60), "outer should come last");
2213    }
2214
2215    #[test]
2216    fn test_collect_subquery_exprs_and_or_branches() {
2217        // Build: callers:(kind:function) AND callees:(kind:method)
2218        let left = subquery_condition("callers", kind_condition("function"), 0, 25);
2219        let right = subquery_condition("callees", kind_condition("method"), 30, 55);
2220        let expr = Expr::And(vec![left, right]);
2221
2222        let mut out = Vec::new();
2223        collect_subquery_exprs(&expr, &mut out);
2224
2225        assert_eq!(out.len(), 2, "should collect subqueries from both branches");
2226        assert_eq!(out[0].0, (0, 25), "left branch subquery");
2227        assert_eq!(out[1].0, (30, 55), "right branch subquery");
2228    }
2229
2230    #[test]
2231    fn test_collect_subquery_exprs_no_subqueries() {
2232        // Simple condition with no subqueries: kind:function
2233        let expr = kind_condition("function");
2234
2235        let mut out = Vec::new();
2236        collect_subquery_exprs(&expr, &mut out);
2237
2238        assert!(
2239            out.is_empty(),
2240            "should collect nothing for plain conditions"
2241        );
2242    }
2243
2244    // ================================================================
2245    // FfiCall edge in references/referenced_by predicates
2246    // ================================================================
2247
2248    use crate::graph::unified::edge::{BidirectionalEdgeStore, FfiConvention};
2249    use crate::graph::unified::storage::{
2250        AuxiliaryIndices, FileRegistry, NodeArena, StringInterner,
2251    };
2252
2253    /// Build a `CodeGraph` with: `caller --FfiCall(C)--> target`
2254    fn build_ffi_graph() -> (CodeGraph, NodeId, NodeId) {
2255        let mut arena = NodeArena::new();
2256        let edges = BidirectionalEdgeStore::new();
2257        let mut strings = StringInterner::new();
2258        let mut files = FileRegistry::new();
2259        let mut indices = AuxiliaryIndices::new();
2260
2261        let caller_name = strings.intern("caller_fn").unwrap();
2262        let target_name = strings.intern("ffi_target").unwrap();
2263        let file_id = files.register(Path::new("test.r")).unwrap();
2264
2265        let caller_id = arena
2266            .alloc(NodeEntry {
2267                kind: NodeKind::Function,
2268                name: caller_name,
2269                file: file_id,
2270                start_byte: 0,
2271                end_byte: 100,
2272                start_line: 1,
2273                start_column: 0,
2274                end_line: 5,
2275                end_column: 0,
2276                signature: None,
2277                doc: None,
2278                qualified_name: None,
2279                visibility: None,
2280                is_async: false,
2281                is_static: false,
2282                is_unsafe: false,
2283                body_hash: None,
2284            })
2285            .unwrap();
2286
2287        let target_id = arena
2288            .alloc(NodeEntry {
2289                kind: NodeKind::Function,
2290                name: target_name,
2291                file: file_id,
2292                start_byte: 200,
2293                end_byte: 300,
2294                start_line: 10,
2295                start_column: 0,
2296                end_line: 15,
2297                end_column: 0,
2298                signature: None,
2299                doc: None,
2300                qualified_name: None,
2301                visibility: None,
2302                is_async: false,
2303                is_static: false,
2304                is_unsafe: false,
2305                body_hash: None,
2306            })
2307            .unwrap();
2308
2309        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
2310        indices.add(target_id, NodeKind::Function, target_name, None, file_id);
2311
2312        edges.add_edge(
2313            caller_id,
2314            target_id,
2315            EdgeKind::FfiCall {
2316                convention: FfiConvention::C,
2317            },
2318            file_id,
2319        );
2320
2321        let graph = CodeGraph::from_components(
2322            arena,
2323            edges,
2324            strings,
2325            files,
2326            indices,
2327            crate::graph::unified::NodeMetadataStore::new(),
2328        );
2329        (graph, caller_id, target_id)
2330    }
2331
2332    #[test]
2333    fn test_ffi_call_edge_in_references_predicate() {
2334        let (graph, _caller_id, target_id) = build_ffi_graph();
2335        let pm = PluginManager::new();
2336        let ctx = GraphEvalContext::new(&graph, &pm);
2337
2338        // `references:ffi_target` should match because there is an FfiCall edge to it
2339        let result = match_references(
2340            &ctx,
2341            target_id,
2342            &Operator::Equal,
2343            &Value::String("ffi_target".to_string()),
2344        );
2345        assert!(result, "references: predicate should match FfiCall edges");
2346    }
2347
2348    #[test]
2349    fn test_ffi_call_edge_in_references_subquery() {
2350        let (graph, caller_id, target_id) = build_ffi_graph();
2351        let pm = PluginManager::new();
2352        let ctx = GraphEvalContext::new(&graph, &pm);
2353
2354        // Simulate a subquery that matched the caller node
2355        let mut subquery_results = HashSet::new();
2356        subquery_results.insert(caller_id);
2357
2358        // match_references_subquery checks incoming edges to target_id
2359        // and verifies the source is in the subquery result set
2360        let result = match_references_subquery(&ctx, target_id, Some(&subquery_results)).unwrap();
2361        assert!(
2362            result,
2363            "references subquery should match FfiCall edge sources"
2364        );
2365    }
2366
2367    // ================================================================
2368    // returns: predicate (edge-based, byte-exact)
2369    // ================================================================
2370
2371    /// Build a `CodeGraph` with two functions and an `error` type node:
2372    /// - `returner_fn` --TypeOf{Return}--> `error`
2373    /// - `plain_fn` (no outgoing TypeOf edges)
2374    ///
2375    /// Returns `(graph, returner_id, plain_id, error_type_id)`.
2376    fn build_returns_graph() -> (CodeGraph, NodeId, NodeId, NodeId) {
2377        let mut arena = NodeArena::new();
2378        let edges = BidirectionalEdgeStore::new();
2379        let mut strings = StringInterner::new();
2380        let mut files = FileRegistry::new();
2381        let mut indices = AuxiliaryIndices::new();
2382
2383        let returner_name = strings.intern("returner_fn").unwrap();
2384        let plain_name = strings.intern("plain_fn").unwrap();
2385        let error_name = strings.intern("error").unwrap();
2386        let file_id = files.register(Path::new("test.go")).unwrap();
2387
2388        let returner_id = arena
2389            .alloc(NodeEntry {
2390                kind: NodeKind::Function,
2391                name: returner_name,
2392                file: file_id,
2393                start_byte: 0,
2394                end_byte: 100,
2395                start_line: 1,
2396                start_column: 0,
2397                end_line: 5,
2398                end_column: 0,
2399                signature: None,
2400                doc: None,
2401                qualified_name: None,
2402                visibility: None,
2403                is_async: false,
2404                is_static: false,
2405                is_unsafe: false,
2406                body_hash: None,
2407            })
2408            .unwrap();
2409
2410        let plain_id = arena
2411            .alloc(NodeEntry {
2412                kind: NodeKind::Function,
2413                name: plain_name,
2414                file: file_id,
2415                start_byte: 200,
2416                end_byte: 300,
2417                start_line: 10,
2418                start_column: 0,
2419                end_line: 15,
2420                end_column: 0,
2421                signature: None,
2422                doc: None,
2423                qualified_name: None,
2424                visibility: None,
2425                is_async: false,
2426                is_static: false,
2427                is_unsafe: false,
2428                body_hash: None,
2429            })
2430            .unwrap();
2431
2432        let error_type_id = arena
2433            .alloc(NodeEntry {
2434                kind: NodeKind::Type,
2435                name: error_name,
2436                file: file_id,
2437                start_byte: 400,
2438                end_byte: 410,
2439                start_line: 20,
2440                start_column: 0,
2441                end_line: 20,
2442                end_column: 10,
2443                signature: None,
2444                doc: None,
2445                qualified_name: None,
2446                visibility: None,
2447                is_async: false,
2448                is_static: false,
2449                is_unsafe: false,
2450                body_hash: None,
2451            })
2452            .unwrap();
2453
2454        indices.add(
2455            returner_id,
2456            NodeKind::Function,
2457            returner_name,
2458            None,
2459            file_id,
2460        );
2461        indices.add(plain_id, NodeKind::Function, plain_name, None, file_id);
2462        indices.add(error_type_id, NodeKind::Type, error_name, None, file_id);
2463
2464        edges.add_edge(
2465            returner_id,
2466            error_type_id,
2467            EdgeKind::TypeOf {
2468                context: Some(TypeOfContext::Return),
2469                index: None,
2470                name: None,
2471            },
2472            file_id,
2473        );
2474
2475        let graph = CodeGraph::from_components(
2476            arena,
2477            edges,
2478            strings,
2479            files,
2480            indices,
2481            crate::graph::unified::NodeMetadataStore::new(),
2482        );
2483        (graph, returner_id, plain_id, error_type_id)
2484    }
2485
2486    #[test]
2487    fn test_match_returns_byte_exact_hit() {
2488        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2489        let pm = PluginManager::new();
2490        let ctx = GraphEvalContext::new(&graph, &pm);
2491        let entry = graph.nodes().get(returner_id).expect("returner exists");
2492
2493        // returns:error against the function with a TypeOf{Return} edge to
2494        // the `error` type node should match (byte-exact).
2495        assert!(match_returns(
2496            &ctx,
2497            returner_id,
2498            entry,
2499            &Operator::Equal,
2500            &Value::String("error".to_string()),
2501        ));
2502    }
2503
2504    #[test]
2505    fn test_match_returns_no_edges_misses() {
2506        let (graph, _returner_id, plain_id, _error_id) = build_returns_graph();
2507        let pm = PluginManager::new();
2508        let ctx = GraphEvalContext::new(&graph, &pm);
2509        let entry = graph.nodes().get(plain_id).expect("plain_fn exists");
2510
2511        // returns:error against a function with no TypeOf{Return} edges
2512        // must NOT match (the legacy substring path would have to be
2513        // entirely off this code path for this to hold).
2514        assert!(!match_returns(
2515            &ctx,
2516            plain_id,
2517            entry,
2518            &Operator::Equal,
2519            &Value::String("error".to_string()),
2520        ));
2521    }
2522
2523    #[test]
2524    fn test_match_returns_byte_exact_miss_on_different_target_name() {
2525        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2526        let pm = PluginManager::new();
2527        let ctx = GraphEvalContext::new(&graph, &pm);
2528        let entry = graph.nodes().get(returner_id).expect("returner exists");
2529
2530        // returns:Error (capitalised) must NOT match `error` — byte-exact
2531        // is case-sensitive.  This is the property the previous
2532        // signature.contains substring path failed to enforce.
2533        assert!(!match_returns(
2534            &ctx,
2535            returner_id,
2536            entry,
2537            &Operator::Equal,
2538            &Value::String("Error".to_string()),
2539        ));
2540    }
2541
2542    #[test]
2543    fn test_match_returns_rejects_non_callable_kinds() {
2544        let (graph, _returner_id, _plain_id, error_id) = build_returns_graph();
2545        let pm = PluginManager::new();
2546        let ctx = GraphEvalContext::new(&graph, &pm);
2547        // The `error` node is a Type, not a Function/Method, so the
2548        // fast-path early-out should reject it without consulting edges.
2549        let entry = graph.nodes().get(error_id).expect("error type exists");
2550
2551        assert!(!match_returns(
2552            &ctx,
2553            error_id,
2554            entry,
2555            &Operator::Equal,
2556            &Value::String("error".to_string()),
2557        ));
2558    }
2559
2560    // ================================================================
2561    // Phase A C indirect-call precision (U18.1)
2562    //
2563    // Predicate parity with the planner surface (`sqry_query`). The three
2564    // new graph-eval helpers — `match_address_taken`,
2565    // `match_callsite_promiscuous`, `match_resolved_via` — surface the
2566    // same three predicates the planner already exposes via U14. SPEC
2567    // §3.1.3 + DESIGN §11 / §12.
2568    // ================================================================
2569
2570    use crate::graph::unified::NodeMetadataStore;
2571    use crate::graph::unified::edge::ResolvedVia;
2572
2573    /// Build a tiny graph with two functions `flagged` and `plain`. Caller is
2574    /// responsible for setting flags on the returned `NodeId`s post-hoc.
2575    /// Returns `(graph, flagged_id, plain_id)`.
2576    fn build_two_function_graph() -> (CodeGraph, NodeId, NodeId) {
2577        let mut arena = NodeArena::new();
2578        let edges = BidirectionalEdgeStore::new();
2579        let mut strings = StringInterner::new();
2580        let mut files = FileRegistry::new();
2581        let mut indices = AuxiliaryIndices::new();
2582
2583        let flagged_name = strings.intern("flagged").unwrap();
2584        let plain_name = strings.intern("plain").unwrap();
2585        let file_id = files.register(Path::new("u18_1.c")).unwrap();
2586
2587        let mk_fn = |arena: &mut NodeArena, name, start: u32, end: u32, line: u32| -> NodeId {
2588            arena
2589                .alloc(NodeEntry {
2590                    kind: NodeKind::Function,
2591                    name,
2592                    file: file_id,
2593                    start_byte: start,
2594                    end_byte: end,
2595                    start_line: line,
2596                    start_column: 0,
2597                    end_line: line + 2,
2598                    end_column: 0,
2599                    signature: None,
2600                    doc: None,
2601                    qualified_name: None,
2602                    visibility: None,
2603                    is_async: false,
2604                    is_static: false,
2605                    is_unsafe: false,
2606                    body_hash: None,
2607                })
2608                .unwrap()
2609        };
2610
2611        let flagged_id = mk_fn(&mut arena, flagged_name, 0, 50, 1);
2612        let plain_id = mk_fn(&mut arena, plain_name, 100, 150, 10);
2613
2614        indices.add(flagged_id, NodeKind::Function, flagged_name, None, file_id);
2615        indices.add(plain_id, NodeKind::Function, plain_name, None, file_id);
2616
2617        let graph = CodeGraph::from_components(
2618            arena,
2619            edges,
2620            strings,
2621            files,
2622            indices,
2623            NodeMetadataStore::new(),
2624        );
2625        (graph, flagged_id, plain_id)
2626    }
2627
2628    /// `match_address_taken(true)` must return `true` for a node with the
2629    /// `NodeFlags::ADDRESS_TAKEN` bit set, and `false` for a sibling without
2630    /// it. Inverse predicate (`address_taken:false`) must hold the complement.
2631    #[test]
2632    fn match_address_taken_returns_true_when_flag_set() {
2633        let (mut graph, flagged_id, plain_id) = build_two_function_graph();
2634        graph.macro_metadata_mut().mark_address_taken(flagged_id);
2635        let pm = PluginManager::new();
2636        let ctx = GraphEvalContext::new(&graph, &pm);
2637
2638        assert!(match_address_taken(
2639            &ctx,
2640            flagged_id,
2641            &Operator::Equal,
2642            &Value::Boolean(true)
2643        ));
2644        assert!(!match_address_taken(
2645            &ctx,
2646            plain_id,
2647            &Operator::Equal,
2648            &Value::Boolean(true)
2649        ));
2650
2651        // Inverse: address_taken:false should match `plain` and NOT `flagged`.
2652        assert!(match_address_taken(
2653            &ctx,
2654            plain_id,
2655            &Operator::Equal,
2656            &Value::Boolean(false)
2657        ));
2658        assert!(!match_address_taken(
2659            &ctx,
2660            flagged_id,
2661            &Operator::Equal,
2662            &Value::Boolean(false)
2663        ));
2664
2665        // String form `"true"` / `"false"` (covers the path through
2666        // `value_to_bool`'s string-coercion fallback).
2667        assert!(match_address_taken(
2668            &ctx,
2669            flagged_id,
2670            &Operator::Equal,
2671            &Value::String("true".to_string())
2672        ));
2673    }
2674
2675    /// `match_callsite_promiscuous(true)` returns `true` for nodes with the
2676    /// `NodeFlags::CALLSITE_PROMISCUOUS` bit set.
2677    #[test]
2678    fn match_callsite_promiscuous_returns_true_when_flag_set() {
2679        let (mut graph, flagged_id, plain_id) = build_two_function_graph();
2680        graph
2681            .macro_metadata_mut()
2682            .mark_callsite_promiscuous(flagged_id);
2683        let pm = PluginManager::new();
2684        let ctx = GraphEvalContext::new(&graph, &pm);
2685
2686        assert!(match_callsite_promiscuous(
2687            &ctx,
2688            flagged_id,
2689            &Operator::Equal,
2690            &Value::Boolean(true)
2691        ));
2692        assert!(!match_callsite_promiscuous(
2693            &ctx,
2694            plain_id,
2695            &Operator::Equal,
2696            &Value::Boolean(true)
2697        ));
2698
2699        // ADDRESS_TAKEN and CALLSITE_PROMISCUOUS are independent bits — setting
2700        // one must not flip the other. Co-occurrence regression check mirrors
2701        // metadata.rs `co_occurrence_macro_and_address_taken`.
2702        graph.macro_metadata_mut().mark_address_taken(flagged_id);
2703        let ctx = GraphEvalContext::new(&graph, &pm);
2704        assert!(match_callsite_promiscuous(
2705            &ctx,
2706            flagged_id,
2707            &Operator::Equal,
2708            &Value::Boolean(true)
2709        ));
2710        assert!(match_address_taken(
2711            &ctx,
2712            flagged_id,
2713            &Operator::Equal,
2714            &Value::Boolean(true)
2715        ));
2716    }
2717
2718    /// `match_resolved_via` must filter outgoing Calls edges by their
2719    /// `ResolvedVia` discriminator. Build a fixture where `caller` has two
2720    /// outgoing Calls edges — one `Direct`, one `BindingPlane` — and confirm
2721    /// the helper distinguishes both, rejects `TypeMatch`, and rejects
2722    /// unknown enum strings.
2723    #[test]
2724    fn match_resolved_via_filters_calls_edges_by_resolution() {
2725        let mut arena = NodeArena::new();
2726        let edges = BidirectionalEdgeStore::new();
2727        let mut strings = StringInterner::new();
2728        let mut files = FileRegistry::new();
2729        let mut indices = AuxiliaryIndices::new();
2730
2731        let caller_name = strings.intern("caller").unwrap();
2732        let target_a = strings.intern("target_direct").unwrap();
2733        let target_b = strings.intern("target_binding").unwrap();
2734        let file_id = files.register(Path::new("u18_1_resolved_via.c")).unwrap();
2735
2736        let mk_fn = |arena: &mut NodeArena, name, start: u32, end: u32, line: u32| {
2737            arena
2738                .alloc(NodeEntry {
2739                    kind: NodeKind::Function,
2740                    name,
2741                    file: file_id,
2742                    start_byte: start,
2743                    end_byte: end,
2744                    start_line: line,
2745                    start_column: 0,
2746                    end_line: line + 2,
2747                    end_column: 0,
2748                    signature: None,
2749                    doc: None,
2750                    qualified_name: None,
2751                    visibility: None,
2752                    is_async: false,
2753                    is_static: false,
2754                    is_unsafe: false,
2755                    body_hash: None,
2756                })
2757                .unwrap()
2758        };
2759        let caller_id = mk_fn(&mut arena, caller_name, 0, 50, 1);
2760        let target_a_id = mk_fn(&mut arena, target_a, 100, 150, 10);
2761        let target_b_id = mk_fn(&mut arena, target_b, 200, 250, 20);
2762
2763        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
2764        indices.add(target_a_id, NodeKind::Function, target_a, None, file_id);
2765        indices.add(target_b_id, NodeKind::Function, target_b, None, file_id);
2766
2767        // Two outgoing Calls from caller: one Direct, one BindingPlane.
2768        edges.add_edge(
2769            caller_id,
2770            target_a_id,
2771            EdgeKind::Calls {
2772                argument_count: 0,
2773                is_async: false,
2774                resolved_via: ResolvedVia::Direct,
2775            },
2776            file_id,
2777        );
2778        edges.add_edge(
2779            caller_id,
2780            target_b_id,
2781            EdgeKind::Calls {
2782                argument_count: 0,
2783                is_async: false,
2784                resolved_via: ResolvedVia::BindingPlane,
2785            },
2786            file_id,
2787        );
2788
2789        let graph = CodeGraph::from_components(
2790            arena,
2791            edges,
2792            strings,
2793            files,
2794            indices,
2795            NodeMetadataStore::new(),
2796        );
2797        let pm = PluginManager::new();
2798        let ctx = GraphEvalContext::new(&graph, &pm);
2799
2800        // Direct hits.
2801        assert!(match_resolved_via(
2802            &ctx,
2803            caller_id,
2804            &Value::String("direct".to_string())
2805        ));
2806        // BindingPlane hits.
2807        assert!(match_resolved_via(
2808            &ctx,
2809            caller_id,
2810            &Value::String("binding_plane".to_string())
2811        ));
2812        // TypeMatch must miss — no such edge in the fixture.
2813        assert!(!match_resolved_via(
2814            &ctx,
2815            caller_id,
2816            &Value::String("type_match".to_string())
2817        ));
2818        // Target nodes have no outgoing Calls — every variant must miss.
2819        assert!(!match_resolved_via(
2820            &ctx,
2821            target_a_id,
2822            &Value::String("direct".to_string())
2823        ));
2824        // Unknown enum strings are graceful misses (no panic, no false match).
2825        assert!(!match_resolved_via(
2826            &ctx,
2827            caller_id,
2828            &Value::String("not_a_real_variant".to_string())
2829        ));
2830        // Non-string values are graceful misses (e.g. someone passes a bool).
2831        assert!(!match_resolved_via(&ctx, caller_id, &Value::Boolean(true)));
2832    }
2833}