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        let _ = 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        let _ = 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                    let _ = 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                    let _ = 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::Channel => "channel",
1671        NodeKind::Other => "other",
1672    }
1673}
1674
1675fn match_scope(
1676    ctx: &GraphEvalContext,
1677    node_id: NodeId,
1678    field: &str,
1679    operator: &Operator,
1680    value: &Value,
1681) -> bool {
1682    let scope_part = field.strip_prefix("scope.").unwrap_or("");
1683    match scope_part {
1684        "type" => match_scope_type(ctx, node_id, operator, value),
1685        "name" => match_scope_name(ctx, node_id, operator, value),
1686        "parent" => match_scope_parent_name(ctx, node_id, operator, value),
1687        "ancestor" => match_scope_ancestor_name(ctx, node_id, operator, value),
1688        _ => false,
1689    }
1690}
1691
1692fn match_scope_type(
1693    ctx: &GraphEvalContext,
1694    node_id: NodeId,
1695    operator: &Operator,
1696    value: &Value,
1697) -> bool {
1698    for edge in ctx.graph.edges().edges_to(node_id) {
1699        if let EdgeKind::Contains = &edge.kind
1700            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1701        {
1702            // Use mapped scope type for parity with legacy index
1703            let scope_type = node_kind_to_scope_type(parent.kind);
1704            return match (operator, value) {
1705                // Case-sensitive comparison
1706                (Operator::Equal, Value::String(exp)) => scope_type == exp,
1707                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1708                    &rv.pattern,
1709                    rv.flags.case_insensitive,
1710                    rv.flags.multiline,
1711                    rv.flags.dot_all,
1712                )
1713                .map(|re| regex_is_match(&re, scope_type))
1714                .unwrap_or(false),
1715                _ => false,
1716            };
1717        }
1718    }
1719    false
1720}
1721
1722fn match_scope_name(
1723    ctx: &GraphEvalContext,
1724    node_id: NodeId,
1725    operator: &Operator,
1726    value: &Value,
1727) -> bool {
1728    for edge in ctx.graph.edges().edges_to(node_id) {
1729        if let EdgeKind::Contains = &edge.kind
1730            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1731            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1732        {
1733            return match (operator, value) {
1734                // Use segments_match for qualified name suffix matching
1735                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1736                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1737                    &rv.pattern,
1738                    rv.flags.case_insensitive,
1739                    rv.flags.multiline,
1740                    rv.flags.dot_all,
1741                )
1742                .map(|re| regex_is_match(&re, &name))
1743                .unwrap_or(false),
1744                _ => false,
1745            };
1746        }
1747    }
1748    false
1749}
1750
1751/// `scope.parent:X` - find nodes with immediate parent NAMED X.
1752///
1753/// Uses `segments_match` for qualified name suffix matching (same as name: predicate).
1754fn match_scope_parent_name(
1755    ctx: &GraphEvalContext,
1756    node_id: NodeId,
1757    operator: &Operator,
1758    value: &Value,
1759) -> bool {
1760    for edge in ctx.graph.edges().edges_to(node_id) {
1761        if let EdgeKind::Contains = &edge.kind
1762            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1763            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1764        {
1765            return match (operator, value) {
1766                // Use segments_match for qualified name suffix matching
1767                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1768                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1769                    &rv.pattern,
1770                    rv.flags.case_insensitive,
1771                    rv.flags.multiline,
1772                    rv.flags.dot_all,
1773                )
1774                .map(|re| regex_is_match(&re, &name))
1775                .unwrap_or(false),
1776                _ => false,
1777            };
1778        }
1779    }
1780    false
1781}
1782
1783/// `scope.ancestor:X` - find nodes with any ancestor NAMED X.
1784///
1785/// CYCLE PROTECTION: Uses visited set to prevent infinite loops.
1786/// Uses `segments_match` for qualified name suffix matching.
1787fn match_scope_ancestor_name(
1788    ctx: &GraphEvalContext,
1789    node_id: NodeId,
1790    operator: &Operator,
1791    value: &Value,
1792) -> bool {
1793    let mut current = node_id;
1794    let mut visited = HashSet::new();
1795    visited.insert(node_id);
1796
1797    loop {
1798        let mut found_parent = false;
1799        for edge in ctx.graph.edges().edges_to(current) {
1800            if let EdgeKind::Contains = &edge.kind {
1801                // CYCLE PROTECTION: Skip if we've already visited this node
1802                if visited.contains(&edge.source) {
1803                    continue;
1804                }
1805                visited.insert(edge.source);
1806
1807                found_parent = true;
1808                current = edge.source;
1809                if let Some(parent) = ctx.graph.nodes().get(current)
1810                    && let Some(name) = ctx.graph.strings().resolve(parent.name)
1811                {
1812                    let matches = match (operator, value) {
1813                        // Use segments_match for qualified name suffix matching
1814                        (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1815                        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1816                            &rv.pattern,
1817                            rv.flags.case_insensitive,
1818                            rv.flags.multiline,
1819                            rv.flags.dot_all,
1820                        )
1821                        .map(|re| regex_is_match(&re, &name))
1822                        .unwrap_or(false),
1823                        _ => false,
1824                    };
1825                    if matches {
1826                        return true;
1827                    }
1828                }
1829                break;
1830            }
1831        }
1832        if !found_parent {
1833            break;
1834        }
1835    }
1836    false
1837}
1838
1839// ============================================================================
1840// Subquery evaluation
1841// ============================================================================
1842
1843/// Evaluate an expression against all nodes and return the set of matching node IDs.
1844///
1845/// Used for subquery evaluation: `callers:(kind:function AND async:true)`.
1846///
1847/// # Errors
1848///
1849/// Returns an error if predicate evaluation fails.
1850pub fn evaluate_subquery(ctx: &GraphEvalContext, expr: &Expr) -> Result<HashSet<NodeId>> {
1851    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
1852    let expr_depth = recursion_limits.effective_expr_depth()?;
1853    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
1854
1855    let arena = ctx.graph.nodes();
1856    let mut matches = HashSet::new();
1857    for (id, _) in arena.iter() {
1858        if evaluate_node(ctx, id, expr, &mut guard)? {
1859            matches.insert(id);
1860        }
1861    }
1862    Ok(matches)
1863}
1864
1865/// Match callers using a precomputed subquery result set.
1866///
1867/// Checks if any of this node's outgoing Calls edges target a node in the
1868/// precomputed set. Returns an error if the subquery was not precomputed
1869/// (indicates a bug in `precompute_subqueries`).
1870fn match_callers_subquery(
1871    ctx: &GraphEvalContext,
1872    node_id: NodeId,
1873    subquery_matches: Option<&HashSet<NodeId>>,
1874) -> Result<bool> {
1875    let Some(matches) = subquery_matches else {
1876        return Err(anyhow!(
1877            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1878        ));
1879    };
1880    for edge in ctx.graph.edges().edges_from(node_id) {
1881        if let EdgeKind::Calls { .. } = &edge.kind
1882            && matches.contains(&edge.target)
1883        {
1884            return Ok(true);
1885        }
1886    }
1887    Ok(false)
1888}
1889
1890/// Match callees using a precomputed subquery result set.
1891fn match_callees_subquery(
1892    ctx: &GraphEvalContext,
1893    node_id: NodeId,
1894    subquery_matches: Option<&HashSet<NodeId>>,
1895) -> Result<bool> {
1896    let Some(matches) = subquery_matches else {
1897        return Err(anyhow!(
1898            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1899        ));
1900    };
1901    for edge in ctx.graph.edges().edges_to(node_id) {
1902        if let EdgeKind::Calls { .. } = &edge.kind
1903            && matches.contains(&edge.source)
1904        {
1905            return Ok(true);
1906        }
1907    }
1908    Ok(false)
1909}
1910
1911/// Match imports using a precomputed subquery result set.
1912///
1913/// Per-node semantic (DB15): returns true iff this specific node has an
1914/// outgoing `Imports` edge whose target is in the precomputed subquery set.
1915/// Aligned with [`match_imports`] and `sqry_db::queries::ImportsQuery`.
1916fn match_imports_subquery(
1917    ctx: &GraphEvalContext,
1918    node_id: NodeId,
1919    subquery_matches: Option<&HashSet<NodeId>>,
1920) -> Result<bool> {
1921    let Some(matches) = subquery_matches else {
1922        return Err(anyhow!(
1923            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1924        ));
1925    };
1926    for edge in ctx.graph.edges().edges_from(node_id) {
1927        if let EdgeKind::Imports { .. } = &edge.kind
1928            && matches.contains(&edge.target)
1929        {
1930            return Ok(true);
1931        }
1932    }
1933    Ok(false)
1934}
1935
1936/// Match exports using a precomputed subquery result set.
1937fn match_exports_subquery(
1938    ctx: &GraphEvalContext,
1939    node_id: NodeId,
1940    subquery_matches: Option<&HashSet<NodeId>>,
1941) -> Result<bool> {
1942    let Some(matches) = subquery_matches else {
1943        return Err(anyhow!(
1944            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1945        ));
1946    };
1947    for edge in ctx.graph.edges().edges_from(node_id) {
1948        if let EdgeKind::Exports { .. } = &edge.kind
1949            && matches.contains(&edge.target)
1950        {
1951            return Ok(true);
1952        }
1953    }
1954    Ok(false)
1955}
1956
1957/// Match implements using a precomputed subquery result set.
1958fn match_implements_subquery(
1959    ctx: &GraphEvalContext,
1960    node_id: NodeId,
1961    subquery_matches: Option<&HashSet<NodeId>>,
1962) -> Result<bool> {
1963    let Some(matches) = subquery_matches else {
1964        return Err(anyhow!(
1965            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1966        ));
1967    };
1968    for edge in ctx.graph.edges().edges_from(node_id) {
1969        if let EdgeKind::Implements = &edge.kind
1970            && matches.contains(&edge.target)
1971        {
1972            return Ok(true);
1973        }
1974    }
1975    Ok(false)
1976}
1977
1978/// Match references using a precomputed subquery result set.
1979fn match_references_subquery(
1980    ctx: &GraphEvalContext,
1981    node_id: NodeId,
1982    subquery_matches: Option<&HashSet<NodeId>>,
1983) -> Result<bool> {
1984    let Some(matches) = subquery_matches else {
1985        return Err(anyhow!(
1986            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1987        ));
1988    };
1989    for edge in ctx.graph.edges().edges_to(node_id) {
1990        let is_reference = matches!(
1991            &edge.kind,
1992            EdgeKind::References
1993                | EdgeKind::Calls { .. }
1994                | EdgeKind::Imports { .. }
1995                | EdgeKind::FfiCall { .. }
1996        );
1997        if is_reference && matches.contains(&edge.source) {
1998            return Ok(true);
1999        }
2000    }
2001    Ok(false)
2002}
2003
2004// ============================================================================
2005// Join evaluation
2006// ============================================================================
2007
2008/// Evaluate a join expression, returning matched (left, right) node pairs.
2009///
2010/// For `(kind:function AND lang:rust) CALLS (kind:function AND lang:python)`:
2011/// 1. Evaluate LHS query → set of matching nodes
2012/// 2. Evaluate RHS query → set of matching nodes
2013/// 3. For each LHS node, check edges of the specified kind
2014/// 4. If edge target is in RHS set → add (lhs, rhs) pair
2015///
2016/// # Errors
2017///
2018/// Returns an error if subquery evaluation or edge traversal fails.
2019pub fn evaluate_join(
2020    ctx: &GraphEvalContext,
2021    join: &JoinExpr,
2022    max_results: Option<usize>,
2023) -> Result<JoinEvalResult> {
2024    let lhs_matches = evaluate_subquery(ctx, &join.left)?;
2025    let rhs_matches = evaluate_subquery(ctx, &join.right)?;
2026    let cap = max_results.unwrap_or(DEFAULT_JOIN_RESULT_CAP);
2027
2028    let mut pairs = Vec::new();
2029    let mut truncated = false;
2030    'outer: for &lhs_id in &lhs_matches {
2031        for edge in ctx.graph.edges().edges_from(lhs_id) {
2032            if edge_matches_join_kind(&edge.kind, &join.edge) && rhs_matches.contains(&edge.target)
2033            {
2034                pairs.push((lhs_id, edge.target));
2035                if pairs.len() >= cap {
2036                    truncated = true;
2037                    break 'outer;
2038                }
2039            }
2040        }
2041    }
2042    Ok(JoinEvalResult { pairs, truncated })
2043}
2044
2045/// Result of a join evaluation including truncation metadata.
2046pub struct JoinEvalResult {
2047    /// The matched (source, target) node ID pairs.
2048    pub pairs: Vec<(NodeId, NodeId)>,
2049    /// Whether the result set was truncated by the result cap.
2050    pub truncated: bool,
2051}
2052
2053/// Default result cap for join queries.
2054///
2055/// Prevents unbounded memory growth when a join produces a large number of matching pairs.
2056const DEFAULT_JOIN_RESULT_CAP: usize = 10_000;
2057
2058/// Check if an edge kind matches a join edge kind.
2059fn edge_matches_join_kind(edge_kind: &EdgeKind, join_kind: &JoinEdgeKind) -> bool {
2060    match join_kind {
2061        JoinEdgeKind::Calls => matches!(edge_kind, EdgeKind::Calls { .. }),
2062        JoinEdgeKind::Imports => matches!(edge_kind, EdgeKind::Imports { .. }),
2063        JoinEdgeKind::Inherits => matches!(edge_kind, EdgeKind::Inherits),
2064        JoinEdgeKind::Implements => matches!(edge_kind, EdgeKind::Implements),
2065    }
2066}
2067
2068#[cfg(test)]
2069mod tests {
2070    use super::*;
2071    use crate::graph::node::Language;
2072    use crate::query::types::{Condition, Field, Span};
2073    use std::path::Path;
2074
2075    #[test]
2076    fn test_import_text_matches_canonicalized_qualified_imports() {
2077        let mut graph = CodeGraph::new();
2078        let file_id = graph
2079            .files_mut()
2080            .register(Path::new("src/FileProcessor.cs"))
2081            .unwrap();
2082        assert!(graph.files_mut().set_language(file_id, Language::CSharp));
2083
2084        assert!(import_text_matches(
2085            &graph,
2086            file_id,
2087            "System::IO",
2088            "System.IO"
2089        ));
2090        assert!(import_text_matches(
2091            &graph,
2092            file_id,
2093            "System::Collections::Generic",
2094            "System.Collections.Generic"
2095        ));
2096        assert!(!import_text_matches(
2097            &graph,
2098            file_id,
2099            "System::Text",
2100            "System.IO"
2101        ));
2102    }
2103
2104    #[test]
2105    fn test_language_aware_segments_match_supports_ruby_method_separators() {
2106        let mut graph = CodeGraph::new();
2107        let file_id = graph
2108            .files_mut()
2109            .register(Path::new("app/models/user.rb"))
2110            .unwrap();
2111        assert!(graph.files_mut().set_language(file_id, Language::Ruby));
2112
2113        assert!(language_aware_segments_match(
2114            &graph,
2115            file_id,
2116            "Admin::Users::Controller::show",
2117            "Admin::Users::Controller#show"
2118        ));
2119        assert!(language_aware_segments_match(
2120            &graph,
2121            file_id,
2122            "Admin::Users::Controller::show",
2123            "show"
2124        ));
2125        assert!(!language_aware_segments_match(
2126            &graph,
2127            file_id,
2128            "Admin::Users::Controller::index",
2129            "Admin::Users::Controller#show"
2130        ));
2131    }
2132
2133    #[test]
2134    fn test_normalize_kind() {
2135        // Case-sensitive synonyms
2136        assert_eq!(normalize_kind("trait"), "interface");
2137        assert_eq!(normalize_kind("TRAIT"), "TRAIT"); // Case-sensitive: not a synonym
2138        assert_eq!(normalize_kind("field"), "property");
2139        assert_eq!(normalize_kind("namespace"), "module");
2140        assert_eq!(normalize_kind("function"), "function"); // unchanged
2141    }
2142
2143    #[test]
2144    fn test_graph_eval_context_builder() {
2145        let graph = CodeGraph::new();
2146        let pm = PluginManager::new();
2147        let ctx = GraphEvalContext::new(&graph, &pm)
2148            .with_workspace_root(Path::new("/test"))
2149            .with_parallel_disabled(true);
2150
2151        assert!(ctx.disable_parallel);
2152        assert_eq!(ctx.workspace_root, Some(Path::new("/test")));
2153    }
2154
2155    // ================================================================
2156    // collect_subquery_exprs tests
2157    // ================================================================
2158
2159    /// Helper: build `field:(inner_expr)` as a Condition with `Value::Subquery`.
2160    fn subquery_condition(field: &str, inner: Expr, start: usize, end: usize) -> Expr {
2161        Expr::Condition(Condition {
2162            field: Field(field.to_string()),
2163            operator: Operator::Equal,
2164            value: Value::Subquery(Box::new(inner)),
2165            span: Span::with_position(start, end, 1, start + 1),
2166        })
2167    }
2168
2169    /// Helper: build a simple `kind:function` condition.
2170    fn kind_condition(kind: &str) -> Expr {
2171        Expr::Condition(Condition {
2172            field: Field("kind".to_string()),
2173            operator: Operator::Equal,
2174            value: Value::String(kind.to_string()),
2175            span: Span::default(),
2176        })
2177    }
2178
2179    #[test]
2180    fn test_collect_subquery_exprs_post_order_depth_2() {
2181        // Build: callers:(callees:(kind:function))
2182        // Inner subquery: callees:(kind:function) at span (20, 40)
2183        // Outer subquery: callers:(...) at span (0, 50)
2184        let inner_subquery = subquery_condition("callees", kind_condition("function"), 20, 40);
2185        let outer_subquery = subquery_condition("callers", inner_subquery, 0, 50);
2186
2187        let mut out = Vec::new();
2188        collect_subquery_exprs(&outer_subquery, &mut out);
2189
2190        // Post-order: inner appears before outer
2191        assert_eq!(
2192            out.len(),
2193            2,
2194            "should collect both inner and outer subqueries"
2195        );
2196        assert_eq!(out[0].0, (20, 40), "inner subquery span should come first");
2197        assert_eq!(out[1].0, (0, 50), "outer subquery span should come second");
2198    }
2199
2200    #[test]
2201    fn test_collect_subquery_exprs_post_order_depth_3() {
2202        // Build: callers:(callees:(imports:(kind:function)))
2203        let innermost = subquery_condition("imports", kind_condition("function"), 30, 50);
2204        let middle = subquery_condition("callees", innermost, 15, 55);
2205        let outer = subquery_condition("callers", middle, 0, 60);
2206
2207        let mut out = Vec::new();
2208        collect_subquery_exprs(&outer, &mut out);
2209
2210        assert_eq!(out.len(), 3, "should collect all three nested subqueries");
2211        assert_eq!(out[0].0, (30, 50), "innermost should come first");
2212        assert_eq!(out[1].0, (15, 55), "middle should come second");
2213        assert_eq!(out[2].0, (0, 60), "outer should come last");
2214    }
2215
2216    #[test]
2217    fn test_collect_subquery_exprs_and_or_branches() {
2218        // Build: callers:(kind:function) AND callees:(kind:method)
2219        let left = subquery_condition("callers", kind_condition("function"), 0, 25);
2220        let right = subquery_condition("callees", kind_condition("method"), 30, 55);
2221        let expr = Expr::And(vec![left, right]);
2222
2223        let mut out = Vec::new();
2224        collect_subquery_exprs(&expr, &mut out);
2225
2226        assert_eq!(out.len(), 2, "should collect subqueries from both branches");
2227        assert_eq!(out[0].0, (0, 25), "left branch subquery");
2228        assert_eq!(out[1].0, (30, 55), "right branch subquery");
2229    }
2230
2231    #[test]
2232    fn test_collect_subquery_exprs_no_subqueries() {
2233        // Simple condition with no subqueries: kind:function
2234        let expr = kind_condition("function");
2235
2236        let mut out = Vec::new();
2237        collect_subquery_exprs(&expr, &mut out);
2238
2239        assert!(
2240            out.is_empty(),
2241            "should collect nothing for plain conditions"
2242        );
2243    }
2244
2245    // ================================================================
2246    // FfiCall edge in references/referenced_by predicates
2247    // ================================================================
2248
2249    use crate::graph::unified::edge::{BidirectionalEdgeStore, FfiConvention};
2250    use crate::graph::unified::storage::{
2251        AuxiliaryIndices, FileRegistry, NodeArena, StringInterner,
2252    };
2253
2254    /// Build a `CodeGraph` with: `caller --FfiCall(C)--> target`
2255    fn build_ffi_graph() -> (CodeGraph, NodeId, NodeId) {
2256        let mut arena = NodeArena::new();
2257        let edges = BidirectionalEdgeStore::new();
2258        let mut strings = StringInterner::new();
2259        let mut files = FileRegistry::new();
2260        let mut indices = AuxiliaryIndices::new();
2261
2262        let caller_name = strings.intern("caller_fn").unwrap();
2263        let target_name = strings.intern("ffi_target").unwrap();
2264        let file_id = files.register(Path::new("test.r")).unwrap();
2265
2266        let caller_id = arena
2267            .alloc(NodeEntry {
2268                kind: NodeKind::Function,
2269                name: caller_name,
2270                file: file_id,
2271                start_byte: 0,
2272                end_byte: 100,
2273                start_line: 1,
2274                start_column: 0,
2275                end_line: 5,
2276                end_column: 0,
2277                signature: None,
2278                doc: None,
2279                qualified_name: None,
2280                visibility: None,
2281                is_async: false,
2282                is_static: false,
2283                is_unsafe: false,
2284                is_definition: false,
2285                body_hash: None,
2286            })
2287            .unwrap();
2288
2289        let target_id = arena
2290            .alloc(NodeEntry {
2291                kind: NodeKind::Function,
2292                name: target_name,
2293                file: file_id,
2294                start_byte: 200,
2295                end_byte: 300,
2296                start_line: 10,
2297                start_column: 0,
2298                end_line: 15,
2299                end_column: 0,
2300                signature: None,
2301                doc: None,
2302                qualified_name: None,
2303                visibility: None,
2304                is_async: false,
2305                is_static: false,
2306                is_unsafe: false,
2307                is_definition: false,
2308                body_hash: None,
2309            })
2310            .unwrap();
2311
2312        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
2313        indices.add(target_id, NodeKind::Function, target_name, None, file_id);
2314
2315        edges.add_edge(
2316            caller_id,
2317            target_id,
2318            EdgeKind::FfiCall {
2319                convention: FfiConvention::C,
2320            },
2321            file_id,
2322        );
2323
2324        let graph = CodeGraph::from_components(
2325            arena,
2326            edges,
2327            strings,
2328            files,
2329            indices,
2330            crate::graph::unified::NodeMetadataStore::new(),
2331        );
2332        (graph, caller_id, target_id)
2333    }
2334
2335    #[test]
2336    fn test_ffi_call_edge_in_references_predicate() {
2337        let (graph, _caller_id, target_id) = build_ffi_graph();
2338        let pm = PluginManager::new();
2339        let ctx = GraphEvalContext::new(&graph, &pm);
2340
2341        // `references:ffi_target` should match because there is an FfiCall edge to it
2342        let result = match_references(
2343            &ctx,
2344            target_id,
2345            &Operator::Equal,
2346            &Value::String("ffi_target".to_string()),
2347        );
2348        assert!(result, "references: predicate should match FfiCall edges");
2349    }
2350
2351    #[test]
2352    fn test_ffi_call_edge_in_references_subquery() {
2353        let (graph, caller_id, target_id) = build_ffi_graph();
2354        let pm = PluginManager::new();
2355        let ctx = GraphEvalContext::new(&graph, &pm);
2356
2357        // Simulate a subquery that matched the caller node
2358        let mut subquery_results = HashSet::new();
2359        subquery_results.insert(caller_id);
2360
2361        // match_references_subquery checks incoming edges to target_id
2362        // and verifies the source is in the subquery result set
2363        let result = match_references_subquery(&ctx, target_id, Some(&subquery_results)).unwrap();
2364        assert!(
2365            result,
2366            "references subquery should match FfiCall edge sources"
2367        );
2368    }
2369
2370    // ================================================================
2371    // returns: predicate (edge-based, byte-exact)
2372    // ================================================================
2373
2374    /// Build a `CodeGraph` with two functions and an `error` type node:
2375    /// - `returner_fn` --TypeOf{Return}--> `error`
2376    /// - `plain_fn` (no outgoing TypeOf edges)
2377    ///
2378    /// Returns `(graph, returner_id, plain_id, error_type_id)`.
2379    fn build_returns_graph() -> (CodeGraph, NodeId, NodeId, NodeId) {
2380        let mut arena = NodeArena::new();
2381        let edges = BidirectionalEdgeStore::new();
2382        let mut strings = StringInterner::new();
2383        let mut files = FileRegistry::new();
2384        let mut indices = AuxiliaryIndices::new();
2385
2386        let returner_name = strings.intern("returner_fn").unwrap();
2387        let plain_name = strings.intern("plain_fn").unwrap();
2388        let error_name = strings.intern("error").unwrap();
2389        let file_id = files.register(Path::new("test.go")).unwrap();
2390
2391        let returner_id = arena
2392            .alloc(NodeEntry {
2393                kind: NodeKind::Function,
2394                name: returner_name,
2395                file: file_id,
2396                start_byte: 0,
2397                end_byte: 100,
2398                start_line: 1,
2399                start_column: 0,
2400                end_line: 5,
2401                end_column: 0,
2402                signature: None,
2403                doc: None,
2404                qualified_name: None,
2405                visibility: None,
2406                is_async: false,
2407                is_static: false,
2408                is_unsafe: false,
2409                is_definition: false,
2410                body_hash: None,
2411            })
2412            .unwrap();
2413
2414        let plain_id = arena
2415            .alloc(NodeEntry {
2416                kind: NodeKind::Function,
2417                name: plain_name,
2418                file: file_id,
2419                start_byte: 200,
2420                end_byte: 300,
2421                start_line: 10,
2422                start_column: 0,
2423                end_line: 15,
2424                end_column: 0,
2425                signature: None,
2426                doc: None,
2427                qualified_name: None,
2428                visibility: None,
2429                is_async: false,
2430                is_static: false,
2431                is_unsafe: false,
2432                is_definition: false,
2433                body_hash: None,
2434            })
2435            .unwrap();
2436
2437        let error_type_id = arena
2438            .alloc(NodeEntry {
2439                kind: NodeKind::Type,
2440                name: error_name,
2441                file: file_id,
2442                start_byte: 400,
2443                end_byte: 410,
2444                start_line: 20,
2445                start_column: 0,
2446                end_line: 20,
2447                end_column: 10,
2448                signature: None,
2449                doc: None,
2450                qualified_name: None,
2451                visibility: None,
2452                is_async: false,
2453                is_static: false,
2454                is_unsafe: false,
2455                is_definition: false,
2456                body_hash: None,
2457            })
2458            .unwrap();
2459
2460        indices.add(
2461            returner_id,
2462            NodeKind::Function,
2463            returner_name,
2464            None,
2465            file_id,
2466        );
2467        indices.add(plain_id, NodeKind::Function, plain_name, None, file_id);
2468        indices.add(error_type_id, NodeKind::Type, error_name, None, file_id);
2469
2470        edges.add_edge(
2471            returner_id,
2472            error_type_id,
2473            EdgeKind::TypeOf {
2474                context: Some(TypeOfContext::Return),
2475                index: None,
2476                name: None,
2477            },
2478            file_id,
2479        );
2480
2481        let graph = CodeGraph::from_components(
2482            arena,
2483            edges,
2484            strings,
2485            files,
2486            indices,
2487            crate::graph::unified::NodeMetadataStore::new(),
2488        );
2489        (graph, returner_id, plain_id, error_type_id)
2490    }
2491
2492    #[test]
2493    fn test_match_returns_byte_exact_hit() {
2494        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2495        let pm = PluginManager::new();
2496        let ctx = GraphEvalContext::new(&graph, &pm);
2497        let entry = graph.nodes().get(returner_id).expect("returner exists");
2498
2499        // returns:error against the function with a TypeOf{Return} edge to
2500        // the `error` type node should match (byte-exact).
2501        assert!(match_returns(
2502            &ctx,
2503            returner_id,
2504            entry,
2505            &Operator::Equal,
2506            &Value::String("error".to_string()),
2507        ));
2508    }
2509
2510    #[test]
2511    fn test_match_returns_no_edges_misses() {
2512        let (graph, _returner_id, plain_id, _error_id) = build_returns_graph();
2513        let pm = PluginManager::new();
2514        let ctx = GraphEvalContext::new(&graph, &pm);
2515        let entry = graph.nodes().get(plain_id).expect("plain_fn exists");
2516
2517        // returns:error against a function with no TypeOf{Return} edges
2518        // must NOT match (the legacy substring path would have to be
2519        // entirely off this code path for this to hold).
2520        assert!(!match_returns(
2521            &ctx,
2522            plain_id,
2523            entry,
2524            &Operator::Equal,
2525            &Value::String("error".to_string()),
2526        ));
2527    }
2528
2529    #[test]
2530    fn test_match_returns_byte_exact_miss_on_different_target_name() {
2531        let (graph, returner_id, _plain_id, _error_id) = build_returns_graph();
2532        let pm = PluginManager::new();
2533        let ctx = GraphEvalContext::new(&graph, &pm);
2534        let entry = graph.nodes().get(returner_id).expect("returner exists");
2535
2536        // returns:Error (capitalised) must NOT match `error` — byte-exact
2537        // is case-sensitive.  This is the property the previous
2538        // signature.contains substring path failed to enforce.
2539        assert!(!match_returns(
2540            &ctx,
2541            returner_id,
2542            entry,
2543            &Operator::Equal,
2544            &Value::String("Error".to_string()),
2545        ));
2546    }
2547
2548    #[test]
2549    fn test_match_returns_rejects_non_callable_kinds() {
2550        let (graph, _returner_id, _plain_id, error_id) = build_returns_graph();
2551        let pm = PluginManager::new();
2552        let ctx = GraphEvalContext::new(&graph, &pm);
2553        // The `error` node is a Type, not a Function/Method, so the
2554        // fast-path early-out should reject it without consulting edges.
2555        let entry = graph.nodes().get(error_id).expect("error type exists");
2556
2557        assert!(!match_returns(
2558            &ctx,
2559            error_id,
2560            entry,
2561            &Operator::Equal,
2562            &Value::String("error".to_string()),
2563        ));
2564    }
2565
2566    // ================================================================
2567    // Phase A C indirect-call precision (U18.1)
2568    //
2569    // Predicate parity with the planner surface (`sqry_query`). The three
2570    // new graph-eval helpers — `match_address_taken`,
2571    // `match_callsite_promiscuous`, `match_resolved_via` — surface the
2572    // same three predicates the planner already exposes via U14. SPEC
2573    // §3.1.3 + DESIGN §11 / §12.
2574    // ================================================================
2575
2576    use crate::graph::unified::NodeMetadataStore;
2577    use crate::graph::unified::edge::ResolvedVia;
2578
2579    /// Build a tiny graph with two functions `flagged` and `plain`. Caller is
2580    /// responsible for setting flags on the returned `NodeId`s post-hoc.
2581    /// Returns `(graph, flagged_id, plain_id)`.
2582    fn build_two_function_graph() -> (CodeGraph, NodeId, NodeId) {
2583        let mut arena = NodeArena::new();
2584        let edges = BidirectionalEdgeStore::new();
2585        let mut strings = StringInterner::new();
2586        let mut files = FileRegistry::new();
2587        let mut indices = AuxiliaryIndices::new();
2588
2589        let flagged_name = strings.intern("flagged").unwrap();
2590        let plain_name = strings.intern("plain").unwrap();
2591        let file_id = files.register(Path::new("u18_1.c")).unwrap();
2592
2593        let mk_fn = |arena: &mut NodeArena, name, start: u32, end: u32, line: u32| -> NodeId {
2594            arena
2595                .alloc(NodeEntry {
2596                    kind: NodeKind::Function,
2597                    name,
2598                    file: file_id,
2599                    start_byte: start,
2600                    end_byte: end,
2601                    start_line: line,
2602                    start_column: 0,
2603                    end_line: line + 2,
2604                    end_column: 0,
2605                    signature: None,
2606                    doc: None,
2607                    qualified_name: None,
2608                    visibility: None,
2609                    is_async: false,
2610                    is_static: false,
2611                    is_unsafe: false,
2612                    is_definition: false,
2613                    body_hash: None,
2614                })
2615                .unwrap()
2616        };
2617
2618        let flagged_id = mk_fn(&mut arena, flagged_name, 0, 50, 1);
2619        let plain_id = mk_fn(&mut arena, plain_name, 100, 150, 10);
2620
2621        indices.add(flagged_id, NodeKind::Function, flagged_name, None, file_id);
2622        indices.add(plain_id, NodeKind::Function, plain_name, None, file_id);
2623
2624        let graph = CodeGraph::from_components(
2625            arena,
2626            edges,
2627            strings,
2628            files,
2629            indices,
2630            NodeMetadataStore::new(),
2631        );
2632        (graph, flagged_id, plain_id)
2633    }
2634
2635    /// `match_address_taken(true)` must return `true` for a node with the
2636    /// `NodeFlags::ADDRESS_TAKEN` bit set, and `false` for a sibling without
2637    /// it. Inverse predicate (`address_taken:false`) must hold the complement.
2638    #[test]
2639    fn match_address_taken_returns_true_when_flag_set() {
2640        let (mut graph, flagged_id, plain_id) = build_two_function_graph();
2641        graph.macro_metadata_mut().mark_address_taken(flagged_id);
2642        let pm = PluginManager::new();
2643        let ctx = GraphEvalContext::new(&graph, &pm);
2644
2645        assert!(match_address_taken(
2646            &ctx,
2647            flagged_id,
2648            &Operator::Equal,
2649            &Value::Boolean(true)
2650        ));
2651        assert!(!match_address_taken(
2652            &ctx,
2653            plain_id,
2654            &Operator::Equal,
2655            &Value::Boolean(true)
2656        ));
2657
2658        // Inverse: address_taken:false should match `plain` and NOT `flagged`.
2659        assert!(match_address_taken(
2660            &ctx,
2661            plain_id,
2662            &Operator::Equal,
2663            &Value::Boolean(false)
2664        ));
2665        assert!(!match_address_taken(
2666            &ctx,
2667            flagged_id,
2668            &Operator::Equal,
2669            &Value::Boolean(false)
2670        ));
2671
2672        // String form `"true"` / `"false"` (covers the path through
2673        // `value_to_bool`'s string-coercion fallback).
2674        assert!(match_address_taken(
2675            &ctx,
2676            flagged_id,
2677            &Operator::Equal,
2678            &Value::String("true".to_string())
2679        ));
2680    }
2681
2682    /// `match_callsite_promiscuous(true)` returns `true` for nodes with the
2683    /// `NodeFlags::CALLSITE_PROMISCUOUS` bit set.
2684    #[test]
2685    fn match_callsite_promiscuous_returns_true_when_flag_set() {
2686        let (mut graph, flagged_id, plain_id) = build_two_function_graph();
2687        graph
2688            .macro_metadata_mut()
2689            .mark_callsite_promiscuous(flagged_id);
2690        let pm = PluginManager::new();
2691        let ctx = GraphEvalContext::new(&graph, &pm);
2692
2693        assert!(match_callsite_promiscuous(
2694            &ctx,
2695            flagged_id,
2696            &Operator::Equal,
2697            &Value::Boolean(true)
2698        ));
2699        assert!(!match_callsite_promiscuous(
2700            &ctx,
2701            plain_id,
2702            &Operator::Equal,
2703            &Value::Boolean(true)
2704        ));
2705
2706        // ADDRESS_TAKEN and CALLSITE_PROMISCUOUS are independent bits — setting
2707        // one must not flip the other. Co-occurrence regression check mirrors
2708        // metadata.rs `co_occurrence_macro_and_address_taken`.
2709        graph.macro_metadata_mut().mark_address_taken(flagged_id);
2710        let ctx = GraphEvalContext::new(&graph, &pm);
2711        assert!(match_callsite_promiscuous(
2712            &ctx,
2713            flagged_id,
2714            &Operator::Equal,
2715            &Value::Boolean(true)
2716        ));
2717        assert!(match_address_taken(
2718            &ctx,
2719            flagged_id,
2720            &Operator::Equal,
2721            &Value::Boolean(true)
2722        ));
2723    }
2724
2725    /// `match_resolved_via` must filter outgoing Calls edges by their
2726    /// `ResolvedVia` discriminator. Build a fixture where `caller` has two
2727    /// outgoing Calls edges — one `Direct`, one `BindingPlane` — and confirm
2728    /// the helper distinguishes both, rejects `TypeMatch`, and rejects
2729    /// unknown enum strings.
2730    #[test]
2731    fn match_resolved_via_filters_calls_edges_by_resolution() {
2732        let mut arena = NodeArena::new();
2733        let edges = BidirectionalEdgeStore::new();
2734        let mut strings = StringInterner::new();
2735        let mut files = FileRegistry::new();
2736        let mut indices = AuxiliaryIndices::new();
2737
2738        let caller_name = strings.intern("caller").unwrap();
2739        let target_a = strings.intern("target_direct").unwrap();
2740        let target_b = strings.intern("target_binding").unwrap();
2741        let file_id = files.register(Path::new("u18_1_resolved_via.c")).unwrap();
2742
2743        let mk_fn = |arena: &mut NodeArena, name, start: u32, end: u32, line: u32| {
2744            arena
2745                .alloc(NodeEntry {
2746                    kind: NodeKind::Function,
2747                    name,
2748                    file: file_id,
2749                    start_byte: start,
2750                    end_byte: end,
2751                    start_line: line,
2752                    start_column: 0,
2753                    end_line: line + 2,
2754                    end_column: 0,
2755                    signature: None,
2756                    doc: None,
2757                    qualified_name: None,
2758                    visibility: None,
2759                    is_async: false,
2760                    is_static: false,
2761                    is_unsafe: false,
2762                    is_definition: false,
2763                    body_hash: None,
2764                })
2765                .unwrap()
2766        };
2767        let caller_id = mk_fn(&mut arena, caller_name, 0, 50, 1);
2768        let target_a_id = mk_fn(&mut arena, target_a, 100, 150, 10);
2769        let target_b_id = mk_fn(&mut arena, target_b, 200, 250, 20);
2770
2771        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
2772        indices.add(target_a_id, NodeKind::Function, target_a, None, file_id);
2773        indices.add(target_b_id, NodeKind::Function, target_b, None, file_id);
2774
2775        // Two outgoing Calls from caller: one Direct, one BindingPlane.
2776        edges.add_edge(
2777            caller_id,
2778            target_a_id,
2779            EdgeKind::Calls {
2780                argument_count: 0,
2781                is_async: false,
2782                resolved_via: ResolvedVia::Direct,
2783            },
2784            file_id,
2785        );
2786        edges.add_edge(
2787            caller_id,
2788            target_b_id,
2789            EdgeKind::Calls {
2790                argument_count: 0,
2791                is_async: false,
2792                resolved_via: ResolvedVia::BindingPlane,
2793            },
2794            file_id,
2795        );
2796
2797        let graph = CodeGraph::from_components(
2798            arena,
2799            edges,
2800            strings,
2801            files,
2802            indices,
2803            NodeMetadataStore::new(),
2804        );
2805        let pm = PluginManager::new();
2806        let ctx = GraphEvalContext::new(&graph, &pm);
2807
2808        // Direct hits.
2809        assert!(match_resolved_via(
2810            &ctx,
2811            caller_id,
2812            &Value::String("direct".to_string())
2813        ));
2814        // BindingPlane hits.
2815        assert!(match_resolved_via(
2816            &ctx,
2817            caller_id,
2818            &Value::String("binding_plane".to_string())
2819        ));
2820        // TypeMatch must miss — no such edge in the fixture.
2821        assert!(!match_resolved_via(
2822            &ctx,
2823            caller_id,
2824            &Value::String("type_match".to_string())
2825        ));
2826        // Target nodes have no outgoing Calls — every variant must miss.
2827        assert!(!match_resolved_via(
2828            &ctx,
2829            target_a_id,
2830            &Value::String("direct".to_string())
2831        ));
2832        // Unknown enum strings are graceful misses (no panic, no false match).
2833        assert!(!match_resolved_via(
2834            &ctx,
2835            caller_id,
2836            &Value::String("not_a_real_variant".to_string())
2837        ));
2838        // Non-string values are graceful misses (e.g. someone passes a bool).
2839        assert!(!match_resolved_via(&ctx, caller_id, &Value::Boolean(true)));
2840    }
2841}