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}