Skip to main content

sqry_core/query/executor/
graph_eval.rs

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