Skip to main content

sqry_core/graph/unified/bind/
alias.rs

1//! Alias derivation for the Phase 2 binding plane.
2//!
3//! An `AliasEntry` records a single alias declaration (`from_symbol` in the
4//! scope resolves to the declaration at `to_symbol`). Wildcard imports
5//! (`from module import *`) are represented with `is_wildcard = true` and a
6//! sentinel `to_symbol` — the resolver forwards these through the target
7//! module's exports rather than the alias table directly.
8
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::graph::unified::build::phase4e_binding::BindingEdgeIndex;
14use crate::graph::unified::edge::kind::EdgeKind;
15use crate::graph::unified::mutation_target::GraphMutationTarget;
16use crate::graph::unified::node::id::NodeId;
17use crate::graph::unified::node::kind::NodeKind;
18use crate::graph::unified::string::id::StringId;
19
20use super::scope::{ScopeArena, ScopeId};
21
22/// Dense handle into an `AliasTable`.
23///
24/// The id is the position of the entry in the sorted `entries` slice, so
25/// `AliasEntryId(k)` is valid as long as the table hasn't been replaced.
26/// `AliasTable::get(id)` performs a single bounds-checked slice index.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct AliasEntryId(pub u32);
29
30/// One alias declaration.
31///
32/// Records the scope where the alias is declared, the local name the alias
33/// introduces (`from_symbol`), the target name the alias points to
34/// (`to_symbol`), the `EdgeId` of the `Imports` edge that produced this
35/// entry, and whether this was a wildcard import.
36///
37/// The `id` field is the position of this entry in the owning `AliasTable`'s
38/// sorted slice after `derive_aliases` has run. It is assigned by the sort
39/// and must not be mutated by callers.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct AliasEntry {
42    /// Stable dense handle for this entry within its owning `AliasTable`.
43    /// Assigned after sorting; equals `AliasEntryId(idx)` where `idx` is the
44    /// position of this entry in the sorted entries slice.
45    pub id: AliasEntryId,
46    /// Scope that contains this alias declaration.
47    pub scope: ScopeId,
48    /// The local symbol name introduced by the alias (e.g., `baz` from
49    /// `use foo::bar as baz`).
50    pub from_symbol: StringId,
51    /// The qualified target name the alias resolves to (e.g., `foo::bar`).
52    ///
53    /// Falls back to the local name if `qualified_name` is unset on the
54    /// import node. In that case `to_symbol == from_symbol`.
55    pub to_symbol: StringId,
56    /// `NodeId` of the `Import` node that this entry was derived from.
57    pub import_node: NodeId,
58    /// Whether this is a wildcard import (`from module import *`).
59    pub is_wildcard: bool,
60}
61
62/// Content-addressed alias lookup table.
63///
64/// Entries are sorted by `(scope, from_symbol)` for `O(log n)` binary-search
65/// lookup via [`AliasTable::resolve_alias`]. Wildcard entries are included in
66/// [`AliasTable::aliases_in`] but are skipped by `resolve_alias` — the
67/// resolver dispatches wildcards through the target module's exports.
68///
69/// # Serialization
70///
71/// Only `entries` is persisted. The `by_scope` index is derived from
72/// `entries` on deserialization and cannot drift out of sync with the entries
73/// slice. This keeps the V9 snapshot footprint minimal and eliminates the
74/// dual-write hazard.
75#[derive(Debug, Clone, Default)]
76pub struct AliasTable {
77    /// All entries, sorted by `(scope, from_symbol)`.
78    entries: Vec<AliasEntry>,
79    /// Per-scope range index mapping a `ScopeId` to its `[start, end)` slice
80    /// in `entries`. Populated after the entries are sorted; **not** persisted.
81    by_scope: HashMap<ScopeId, (u32, u32)>,
82}
83
84impl AliasTable {
85    /// Creates an empty `AliasTable`.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Returns the total number of alias entries.
92    #[must_use]
93    pub fn len(&self) -> usize {
94        self.entries.len()
95    }
96
97    /// Returns `true` if the table contains no entries.
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.entries.is_empty()
101    }
102
103    /// Returns the full sorted entries slice.
104    ///
105    /// Useful for V9 persistence walkers, debug dumps, stats collection, and
106    /// any future consumer that needs to iterate the entire table.
107    #[must_use]
108    pub fn entries(&self) -> &[AliasEntry] {
109        &self.entries
110    }
111
112    /// Looks up an alias entry by its dense `AliasEntryId`.
113    ///
114    /// Returns `None` if `id.0 >= self.len()`. The id is the position of the
115    /// entry in the sorted slice, assigned during `derive_aliases`. It is
116    /// stable for the lifetime of this table instance.
117    ///
118    /// Used by `ResolutionStep::FollowAlias { alias: AliasEntryId }` in the
119    /// P2U07 resolver to recover the full `AliasEntry` for a recorded step.
120    #[must_use]
121    pub fn get(&self, id: AliasEntryId) -> Option<&AliasEntry> {
122        self.entries.get(id.0 as usize)
123    }
124
125    /// Returns all alias entries declared in `scope`, including wildcard
126    /// entries. Returns an empty slice when no aliases belong to `scope`.
127    #[must_use]
128    pub fn aliases_in(&self, scope: ScopeId) -> &[AliasEntry] {
129        match self.by_scope.get(&scope) {
130            Some(&(start, end)) => &self.entries[start as usize..end as usize],
131            None => &[],
132        }
133    }
134
135    /// Resolves `symbol` within `scope` to its target name.
136    ///
137    /// Returns `None` when no matching non-wildcard alias exists. Wildcard
138    /// entries are deliberately skipped — the resolver dispatches wildcards
139    /// through the target module's exports at query time.
140    ///
141    /// The lookup uses binary search on the sorted `(scope, from_symbol)`
142    /// order, so it is `O(log n)` in the number of aliases in `scope`.
143    #[must_use]
144    pub fn resolve_alias(&self, scope: ScopeId, symbol: StringId) -> Option<StringId> {
145        let slice = self.aliases_in(scope);
146        // slice is already scope-filtered, so sorting by from_symbol alone is valid.
147        slice
148            .binary_search_by(|entry| entry.from_symbol.cmp(&symbol))
149            .ok()
150            .map(|idx| &slice[idx])
151            .filter(|entry| !entry.is_wildcard)
152            .map(|entry| entry.to_symbol)
153    }
154
155    /// Applies `keep` to every entry's `import_node`, dropping entries whose
156    /// import-node fails the predicate.
157    ///
158    /// After filtering, the `id` of each surviving entry is reassigned to its
159    /// new position in the sorted `entries` slice, and the `by_scope` range
160    /// index is rebuilt from scratch so `resolve_alias` / `aliases_in` stay
161    /// consistent. The relative order of surviving entries is preserved, so
162    /// the table remains sorted by `(scope, from_symbol)`.
163    ///
164    /// This is the mutation entry point used by the Gate 0c `NodeIdBearing`
165    /// impl (A2 §K row K.A12). Callers that hold the table behind an `Arc`
166    /// must reach it through `Arc::make_mut` before invoking this method.
167    ///
168    /// Live in the default build: the consumer is `RebuildGraph::finalize()`
169    /// via the `retain_nodes` impl, reached from the ungated public
170    /// `build::incremental::incremental_rebuild` -> `finalize` path (the
171    /// `rebuild::coverage` unit tests exercise it too).
172    pub(crate) fn retain_by_node(&mut self, keep: &dyn Fn(NodeId) -> bool) {
173        self.entries.retain(|entry| keep(entry.import_node));
174        for (idx, entry) in self.entries.iter_mut().enumerate() {
175            entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
176        }
177        self.rebuild_index();
178    }
179
180    /// Rewrite every `StringId` stored on every entry through `remap`,
181    /// replacing any ID that appears as a key with its canonical value.
182    ///
183    /// This is the mutation entry point used by Gate 0c's finalize step 1
184    /// (`StringId` canonicalisation). `from_symbol` is part of the
185    /// `(scope, from_symbol)` sort key, so after rewrite the entries are
186    /// re-sorted by `(scope, from_symbol)` and `by_scope` is rebuilt so
187    /// `resolve_alias` / `aliases_in` keep returning consistent results.
188    ///
189    /// The relative order within a `(scope, from_symbol)` group is
190    /// preserved by using a stable sort. Empty `remap` is a no-op.
191    ///
192    /// Callers that hold the table behind an `Arc` must reach it through
193    /// `Arc::make_mut` before invoking this method.
194    ///
195    /// Live in the default build: the consumer is `RebuildGraph::finalize()`
196    /// step 1, reached from the ungated public
197    /// `build::incremental::incremental_rebuild` -> `finalize` path.
198    pub(crate) fn rewrite_string_ids_through_remap(
199        &mut self,
200        remap: &std::collections::HashMap<StringId, StringId>,
201    ) {
202        if remap.is_empty() {
203            return;
204        }
205        let mut changed = false;
206        for entry in &mut self.entries {
207            if let Some(&canon) = remap.get(&entry.from_symbol) {
208                entry.from_symbol = canon;
209                changed = true;
210            }
211            if let Some(&canon) = remap.get(&entry.to_symbol) {
212                entry.to_symbol = canon;
213                changed = true;
214            }
215        }
216        if !changed {
217            return;
218        }
219        // `from_symbol` may have collapsed onto a different canonical ID,
220        // so re-establish the (scope, from_symbol) sort order and rebuild
221        // the scope range index. Use a stable sort to preserve the prior
222        // order within each (scope, from_symbol) group.
223        self.entries
224            .sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
225        for (idx, entry) in self.entries.iter_mut().enumerate() {
226            entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
227        }
228        self.rebuild_index();
229    }
230
231    /// Rebuilds the `by_scope` range index from `self.entries`.
232    ///
233    /// Called after deserialization (where `by_scope` is not persisted) and
234    /// after constructing an `AliasTable` from a raw `Vec<AliasEntry>`. The
235    /// entries must already be sorted by `(scope, from_symbol)`.
236    fn rebuild_index(&mut self) {
237        debug_assert!(
238            self.entries.windows(2).all(|w| w[0].scope <= w[1].scope),
239            "rebuild_index requires entries sorted by scope ascending"
240        );
241        self.by_scope.clear();
242        let mut cursor = 0u32;
243        while (cursor as usize) < self.entries.len() {
244            let scope = self.entries[cursor as usize].scope;
245            let start = cursor;
246            while (cursor as usize) < self.entries.len()
247                && self.entries[cursor as usize].scope == scope
248            {
249                cursor += 1;
250            }
251            self.by_scope.insert(scope, (start, cursor));
252        }
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Serde: persist only `entries`; rebuild `by_scope` on deserialize.
258// ---------------------------------------------------------------------------
259
260impl Serialize for AliasTable {
261    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
262        // Serialize as a newtype wrapping just the entries Vec.
263        self.entries.serialize(serializer)
264    }
265}
266
267impl<'de> Deserialize<'de> for AliasTable {
268    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
269        let entries = Vec::<AliasEntry>::deserialize(deserializer)?;
270        let mut table = AliasTable {
271            entries,
272            by_scope: HashMap::new(),
273        };
274        table.rebuild_index();
275        Ok(table)
276    }
277}
278
279/// Iterates every `Import` node via the `by_kind` index and, for each one,
280/// looks up its incoming `Imports` edges from the pre-built
281/// `BindingEdgeIndex::imports_by_target` map (O(1) per node). The source of
282/// each such edge is the importing scope's node; the enclosing scope is
283/// resolved via the precomputed `node_to_scope` map built from
284/// `ScopeArena::iter()`.
285///
286/// For `use foo::bar as baz;` the `alias` field on the edge carries `baz` and
287/// the import node's `qualified_name` carries `foo::bar`. For
288/// `from module import *`, `alias` is `None`, `is_wildcard` is `true`, and the
289/// import node's name is the sentinel `*`.
290///
291/// When the import node's `qualified_name` is `None`, `to_symbol` falls back
292/// to the plain `name` field — i.e., `to_symbol == from_symbol`.
293///
294/// Returns `(AliasTable, aliases_with_invalid_scope)` where the second value
295/// is the number of `Import` nodes whose enclosing scope could not be resolved.
296///
297/// # Performance
298///
299/// The `edge_index` parameter replaces the per-import `edges_to()` calls that
300/// previously triggered O(E) delta scans, turning per-node edge lookup from
301/// O(E) to O(1).
302pub(crate) fn derive_aliases<G: GraphMutationTarget>(
303    graph: &G,
304    scopes: &ScopeArena,
305    edge_index: &BindingEdgeIndex,
306) -> (AliasTable, u64) {
307    // Build a NodeId → ScopeId map for O(1) enclosing-scope lookup.
308    // Uses ScopeArena::iter() so each slot's real generation is used; avoids
309    // the generation=1 hardcode that a manual 0..slot_count loop would require.
310    let node_to_scope = build_node_to_scope_map(scopes);
311
312    let mut raw: Vec<AliasEntry> = Vec::new();
313    let mut invalid_scope_count: u64 = 0;
314
315    // Iterate every Import node and read its incoming Imports edges from
316    // the pre-built index (O(1) per node). Imports edges point FROM the
317    // importing scope/module TO the import node.
318    for &import_node_id in graph.indices().by_kind(NodeKind::Import) {
319        let Some(import_entry) = graph.nodes().get(import_node_id) else {
320            continue;
321        };
322
323        // Look up pre-indexed incoming Imports edges for this import node.
324        let Some(incoming) = edge_index.imports_by_target.get(&import_node_id) else {
325            continue;
326        };
327
328        for (source_node_id, edge_kind) in incoming {
329            let EdgeKind::Imports { alias, is_wildcard } = edge_kind else {
330                continue;
331            };
332
333            // Resolve the enclosing scope.
334            let scope = if let Some(id) = node_to_scope.get(source_node_id).copied() {
335                id
336            } else {
337                // The source node has no corresponding scope. Emit the
338                // entry with INVALID scope and count it for observability.
339                invalid_scope_count += 1;
340                ScopeId::INVALID
341            };
342
343            // `alias` holds the local name (e.g., `baz` in `use foo::bar as baz`).
344            // When no alias is given, use the import node's own name.
345            let from_symbol = alias.unwrap_or(import_entry.name);
346
347            // The target qualified name comes from the import node's qualified_name
348            // field, falling back to the plain name if not set — i.e., to_symbol
349            // equals from_symbol in the no-qualified-name case.
350            let to_symbol = import_entry.qualified_name.unwrap_or(import_entry.name);
351
352            // Placeholder id; will be overwritten after sorting.
353            raw.push(AliasEntry {
354                id: AliasEntryId(0),
355                scope,
356                from_symbol,
357                to_symbol,
358                import_node: import_node_id,
359                is_wildcard: *is_wildcard,
360            });
361        }
362    }
363
364    // Sort by (scope, from_symbol) to enable binary-search lookup.
365    raw.sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
366
367    // Assign stable ids: the position in the sorted slice IS the AliasEntryId.
368    for (idx, entry) in raw.iter_mut().enumerate() {
369        entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
370    }
371
372    // Build the per-scope range index via the shared helper so the logic lives
373    // in exactly one place (deserialization also calls rebuild_index).
374    let mut table = AliasTable {
375        entries: raw,
376        by_scope: HashMap::new(),
377    };
378    table.rebuild_index();
379
380    (table, invalid_scope_count)
381}
382
383/// Builds a `HashMap<NodeId, ScopeId>` from the scope arena so that
384/// `derive_aliases` can resolve the enclosing scope for any node in O(1).
385///
386/// Uses `ScopeArena::iter()` so each slot's real generation is used and the
387/// generation=1 hardcode that a manual `0..slot_count` loop would require is
388/// avoided.
389fn build_node_to_scope_map(scopes: &ScopeArena) -> HashMap<NodeId, ScopeId> {
390    scopes.iter().map(|(id, scope)| (scope.node, id)).collect()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::graph::unified::node::id::NodeId;
397    use crate::graph::unified::string::id::StringId;
398
399    fn make_scope(index: u32) -> ScopeId {
400        ScopeId::new(index, 1)
401    }
402
403    fn make_node() -> NodeId {
404        NodeId::new(0, 1)
405    }
406
407    fn make_entry(scope: ScopeId, from: u32, to: u32, wildcard: bool) -> AliasEntry {
408        AliasEntry {
409            id: AliasEntryId(0), // patched by derive_aliases; set manually in unit tests
410            scope,
411            from_symbol: StringId::new(from),
412            to_symbol: StringId::new(to),
413            import_node: make_node(),
414            is_wildcard: wildcard,
415        }
416    }
417
418    /// Build an `AliasTable` directly from a pre-sorted entries slice (for
419    /// unit tests that don't want to go through `derive_aliases`).
420    fn table_from_sorted(mut entries: Vec<AliasEntry>) -> AliasTable {
421        for (idx, e) in entries.iter_mut().enumerate() {
422            e.id = AliasEntryId(u32::try_from(idx).unwrap());
423        }
424        let mut table = AliasTable {
425            entries,
426            by_scope: HashMap::new(),
427        };
428        table.rebuild_index();
429        table
430    }
431
432    #[test]
433    fn empty_table_lookup_returns_none() {
434        let table = AliasTable::new();
435        assert!(table.is_empty());
436        let scope = make_scope(0);
437        assert!(table.aliases_in(scope).is_empty());
438        assert_eq!(table.resolve_alias(scope, StringId::new(0)), None);
439    }
440
441    #[test]
442    fn binary_search_resolve_alias() {
443        let scope = make_scope(0);
444        let entries = vec![
445            make_entry(scope, 10, 100, false),
446            make_entry(scope, 20, 200, false),
447        ];
448        let table = table_from_sorted(entries);
449        assert_eq!(
450            table.resolve_alias(scope, StringId::new(10)),
451            Some(StringId::new(100))
452        );
453        assert_eq!(
454            table.resolve_alias(scope, StringId::new(20)),
455            Some(StringId::new(200))
456        );
457        assert_eq!(table.resolve_alias(scope, StringId::new(30)), None);
458    }
459
460    #[test]
461    fn wildcard_skipped_by_resolve() {
462        let scope = make_scope(0);
463        let entries = vec![make_entry(scope, 5, 99, true)];
464        let table = table_from_sorted(entries);
465        assert_eq!(table.resolve_alias(scope, StringId::new(5)), None);
466        assert!(
467            table.aliases_in(scope).iter().any(|e| e.is_wildcard),
468            "wildcard entry visible via aliases_in"
469        );
470    }
471
472    #[test]
473    fn get_by_alias_entry_id_round_trips() {
474        let scope = make_scope(0);
475        let entries = vec![
476            make_entry(scope, 10, 100, false),
477            make_entry(scope, 20, 200, false),
478        ];
479        let table = table_from_sorted(entries);
480
481        // Ids are assigned by position after sort.
482        let e0 = table.get(AliasEntryId(0)).expect("id 0 must exist");
483        let e1 = table.get(AliasEntryId(1)).expect("id 1 must exist");
484
485        assert_eq!(e0.from_symbol, StringId::new(10));
486        assert_eq!(e0.to_symbol, StringId::new(100));
487        assert_eq!(e0.id, AliasEntryId(0));
488
489        assert_eq!(e1.from_symbol, StringId::new(20));
490        assert_eq!(e1.to_symbol, StringId::new(200));
491        assert_eq!(e1.id, AliasEntryId(1));
492
493        assert!(table.get(AliasEntryId(2)).is_none());
494    }
495
496    #[test]
497    fn entries_accessor_returns_full_slice() {
498        let scope = make_scope(0);
499        let entries = vec![
500            make_entry(scope, 1, 10, false),
501            make_entry(scope, 2, 20, false),
502            make_entry(scope, 3, 30, true),
503        ];
504        let table = table_from_sorted(entries);
505        assert_eq!(table.entries().len(), 3);
506    }
507
508    #[test]
509    fn postcard_round_trip_preserves_entries_and_rebuilds_by_scope() {
510        let s_a = make_scope(1);
511        let s_b = make_scope(2);
512        let entries = vec![
513            make_entry(s_a, 10, 100, false),
514            make_entry(s_a, 11, 101, false),
515            make_entry(s_b, 20, 200, true),
516        ];
517        let original = table_from_sorted(entries);
518
519        let bytes = postcard::to_allocvec(&original).expect("serialize");
520        let restored: AliasTable = postcard::from_bytes(&bytes).expect("deserialize");
521
522        assert_eq!(
523            restored.entries().len(),
524            original.entries().len(),
525            "entry count must survive round-trip"
526        );
527        assert_eq!(
528            restored.entries(),
529            original.entries(),
530            "entries must be byte-equal after round-trip"
531        );
532
533        // Verify by_scope was rebuilt correctly: resolve_alias must work.
534        assert_eq!(
535            restored.resolve_alias(s_a, StringId::new(10)),
536            Some(StringId::new(100)),
537            "resolve_alias on restored table must return correct target for s_a entry 0"
538        );
539        assert_eq!(
540            restored.resolve_alias(s_a, StringId::new(11)),
541            Some(StringId::new(101)),
542            "resolve_alias on restored table must return correct target for s_a entry 1"
543        );
544        // Wildcard entry in s_b is not resolved by resolve_alias.
545        assert_eq!(
546            restored.resolve_alias(s_b, StringId::new(20)),
547            None,
548            "wildcard entry must not be returned by resolve_alias after round-trip"
549        );
550        assert!(
551            restored.aliases_in(s_b).iter().any(|e| e.is_wildcard),
552            "wildcard entry in s_b must survive round-trip"
553        );
554    }
555
556    #[test]
557    fn aliases_in_partition_by_scope() {
558        let s_a = make_scope(1);
559        let s_b = make_scope(2);
560        let s_c = make_scope(3);
561
562        let from_x = StringId::new(10);
563        let from_y = StringId::new(11);
564        let to_1 = StringId::new(20);
565        let to_2 = StringId::new(21);
566
567        // Six entries across three scopes, two entries each.
568        // Build unsorted and let table_from_sorted sort them so the sort matters.
569        let mut entries = vec![
570            // s_b entries (will sort after s_a, before s_c by ScopeId ordering)
571            AliasEntry {
572                id: AliasEntryId(0),
573                scope: s_b,
574                from_symbol: from_x,
575                to_symbol: to_1,
576                import_node: make_node(),
577                is_wildcard: false,
578            },
579            AliasEntry {
580                id: AliasEntryId(0),
581                scope: s_b,
582                from_symbol: from_y,
583                to_symbol: to_2,
584                import_node: make_node(),
585                is_wildcard: false,
586            },
587            // s_c entries
588            AliasEntry {
589                id: AliasEntryId(0),
590                scope: s_c,
591                from_symbol: from_x,
592                to_symbol: to_2,
593                import_node: make_node(),
594                is_wildcard: false,
595            },
596            AliasEntry {
597                id: AliasEntryId(0),
598                scope: s_c,
599                from_symbol: from_y,
600                to_symbol: to_1,
601                import_node: make_node(),
602                is_wildcard: false,
603            },
604            // s_a entries (will sort first)
605            AliasEntry {
606                id: AliasEntryId(0),
607                scope: s_a,
608                from_symbol: from_x,
609                to_symbol: to_2,
610                import_node: make_node(),
611                is_wildcard: false,
612            },
613            AliasEntry {
614                id: AliasEntryId(0),
615                scope: s_a,
616                from_symbol: from_y,
617                to_symbol: to_1,
618                import_node: make_node(),
619                is_wildcard: false,
620            },
621        ];
622        entries.sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
623        let table = table_from_sorted(entries);
624
625        // Each scope must see exactly its own 2 entries.
626        let b_entries = table.aliases_in(s_b);
627        assert_eq!(b_entries.len(), 2, "s_b must have exactly 2 entries");
628        assert!(
629            b_entries.iter().all(|e| e.scope == s_b),
630            "s_b slice must only contain s_b entries"
631        );
632
633        let a_entries = table.aliases_in(s_a);
634        assert_eq!(a_entries.len(), 2, "s_a must have exactly 2 entries");
635        assert!(
636            a_entries.iter().all(|e| e.scope == s_a),
637            "s_a slice must only contain s_a entries"
638        );
639
640        let c_entries = table.aliases_in(s_c);
641        assert_eq!(c_entries.len(), 2, "s_c must have exactly 2 entries");
642        assert!(
643            c_entries.iter().all(|e| e.scope == s_c),
644            "s_c slice must only contain s_c entries"
645        );
646
647        // Scope isolation: same from_symbol in different scopes resolves to different targets.
648        let resolved_a = table.resolve_alias(s_a, from_x);
649        let resolved_b = table.resolve_alias(s_b, from_x);
650        assert!(resolved_a.is_some(), "s_a must resolve from_x");
651        assert!(resolved_b.is_some(), "s_b must resolve from_x");
652        assert_ne!(
653            resolved_a, resolved_b,
654            "same from_symbol in different scopes must resolve to different targets"
655        );
656    }
657}