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