Skip to main content

sqry_core/graph/unified/bind/
plane.rs

1//! `BindingPlane<'g>` — the Phase 2 facade that wraps a `GraphSnapshot` and
2//! provides witness-bearing resolution, scope/alias/shadow accessors, and
3//! the `explain()` renderer.
4//!
5//! This is the primary Phase 2 public API. Phases 3-6 (derived analysis DB,
6//! query planner, rule layer, provenance/history) consume it as the stable
7//! binding-plane surface.
8//!
9//! # Canonical emission-path conventions (`resolve_shared` contract)
10//!
11//! The step vocabulary has two pairs of overlapping variants. When emitting
12//! steps inside `resolve_shared`, use the following canonical paths:
13//!
14//! **Visibility rejections** — emit `FilterByVisibility { candidate, reason }`
15//! (preferred). The `Rejected { reason: RejectionReason::PrivateVisibility }`
16//! variant is reserved for non-visibility-specific rejection contexts where the
17//! caller does not have a `VisibilityReason` value (e.g., generic catch-all
18//! rejection paths in future P2U work). Never emit both for the same candidate.
19//!
20//! **Shadow rejections** — emit `ShadowedBy { outer, inner, by_node }` when
21//! the resolver detects that an outer binding is superseded by an inner
22//! binding (the structured form). The `Rejected { reason: RejectionReason::Shadowed }`
23//! variant is for catch-all rejection contexts that do not have scope-pair
24//! information available. Prefer `ShadowedBy` whenever both scopes are known.
25//!
26//! These conventions exist so that downstream consumers (P2U09 T19, P2U10
27//! CLI `--explain`, Phases 3-6 rule layer) can match step variants
28//! deterministically without guessing which emission path was used.
29
30use crate::graph::unified::concurrent::GraphSnapshot;
31use crate::graph::unified::node::id::NodeId;
32use crate::graph::unified::resolution::{SymbolQuery, SymbolResolutionWitness};
33
34use super::alias::AliasEntry;
35use super::scope::provenance::{ScopeProvenance, ScopeStableId};
36use super::scope::tree::scope_chain;
37use super::scope::{Scope, ScopeId};
38use super::shadow::ShadowEntry;
39use super::witness::render::{WitnessRendering, render_witness};
40use super::witness::step::ResolutionStep;
41use super::{BindingResult, ResolvedBinding, SymbolClassification, classify_node};
42use crate::graph::unified::resolution::{SymbolCandidateBucket, SymbolResolutionOutcome};
43use crate::graph::unified::string::id::StringId;
44
45/// Combined resolution result: the existing `BindingResult` alongside the
46/// witness with its ordered step trace.
47///
48/// `BindingResolution` is the primary return type of [`BindingPlane::resolve`].
49/// Callers that only need the `BindingResult` can access `resolution.result`;
50/// callers that need the step trace can walk `resolution.witness.steps`.
51///
52/// Note: `BindingResolution` does not implement `Serialize`/`Deserialize`
53/// because `SymbolResolutionWitness` does not implement those traits (it
54/// contains `Vec<ResolutionStep>` which is Serialize, but the outer struct is
55/// not annotated). Use `result` for serializable output, or `witness.steps`
56/// for the step trace.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct BindingResolution {
59    /// The resolution result (bindings, outcome, normalized query).
60    pub result: BindingResult,
61    /// Ordered step trace from the resolver, including bucket probes,
62    /// scope entries, alias follows, shadow detections, and the final
63    /// `Chose` or `Unresolved` terminal.
64    pub witness: SymbolResolutionWitness,
65}
66
67/// Short-lived facade borrowing a `GraphSnapshot`.
68///
69/// Construct via [`GraphSnapshot::binding_plane`]. The facade provides the
70/// stable Phase 2 public API:
71///
72/// - [`BindingPlane::resolve`] — witness-bearing resolution entry point
73/// - [`BindingPlane::explain`] — renders a `BindingResolution` as text + JSON
74/// - Scope accessors: [`scope_of`], [`scope_chain`], [`scope`],
75///   [`scope_by_stable_id`], [`scope_provenance`]
76/// - Alias accessors: [`aliases_in`], [`resolve_alias`]
77/// - Shadow accessors: [`shadows_in`], [`effective_binding`]
78///
79/// # Usage from `CodeGraph`
80///
81/// ```rust,ignore
82/// // Two-line MVCC-safe pattern for CodeGraph callers:
83/// let snapshot = graph.snapshot();
84/// let plane = snapshot.binding_plane();
85/// let resolution = plane.resolve(&query);
86/// ```
87///
88/// # Usage from `ConcurrentCodeGraph`
89///
90/// ```rust,ignore
91/// // Three-line pattern for ConcurrentCodeGraph callers:
92/// let read_guard = concurrent.read();
93/// let snapshot = read_guard.snapshot();
94/// let plane = snapshot.binding_plane();
95/// let resolution = plane.resolve(&query);
96/// ```
97///
98/// The two/three-line idiom is intentional: `BindingPlane<'g>` borrows from a
99/// `GraphSnapshot` and the explicit snapshot handle makes the MVCC lifetime
100/// visible to the caller.
101pub struct BindingPlane<'g> {
102    snapshot: &'g GraphSnapshot,
103}
104
105impl<'g> BindingPlane<'g> {
106    /// Creates a new `BindingPlane` borrowing the given snapshot.
107    ///
108    /// Prefer `GraphSnapshot::binding_plane()` over calling this directly.
109    #[inline]
110    #[must_use]
111    pub fn new(snapshot: &'g GraphSnapshot) -> Self {
112        Self { snapshot }
113    }
114
115    /// Primary entry point — performs witness-bearing resolution and returns
116    /// the combined `BindingResolution` with both the `BindingResult` and the
117    /// ordered step trace.
118    ///
119    /// See module-level doc for the canonical emission-path conventions that
120    /// govern which step variants are emitted for visibility and shadow events.
121    #[must_use]
122    pub fn resolve(&self, query: &SymbolQuery<'_>) -> BindingResolution {
123        resolve_shared(query, self.snapshot)
124    }
125
126    /// Classifies a node as declaration / reference / import / ambiguous.
127    ///
128    /// Returns [`SymbolClassification::Unknown`] if `node_id` is not present
129    /// in the snapshot.
130    #[must_use]
131    pub fn classify(&self, node_id: NodeId) -> SymbolClassification {
132        let Some(entry) = self.snapshot.get_node(node_id) else {
133            return SymbolClassification::Unknown;
134        };
135        classify_node(self.snapshot, node_id, entry.kind)
136    }
137
138    // ------------------------------------------------------------------
139    // Scope accessors
140    // ------------------------------------------------------------------
141
142    /// Returns the `ScopeId` of the scope whose `node` field matches
143    /// `node_id`, or `None` if no such scope is allocated.
144    ///
145    /// Uses `ScopeArena::iter()` for correct generational-index handling.
146    #[must_use]
147    pub fn scope_of(&self, node_id: NodeId) -> Option<ScopeId> {
148        self.snapshot
149            .scope_arena()
150            .iter()
151            .find(|(_, scope)| scope.node == node_id)
152            .map(|(id, _)| id)
153    }
154
155    /// Returns the scope chain for `scope_id` in innermost-first order.
156    ///
157    /// Returns an empty `Vec` if `scope_id` is `ScopeId::INVALID` or is not
158    /// present in the arena.
159    #[must_use]
160    pub fn scope_chain(&self, scope_id: ScopeId) -> Vec<ScopeId> {
161        scope_chain(self.snapshot.scope_arena(), scope_id)
162    }
163
164    /// Returns a reference to the `Scope` record for `scope_id`, or `None` if
165    /// the handle is invalid or stale.
166    #[must_use]
167    pub fn scope(&self, scope_id: ScopeId) -> Option<&Scope> {
168        self.snapshot.scope_arena().get(scope_id)
169    }
170
171    /// Looks up the live `ScopeId` for a stable scope identity.
172    ///
173    /// Returns `None` if no provenance record is registered for `stable`.
174    #[must_use]
175    pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
176        self.snapshot.scope_by_stable_id(stable)
177    }
178
179    /// Looks up the `ScopeProvenance` record for `scope_id`.
180    ///
181    /// Returns `None` if `scope_id` is invalid, stale, or has no provenance
182    /// record in the store.
183    #[must_use]
184    pub fn scope_provenance(&self, scope_id: ScopeId) -> Option<&ScopeProvenance> {
185        self.snapshot.scope_provenance(scope_id)
186    }
187
188    // ------------------------------------------------------------------
189    // Alias accessors
190    // ------------------------------------------------------------------
191
192    /// Returns all alias entries registered for `scope_id`.
193    ///
194    /// Returns an empty slice if `scope_id` has no entries in the alias table.
195    #[must_use]
196    pub fn aliases_in(&self, scope_id: ScopeId) -> &[AliasEntry] {
197        self.snapshot.alias_table().aliases_in(scope_id)
198    }
199
200    /// Resolves an alias for `symbol` in `scope_id`, returning the canonical
201    /// target symbol `StringId` if one is registered.
202    ///
203    /// Returns `None` if no alias is registered for `(scope_id, symbol)`.
204    #[must_use]
205    pub fn resolve_alias(&self, scope_id: ScopeId, symbol: StringId) -> Option<StringId> {
206        self.snapshot.alias_table().resolve_alias(scope_id, symbol)
207    }
208
209    // ------------------------------------------------------------------
210    // Shadow accessors
211    // ------------------------------------------------------------------
212
213    /// Returns all shadow entries registered for `scope_id`, sorted by byte
214    /// offset (ascending).
215    #[must_use]
216    pub fn shadows_in(&self, scope_id: ScopeId) -> Vec<&ShadowEntry> {
217        self.snapshot.shadow_table().shadows_in(scope_id)
218    }
219
220    /// Returns the effective binding for `symbol` in `scope_id` at
221    /// `byte_offset` — i.e., the innermost re-binding strictly before that
222    /// offset.
223    ///
224    /// Returns `None` if no binding for `(scope_id, symbol)` is in scope at
225    /// `byte_offset`.
226    #[must_use]
227    pub fn effective_binding(
228        &self,
229        scope_id: ScopeId,
230        symbol: StringId,
231        byte_offset: u32,
232    ) -> Option<NodeId> {
233        self.snapshot
234            .shadow_table()
235            .effective_binding(scope_id, symbol, byte_offset)
236    }
237
238    // ------------------------------------------------------------------
239    // Explain
240    // ------------------------------------------------------------------
241
242    /// Renders a `BindingResolution` as a human-readable numbered step list
243    /// plus a deterministic JSON value.
244    ///
245    /// The JSON shape is the stable external contract for the CLI `--explain`
246    /// output produced in P2U10. Changes to the JSON keys/structure are a
247    /// breaking public-API change.
248    #[must_use]
249    pub fn explain(&self, resolution: &BindingResolution) -> WitnessRendering {
250        render_witness(&resolution.witness)
251    }
252}
253
254// ---------------------------------------------------------------------------
255// resolve_shared — the shared implementation core
256// ---------------------------------------------------------------------------
257
258/// Shared helper extracted from the pre-P2U07 `BindingQuery::resolve()` body.
259///
260/// Called by both `BindingQuery::resolve()` (which returns
261/// `BindingResolution.result`) and `BindingPlane::resolve()` (which returns
262/// the full `BindingResolution`). This is the drift-proof contract that
263/// preserves `BindingQuery::resolve()`'s byte-equal output on its existing
264/// call sites — both public entry points delegate to the same code path.
265///
266/// # Canonical emission-path conventions
267///
268/// The step trace inside `witness.steps` uses these canonical paths:
269///
270/// - **Visibility rejections**: prefer `ResolutionStep::FilterByVisibility {
271///   candidate, reason }`. Use `Rejected { reason: PrivateVisibility }` only
272///   in catch-all contexts where no `VisibilityReason` is available.
273///
274/// - **Shadow rejections**: prefer `ResolutionStep::ShadowedBy { outer,
275///   inner, by_node }` when both enclosing scopes are known. Use `Rejected {
276///   reason: Shadowed }` only in catch-all contexts where scope-pair
277///   information is absent.
278///
279/// # Step emission
280///
281/// The step trace documents the resolver's internal work:
282/// 1. `ApplyResolutionMode` — the caller-supplied mode
283/// 2. `LookupInBucket` — for each bucket probed (`ExactQualified`,
284///    `ExactSimple`, `CanonicalSuffix`)
285/// 3. `ConsiderCandidate` — for each candidate in the winning bucket
286/// 4. Terminal: `Chose` (single winner), `Ambiguous` (multiple), or
287///    `Unresolved` (not found / file not indexed)
288///
289/// Scope entries, alias follows, and shadow detection steps are emitted by
290/// higher-level P2U work that instruments the scope-walk loop. At P2U07
291/// the emission covers the bucket probe and terminal steps; the scope-walk
292/// instrumentation is added in P2U08.
293pub(crate) fn resolve_shared(
294    query: &SymbolQuery<'_>,
295    snapshot: &GraphSnapshot,
296) -> BindingResolution {
297    // Delegate to resolve_symbol_with_witness, which calls
298    // find_symbol_candidates_with_witness internally and maps the candidate
299    // outcome to SymbolResolutionOutcome. The mapping is identical to the
300    // pre-P2U07 BindingQuery::resolve() body.
301    let mut witness = snapshot.resolve_symbol_with_witness(query);
302
303    // Emit step trace for the resolution work performed.
304    emit_resolution_steps(&mut witness, query);
305
306    // Build bindings from witness.candidates, same as the pre-P2U07 body.
307    let bindings: Vec<ResolvedBinding> = witness
308        .candidates
309        .iter()
310        .filter_map(|candidate| {
311            let entry = snapshot.get_node(candidate.node_id)?;
312            Some(ResolvedBinding {
313                node_id: candidate.node_id,
314                classification: classify_node(snapshot, candidate.node_id, entry.kind),
315                bucket: candidate.bucket,
316                kind: entry.kind,
317            })
318        })
319        .collect();
320
321    let result = BindingResult {
322        query: witness.normalized_query.clone(),
323        bindings,
324        outcome: witness.outcome.clone(),
325    };
326
327    BindingResolution { result, witness }
328}
329
330/// Populates `witness.steps` with the ordered step trace for the resolution
331/// that already ran (post-hoc emission).
332///
333/// The step emission documents:
334/// 1. `ApplyResolutionMode` — the mode used for this query
335/// 2. `LookupInBucket` — for each bucket probed until one with candidates
336/// 3. `ConsiderCandidate` — for each candidate in the winning bucket
337/// 4. Terminal step — `Chose`, `Ambiguous`, or `Unresolved`
338///
339/// This is a post-hoc approach: `resolve_symbol_with_witness` has already
340/// run and we reconstruct the step trace from the outcome/candidates fields.
341/// The pre-P2U07 `resolve_symbol_with_witness` already returns `steps:
342/// Vec::new()`, so populating it here is safe and additive.
343fn emit_resolution_steps(witness: &mut SymbolResolutionWitness, query: &SymbolQuery<'_>) {
344    use super::witness::step::UnresolvedReason;
345    use smallvec::SmallVec;
346
347    let steps = &mut witness.steps;
348
349    // Step 1: document the resolution mode applied.
350    steps.push(ResolutionStep::ApplyResolutionMode { mode: query.mode });
351
352    // Step 2: document bucket probes. The resolver tries ExactQualified →
353    // ExactSimple → CanonicalSuffix (if mode allows suffix). We infer
354    // which buckets were tried from the winning bucket and the outcome.
355    let winning_bucket = witness.selected_bucket;
356    match winning_bucket {
357        None => {
358            // No bucket produced candidates — all three (or two) were tried
359            // and came up empty.
360            steps.push(ResolutionStep::LookupInBucket {
361                bucket: SymbolCandidateBucket::ExactQualified,
362            });
363            steps.push(ResolutionStep::LookupInBucket {
364                bucket: SymbolCandidateBucket::ExactSimple,
365            });
366            if query.mode
367                == crate::graph::unified::resolution::ResolutionMode::AllowSuffixCandidates
368            {
369                steps.push(ResolutionStep::LookupInBucket {
370                    bucket: SymbolCandidateBucket::CanonicalSuffix,
371                });
372            }
373        }
374        Some(SymbolCandidateBucket::ExactQualified) => {
375            steps.push(ResolutionStep::LookupInBucket {
376                bucket: SymbolCandidateBucket::ExactQualified,
377            });
378        }
379        Some(SymbolCandidateBucket::ExactSimple) => {
380            steps.push(ResolutionStep::LookupInBucket {
381                bucket: SymbolCandidateBucket::ExactQualified,
382            });
383            steps.push(ResolutionStep::LookupInBucket {
384                bucket: SymbolCandidateBucket::ExactSimple,
385            });
386        }
387        Some(SymbolCandidateBucket::CanonicalSuffix) => {
388            steps.push(ResolutionStep::LookupInBucket {
389                bucket: SymbolCandidateBucket::ExactQualified,
390            });
391            steps.push(ResolutionStep::LookupInBucket {
392                bucket: SymbolCandidateBucket::ExactSimple,
393            });
394            steps.push(ResolutionStep::LookupInBucket {
395                bucket: SymbolCandidateBucket::CanonicalSuffix,
396            });
397        }
398    }
399
400    // Step 3: document each candidate considered.
401    for (rank, candidate) in witness.candidates.iter().enumerate() {
402        steps.push(ResolutionStep::ConsiderCandidate {
403            node: candidate.node_id,
404            rank: u16::try_from(rank).unwrap_or(u16::MAX),
405        });
406    }
407
408    // Step 4: emit terminal step based on outcome.
409    //
410    // For Unresolved steps the symbol StringId is read from
411    // `witness.symbol`, which was populated by a read-only interner lookup
412    // in `resolve_symbol_with_witness`. When the symbol was not found in the
413    // interner (i.e., truly not indexed), we fall back to StringId(0) so
414    // callers can still match on the step variant.
415    let unresolved_symbol = witness
416        .symbol
417        .unwrap_or_else(|| crate::graph::unified::string::id::StringId::new(0));
418
419    match &witness.outcome {
420        SymbolResolutionOutcome::Resolved(node_id) => {
421            // `Resolved` is only constructed when exactly one candidate
422            // exists (see `resolve_symbol_with_witness`). A debug assertion
423            // makes this invariant explicit; no TieBreak step is needed.
424            debug_assert_eq!(
425                witness.candidates.len(),
426                1,
427                "Resolved outcome must have exactly one candidate"
428            );
429            steps.push(ResolutionStep::Chose { node: *node_id });
430        }
431        SymbolResolutionOutcome::Ambiguous(candidates) => {
432            let mut sv: SmallVec<[NodeId; 4]> = SmallVec::new();
433            sv.extend_from_slice(candidates);
434            steps.push(ResolutionStep::Ambiguous { candidates: sv });
435        }
436        SymbolResolutionOutcome::NotFound => {
437            steps.push(ResolutionStep::Unresolved {
438                symbol: unresolved_symbol,
439                reason: UnresolvedReason::NotInAnyScope,
440            });
441        }
442        SymbolResolutionOutcome::FileNotIndexed => {
443            steps.push(ResolutionStep::Unresolved {
444                symbol: unresolved_symbol,
445                reason: UnresolvedReason::FileNotIndexed,
446            });
447        }
448    }
449}
450
451// ---------------------------------------------------------------------------
452// Tests
453// ---------------------------------------------------------------------------
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::graph::node::Language;
459    use crate::graph::unified::concurrent::CodeGraph;
460    use crate::graph::unified::edge::kind::EdgeKind;
461    use crate::graph::unified::node::kind::NodeKind;
462    use crate::graph::unified::resolution::{FileScope, ResolutionMode, SymbolQuery};
463    use crate::graph::unified::storage::arena::NodeEntry;
464
465    // -------------------------------------------------------------------
466    // Test helper: build a minimal two-node graph with a Contains edge.
467    // -------------------------------------------------------------------
468    fn make_graph_with_function(sym: &str) -> CodeGraph {
469        let mut graph = CodeGraph::new();
470        let path = std::path::PathBuf::from("/plane-tests/test.rs");
471        let file_id = graph
472            .files_mut()
473            .register_with_language(&path, Some(Language::Rust))
474            .expect("register file");
475        let name = graph.strings_mut().intern(sym).expect("intern sym");
476        let qn = graph
477            .strings_mut()
478            .intern(&format!("crate::{sym}"))
479            .expect("intern qn");
480        let mod_name = graph.strings_mut().intern("root").expect("intern root");
481        let mod_qn = graph.strings_mut().intern("crate").expect("intern crate");
482        let mod_id = graph
483            .nodes_mut()
484            .alloc(
485                NodeEntry::new(NodeKind::Module, mod_name, file_id)
486                    .with_qualified_name(mod_qn)
487                    .with_byte_range(0, 100),
488            )
489            .expect("alloc mod");
490        graph
491            .indices_mut()
492            .add(mod_id, NodeKind::Module, mod_name, Some(mod_qn), file_id);
493        let fn_id = graph
494            .nodes_mut()
495            .alloc(
496                NodeEntry::new(NodeKind::Function, name, file_id)
497                    .with_qualified_name(qn)
498                    .with_byte_range(5, 80),
499            )
500            .expect("alloc fn");
501        graph
502            .indices_mut()
503            .add(fn_id, NodeKind::Function, name, Some(qn), file_id);
504        graph
505            .edges_mut()
506            .add_edge(mod_id, fn_id, EdgeKind::Contains, file_id);
507        graph
508    }
509
510    #[test]
511    fn plane_resolve_returns_binding_result_and_witness() {
512        let graph = make_graph_with_function("my_fn");
513        let snapshot = graph.snapshot();
514        let plane = snapshot.binding_plane();
515        let query = SymbolQuery {
516            symbol: "my_fn",
517            file_scope: FileScope::Any,
518            mode: ResolutionMode::AllowSuffixCandidates,
519        };
520        let resolution = plane.resolve(&query);
521
522        // BindingResult must have exactly one binding for "my_fn".
523        assert!(
524            !resolution.result.bindings.is_empty(),
525            "expected at least one binding"
526        );
527        assert_eq!(
528            resolution.result.bindings[0].classification,
529            SymbolClassification::Declaration,
530        );
531
532        // Witness must carry a non-empty step trace.
533        assert!(
534            !resolution.witness.steps.is_empty(),
535            "step trace must be non-empty after P2U07 emission"
536        );
537    }
538
539    #[test]
540    fn plane_resolve_not_found_emits_unresolved_step() {
541        let graph = make_graph_with_function("some_fn");
542        let snapshot = graph.snapshot();
543        let plane = snapshot.binding_plane();
544        let query = SymbolQuery {
545            symbol: "does_not_exist",
546            file_scope: FileScope::Any,
547            mode: ResolutionMode::Strict,
548        };
549        let resolution = plane.resolve(&query);
550
551        assert_eq!(resolution.result.outcome, SymbolResolutionOutcome::NotFound);
552        let has_unresolved = resolution.witness.steps.iter().any(|s| {
553            matches!(
554                s,
555                ResolutionStep::Unresolved {
556                    reason: super::super::witness::step::UnresolvedReason::NotInAnyScope,
557                    ..
558                }
559            )
560        });
561        assert!(
562            has_unresolved,
563            "expected Unresolved step for not-found query"
564        );
565    }
566
567    #[test]
568    fn plane_resolve_found_emits_chose_step() {
569        let graph = make_graph_with_function("chosen_fn");
570        let snapshot = graph.snapshot();
571        let plane = snapshot.binding_plane();
572        let query = SymbolQuery {
573            symbol: "chosen_fn",
574            file_scope: FileScope::Any,
575            mode: ResolutionMode::AllowSuffixCandidates,
576        };
577        let resolution = plane.resolve(&query);
578
579        let has_chose = resolution
580            .witness
581            .steps
582            .iter()
583            .any(|s| matches!(s, ResolutionStep::Chose { .. }));
584        assert!(has_chose, "expected Chose terminal step for resolved query");
585    }
586
587    #[test]
588    fn plane_explain_produces_non_empty_text_and_json() {
589        let graph = make_graph_with_function("explainable_fn");
590        let snapshot = graph.snapshot();
591        let plane = snapshot.binding_plane();
592        let query = SymbolQuery {
593            symbol: "explainable_fn",
594            file_scope: FileScope::Any,
595            mode: ResolutionMode::AllowSuffixCandidates,
596        };
597        let resolution = plane.resolve(&query);
598        let rendering = plane.explain(&resolution);
599
600        assert!(!rendering.text.is_empty(), "explain text must be non-empty");
601        assert!(
602            rendering.json.get("steps").is_some(),
603            "explain JSON must have a 'steps' field"
604        );
605    }
606
607    #[test]
608    fn binding_query_resolve_matches_plane_resolve_result() {
609        // Verify that BindingQuery::resolve() and BindingPlane::resolve().result
610        // return identical BindingResult values (byte-equality proof).
611        let graph = make_graph_with_function("parity_fn");
612        let snapshot = graph.snapshot();
613
614        let query_result = crate::graph::unified::bind::BindingQuery::new("parity_fn")
615            .file_scope(FileScope::Any)
616            .mode(ResolutionMode::AllowSuffixCandidates)
617            .resolve(&snapshot);
618
619        let plane_result = snapshot.binding_plane().resolve(&SymbolQuery {
620            symbol: "parity_fn",
621            file_scope: FileScope::Any,
622            mode: ResolutionMode::AllowSuffixCandidates,
623        });
624
625        assert_eq!(
626            query_result, plane_result.result,
627            "BindingQuery::resolve() and BindingPlane::resolve().result must be identical"
628        );
629    }
630
631    #[test]
632    fn scope_of_returns_none_for_unknown_node() {
633        let graph = make_graph_with_function("any_fn");
634        let snapshot = graph.snapshot();
635        let plane = snapshot.binding_plane();
636        // Use an invalid NodeId — scope_of must return None.
637        let invalid_id = NodeId::new(u32::MAX - 1, 99);
638        assert!(plane.scope_of(invalid_id).is_none());
639    }
640
641    #[test]
642    fn classify_returns_unknown_for_invalid_node() {
643        let graph = make_graph_with_function("any_fn2");
644        let snapshot = graph.snapshot();
645        let plane = snapshot.binding_plane();
646        let invalid_id = NodeId::new(u32::MAX - 2, 99);
647        assert_eq!(plane.classify(invalid_id), SymbolClassification::Unknown);
648    }
649
650    #[test]
651    fn step_trace_contains_apply_resolution_mode_first() {
652        let graph = make_graph_with_function("mode_fn");
653        let snapshot = graph.snapshot();
654        let plane = snapshot.binding_plane();
655        let query = SymbolQuery {
656            symbol: "mode_fn",
657            file_scope: FileScope::Any,
658            mode: ResolutionMode::Strict,
659        };
660        let resolution = plane.resolve(&query);
661
662        let first = resolution.witness.steps.first();
663        assert!(
664            matches!(
665                first,
666                Some(ResolutionStep::ApplyResolutionMode {
667                    mode: ResolutionMode::Strict
668                })
669            ),
670            "first step must be ApplyResolutionMode with the query mode"
671        );
672    }
673
674    #[test]
675    fn step_trace_exact_qualified_win_emits_single_bucket_step() {
676        // When the ExactQualified bucket wins, only one LookupInBucket step
677        // is emitted before the ConsiderCandidate/Chose steps.
678        let graph = make_graph_with_function("exact_fn");
679        let snapshot = graph.snapshot();
680        let plane = snapshot.binding_plane();
681
682        // Search by full qualified name so ExactQualified bucket wins.
683        let query = SymbolQuery {
684            symbol: "crate::exact_fn",
685            file_scope: FileScope::Any,
686            mode: ResolutionMode::AllowSuffixCandidates,
687        };
688        let resolution = plane.resolve(&query);
689
690        let bucket_steps: Vec<_> = resolution
691            .witness
692            .steps
693            .iter()
694            .filter(|s| matches!(s, ResolutionStep::LookupInBucket { .. }))
695            .collect();
696        // Exactly one bucket probe: ExactQualified won immediately.
697        assert_eq!(
698            bucket_steps.len(),
699            1,
700            "expected exactly one LookupInBucket step when ExactQualified wins"
701        );
702        assert!(matches!(
703            bucket_steps[0],
704            ResolutionStep::LookupInBucket {
705                bucket: SymbolCandidateBucket::ExactQualified
706            }
707        ));
708    }
709}