Skip to main content

projectatlas/runtime/graph_projection/
binding_resolution.rs

1//! Bounded source-proved forwarding, resolved before canonical graph publication.
2
3mod rust;
4
5use super::{
6    CliError, EntityProjection, MAX_INCREMENTAL_RESOLUTION_ITEMS, PackageIndex,
7    ProjectResolutionRegistry, ResolutionCandidateKind, STAGED_GRAPH_ROW_BYTES, SourceBindingCache,
8    SourceGraph, append_persisted_resolution_candidates, dependency_closure_limit,
9    document_file_resolution_key, enforce_resolution_registry_budget_with_limit,
10    entity_retained_bytes, invalid_graph_contract, registry_resolution_matches, relation_reference,
11    resolution_key_limit_failure, resolution_retained_bytes, source_symbol_entity,
12};
13use projectatlas_core::graph::{
14    CanonicalResolutionKey, EntitySelector, GraphEntity, GraphIdentityText, GraphLimits,
15    ProjectInstanceId, RelationResolution,
16};
17use projectatlas_core::language::{SemanticProviderOwner, language_capability};
18use projectatlas_core::symbols::{SymbolGraph, SymbolKind, SymbolRelation};
19use projectatlas_core::{IndexGeneration, IndexWorkControl, IndexWorkStage};
20use projectatlas_db::AtlasStore;
21use projectatlas_symbols::{
22    ConfiguredModuleResolution, ResolutionKeyProjection, ResolutionProjectionError,
23    SourceCallBinding, SourceSemanticFacts, derive_resolution_keys_with_context,
24};
25use projectatlas_symbols::{ImportReference, SourceExportBinding, SourceExportNamespace};
26use std::cmp::Ordering;
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28use std::sync::Arc;
29
30/// Maximum explicit forwarding steps; longer chains remain unresolved.
31const MAX_FORWARDING_DEPTH: usize = 64;
32
33/// One immutable call outcome, shared by identical source binding queries.
34#[derive(Default)]
35pub(super) struct CallBindingResolution {
36    /// Actual admitted declaration, never an exporting file marker.
37    target: Option<GraphEntity>,
38    /// Source-owned competing slots retain an honest bounded candidate count.
39    ambiguous: Option<std::num::NonZeroU32>,
40    /// Source proves this binding does not occupy the requested namespace.
41    absent: bool,
42    /// An explicit export slot was followed, including unsupported or cyclic slots.
43    followed_export: bool,
44    /// Invalidation causes are independent from the declaration target.
45    dependencies: BTreeSet<CanonicalResolutionKey>,
46}
47
48impl CallBindingResolution {
49    /// Construct a canonical outcome without reinterpreting lookup markers as targets.
50    pub(super) fn resolution(
51        &self,
52        relation: &SymbolRelation,
53    ) -> Result<RelationResolution, CliError> {
54        if let Some(candidates) = self.ambiguous {
55            return Ok(RelationResolution::Ambiguous {
56                reference: GraphIdentityText::new(relation_reference(relation))
57                    .map_err(invalid_graph_contract)?,
58                candidates,
59            });
60        }
61        self.target.as_ref().map_or_else(
62            || {
63                Ok(RelationResolution::Unresolved {
64                    reference: GraphIdentityText::new(relation_reference(relation))
65                        .map_err(invalid_graph_contract)?,
66                })
67            },
68            |target| {
69                if matches!(target.selector(), EntitySelector::External { .. }) {
70                    RelationResolution::external(target).map_err(invalid_graph_contract)
71                } else {
72                    RelationResolution::resolved(target).map_err(invalid_graph_contract)
73                }
74            },
75        )
76    }
77
78    /// Borrow an actual external entity that publication must stage with its relation.
79    pub(super) fn external_target(&self) -> Option<&GraphEntity> {
80        self.target
81            .as_ref()
82            .filter(|target| matches!(target.selector(), EntitySelector::External { .. }))
83    }
84
85    /// Borrow causes retained even when forwarding cannot establish a target.
86    pub(super) fn dependencies(&self) -> &BTreeSet<CanonicalResolutionKey> {
87        &self.dependencies
88    }
89
90    /// Account this uniquely cached outcome and its owned payloads.
91    fn retained_bytes(&self) -> u64 {
92        self.dependencies
93            .iter()
94            .fold(STAGED_GRAPH_ROW_BYTES, |bytes, key| {
95                bytes
96                    .saturating_add(STAGED_GRAPH_ROW_BYTES)
97                    .saturating_add(key.canonical_identity().len() as u64)
98            })
99            .saturating_add(self.target.as_ref().map_or(0, entity_retained_bytes))
100    }
101}
102
103/// Bytes temporarily reserved while constructing one retained resolver value.
104#[derive(Default)]
105struct ResolverScratch {
106    /// Reservations released or transferred when the operation returns.
107    retained_bytes: u64,
108}
109
110impl ResolverScratch {
111    /// Refuse growth before retaining another owned entry.
112    fn reserve(
113        &mut self,
114        shared: &mut BindingResolver<'_, '_, '_>,
115        bytes: u64,
116    ) -> Result<(), CliError> {
117        shared.reserve_scratch(bytes)?;
118        self.retained_bytes = self.retained_bytes.saturating_add(bytes);
119        Ok(())
120    }
121}
122
123/// A query-owned cause set whose unique key copies are reserved before insertion.
124struct CauseWorkspace<'a> {
125    /// Exact deduplicated invalidation keys being constructed.
126    keys: BTreeSet<CanonicalResolutionKey>,
127    /// Construction owner that transfers these bytes into the completed value.
128    scratch: &'a mut ResolverScratch,
129}
130
131impl<'a> CauseWorkspace<'a> {
132    /// Start without allocating or borrowing a cached dependency set.
133    fn new(scratch: &'a mut ResolverScratch) -> Self {
134        Self {
135            keys: BTreeSet::new(),
136            scratch,
137        }
138    }
139}
140
141/// Per-graph, per-occurrence forwarding outcomes.
142pub(super) type CallBindings = BTreeMap<String, BTreeMap<usize, Arc<CallBindingResolution>>>;
143
144/// Canonical module value identity, valid only within one binding resolver.
145#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
146struct ModuleId(usize);
147
148/// Intern module values once per source allocation, retaining witnesses until drop.
149#[derive(Default)]
150struct ModuleInterner {
151    /// The retained Arc prevents a remembered address from being reused.
152    allocations: BTreeMap<(usize, usize), (Arc<str>, ModuleId)>,
153    /// Standard value equality handles equal allocations and hash collisions exactly.
154    values: HashMap<Arc<str>, ModuleId>,
155}
156
157impl ModuleInterner {
158    /// Admit metadata before insertion; hot allocations never hash module bytes again.
159    fn intern<E>(
160        &mut self,
161        module: Arc<str>,
162        admit: impl FnOnce(u64) -> Result<(), E>,
163    ) -> Result<ModuleId, E> {
164        let allocation = (module.as_ptr() as usize, module.len());
165        if let Some((_, id)) = self.allocations.get(&allocation) {
166            return Ok(*id);
167        }
168        let existing = self.values.get(module.as_ref()).copied();
169        let entries = if existing.is_some() { 1 } else { 2 };
170        admit(STAGED_GRAPH_ROW_BYTES.saturating_mul(entries))?;
171        let id = if let Some(id) = existing {
172            id
173        } else {
174            let id = ModuleId(self.values.len());
175            self.values.insert(Arc::clone(&module), id);
176            id
177        };
178        self.allocations.insert(allocation, (module, id));
179        Ok(id)
180    }
181}
182
183/// A typed module/export/member request; canonical key witnesses remain opaque.
184#[derive(Clone)]
185struct BindingQuery {
186    /// Source whose import syntax owns this module reference.
187    source: String,
188    /// Cooked module allocation retained and accounted by its source evidence.
189    module: Arc<str>,
190    /// Exact value identity from the same resolver used by both query/visited maps.
191    module_id: ModuleId,
192    /// Explicit imported slot, absent for a namespace binding.
193    imported: Option<String>,
194    /// Remaining statically named namespace member.
195    member: Option<String>,
196}
197
198impl BindingQuery {
199    /// Preserve binding roles and exact module values through one resolver's interner.
200    fn new<E>(
201        source: &str,
202        reference: &ImportReference,
203        member: Option<&str>,
204        modules: &mut ModuleInterner,
205        admit: impl FnOnce(u64) -> Result<(), E>,
206    ) -> Result<Self, E> {
207        let module = reference.shared_module();
208        let module_id = modules.intern(Arc::clone(&module), admit)?;
209        Ok(Self {
210            source: source.to_string(),
211            module,
212            module_id,
213            imported: reference.imported().map(ToString::to_string),
214            member: member.map(ToString::to_string),
215        })
216    }
217
218    /// Compare short fields and one canonical ID; text stays available for resolution.
219    fn identity(&self) -> (&str, Option<&str>, Option<&str>, ModuleId) {
220        (
221            &self.source,
222            self.imported.as_deref(),
223            self.member.as_deref(),
224            self.module_id,
225        )
226    }
227
228    /// Count query-owned text and headers; source owners already charge shared module bytes.
229    fn retained_bytes(&self) -> u64 {
230        STAGED_GRAPH_ROW_BYTES
231            .saturating_add(self.source.capacity() as u64)
232            .saturating_add(self.imported.as_ref().map_or(0, String::capacity) as u64)
233            .saturating_add(self.member.as_ref().map_or(0, String::capacity) as u64)
234    }
235}
236
237impl PartialEq for BindingQuery {
238    fn eq(&self, other: &Self) -> bool {
239        self.identity() == other.identity()
240    }
241}
242
243impl Eq for BindingQuery {}
244
245impl PartialOrd for BindingQuery {
246    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
247        Some(self.cmp(other))
248    }
249}
250
251impl Ord for BindingQuery {
252    fn cmp(&self, other: &Self) -> Ordering {
253        self.identity().cmp(&other.identity())
254    }
255}
256
257/// Existing staged projection or an extra context source's admitted key projection.
258enum SourceProjection<'g> {
259    /// Reuse already validated keys without cloning them.
260    Staged(&'g ResolutionKeyProjection),
261    /// Project an explicitly required unchanged context once.
262    Captured(ResolutionKeyProjection),
263}
264
265impl SourceProjection<'_> {
266    /// Borrow the common key projection.
267    fn get(&self) -> &ResolutionKeyProjection {
268        match self {
269            Self::Staged(keys) => keys,
270            Self::Captured(keys) => keys,
271        }
272    }
273}
274
275/// One exact context source and precomputed bounded lookup indexes.
276struct BindingSource<'g> {
277    /// Fresh borrowed evidence or the existing exact source cache's shared capture.
278    source: SourceGraph<'g>,
279    /// Actual source export ordinals grouped only in the runtime value namespace.
280    slots: BTreeMap<String, Vec<usize>>,
281    /// Static module dependencies indexed once rather than scanned per call.
282    modules: BTreeMap<String, Vec<CanonicalResolutionKey>>,
283    /// The same qualified parent selectors used by ordinary entity publication.
284    parents: Vec<Option<GraphIdentityText>>,
285}
286
287/// Find an associated parser ordinal without assuming projection row ordinals are raw ordinals.
288fn relation_keys(
289    projection: &ResolutionKeyProjection,
290    relation: usize,
291) -> &[CanonicalResolutionKey] {
292    projection
293        .relation_keys()
294        .binary_search_by_key(
295            &relation,
296            projectatlas_symbols::RelationResolutionKeys::relation_index,
297        )
298        .map_or(&[], |index| projection.relation_keys()[index].keys())
299}
300
301/// Preserve the ordinary declaration fast path, including already ambiguous targets.
302fn needs_forwarding(
303    keys: &[CanonicalResolutionKey],
304    candidates: &ProjectResolutionRegistry,
305) -> bool {
306    keys.iter().any(|key| {
307        candidates
308            .forwarding_key_digests
309            .contains(&key.digest_bytes())
310    }) || !keys
311        .iter()
312        .any(|key| candidates.candidate_digests_by_key.contains_key(key))
313}
314
315/// One operation's source/query caches and existing repository resolution registry.
316struct BindingResolver<'a, 'g, 'n> {
317    /// Existing read authority; no source parsing occurs inside a writer transaction.
318    store: &'a AtlasStore,
319    /// Selected project and pending generation.
320    project: ProjectInstanceId,
321    /// Pending publication generation.
322    generation: IndexGeneration,
323    /// Exact graph entity generation available before this publication.
324    base_generation: Option<IndexGeneration>,
325    /// Current source graphs paired with their own parser evidence.
326    graphs: BTreeMap<&'g str, (&'g SymbolGraph, Option<&'g SourceSemanticFacts>)>,
327    /// Existing admitted entities and projection keys.
328    entities: &'g EntityProjection,
329    /// Existing package and configured-module owners.
330    packages: &'a PackageIndex,
331    /// One validated configuration snapshot shared with ordinary resolution.
332    configured: &'a ConfiguredModuleResolution,
333    /// Existing hash-checked, accounted source recovery owner.
334    sources: &'a mut SourceBindingCache<'n>,
335    /// Existing candidate lookup owner, extended only for explicitly requested keys.
336    candidates: &'a mut ProjectResolutionRegistry,
337    /// Paths replaced by the pending publication must not re-enter through old rows.
338    replaced: &'a BTreeSet<String>,
339    /// Complete candidate keys already fetched in an incremental operation.
340    loaded: Option<BTreeSet<CanonicalResolutionKey>>,
341    /// Captured source views, including borrowed fresh evidence.
342    context: BTreeMap<String, Arc<BindingSource<'g>>>,
343    /// Exact target hydration cached by stable identity, including absent owners.
344    hydrated: BTreeMap<String, Option<GraphEntity>>,
345    /// Canonical IDs and retained allocation witnesses for this resolver only.
346    modules: ModuleInterner,
347    /// Finished outcomes shared by repeated identical binding queries.
348    queries: BTreeMap<BindingQuery, BTreeMap<usize, Arc<CallBindingResolution>>>,
349    /// Conservative bytes owned by this resolver's additional indexes and outcomes.
350    retained_bytes: u64,
351    /// Existing staging ceiling, injectable per owner for deterministic boundary tests.
352    retained_limit: u64,
353    /// Shared indexing deadline, cancellation and work budget.
354    control: &'a IndexWorkControl,
355}
356
357/// Resolve only calls whose ordinary exact declaration lookup lacks complete proof.
358#[allow(clippy::too_many_arguments)]
359pub(super) fn resolve_source_bindings<'g>(
360    store: &AtlasStore,
361    project: ProjectInstanceId,
362    generation: IndexGeneration,
363    graphs: &[&'g SourceGraph<'g>],
364    packages: &PackageIndex,
365    configured: &ConfiguredModuleResolution,
366    sources: &mut SourceBindingCache<'_>,
367    entities: &'g EntityProjection,
368    candidates: &mut ProjectResolutionRegistry,
369    loaded: Option<BTreeSet<CanonicalResolutionKey>>,
370    replaced: &BTreeSet<String>,
371    control: &IndexWorkControl,
372    staging_limit: u64,
373) -> Result<(CallBindings, u64), CliError> {
374    control.check(IndexWorkStage::SymbolParsing)?;
375    let rust_needed = graphs.iter().any(|graph| {
376        graph
377            .semantics()
378            .is_some_and(|facts| facts.rust().is_some())
379            && packages.package_owner(&graph.graph().path).is_some()
380    });
381    let mut needed = rust_needed;
382    'sources: for graph in graphs {
383        control.check(IndexWorkStage::SymbolParsing)?;
384        if graph
385            .graph()
386            .language
387            .as_deref()
388            .and_then(language_capability)
389            .and_then(|capability| capability.effective_semantic_provider())
390            != Some(SemanticProviderOwner::EcmaScript)
391        {
392            continue;
393        }
394        let Some(facts) = graph.semantics() else {
395            continue;
396        };
397        let Some(projection) = entities.keys_by_graph.get(&graph.graph().path) else {
398            continue;
399        };
400        for call in facts.calls() {
401            control.check(IndexWorkStage::SymbolParsing)?;
402            if matches!(call.binding, SourceCallBinding::Imported { .. })
403                && needs_forwarding(relation_keys(projection, call.relation), candidates)
404            {
405                needed = true;
406                break 'sources;
407            }
408        }
409    }
410    if !needed {
411        return Ok((CallBindings::new(), 0));
412    }
413    let loaded_bytes = loaded.as_ref().map_or(0, |keys| {
414        keys.iter()
415            .map(super::resolution_workspace_key_bytes)
416            .fold(0_u64, u64::saturating_add)
417    });
418    let mut resolver = BindingResolver {
419        store,
420        project,
421        generation,
422        base_generation: store.repository_graph_generation()?,
423        graphs: graphs
424            .iter()
425            .map(|graph| {
426                (
427                    graph.graph().path.as_str(),
428                    (graph.graph(), graph.semantics()),
429                )
430            })
431            .collect(),
432        entities,
433        packages,
434        configured,
435        sources,
436        candidates,
437        replaced,
438        loaded,
439        context: BTreeMap::new(),
440        modules: ModuleInterner::default(),
441        queries: BTreeMap::new(),
442        hydrated: BTreeMap::new(),
443        retained_bytes: (graphs.len() as u64)
444            .saturating_mul(STAGED_GRAPH_ROW_BYTES)
445            .saturating_add(std::mem::size_of::<ModuleInterner>() as u64)
446            .saturating_add(loaded_bytes),
447        control,
448        retained_limit: staging_limit,
449    };
450    resolver.check_budget()?;
451    let mut bindings = CallBindings::new();
452    for graph in graphs {
453        control.check(IndexWorkStage::SymbolParsing)?;
454        let raw = graph.graph();
455        if raw
456            .language
457            .as_deref()
458            .and_then(language_capability)
459            .and_then(|capability| capability.effective_semantic_provider())
460            != Some(SemanticProviderOwner::EcmaScript)
461        {
462            continue;
463        }
464        let Some(facts) = graph.semantics() else {
465            continue;
466        };
467        let Some(projection) = entities.keys_by_graph.get(&raw.path) else {
468            continue;
469        };
470        for call in facts.calls() {
471            control.check(IndexWorkStage::SymbolParsing)?;
472            let SourceCallBinding::Imported {
473                import,
474                reference,
475                member,
476                ..
477            } = &call.binding
478            else {
479                continue;
480            };
481            let reference = &facts.imports()[*import].references[*reference];
482            if reference.imported().is_none() && member.is_none() {
483                continue;
484            }
485            let ordinary = relation_keys(projection, call.relation);
486            if !needs_forwarding(ordinary, resolver.candidates) {
487                continue;
488            }
489            let query = resolver.query(&raw.path, reference, member.as_deref())?;
490            let outcome = resolver.resolve(query, &mut BTreeMap::new(), 0)?;
491            if !outcome.followed_export {
492                continue;
493            }
494            bindings
495                .entry(raw.path.clone())
496                .or_default()
497                .insert(call.relation, outcome);
498            resolver.retained_bytes = resolver
499                .retained_bytes
500                .saturating_add(STAGED_GRAPH_ROW_BYTES)
501                .saturating_add(raw.path.len() as u64);
502            resolver.check_budget()?;
503        }
504    }
505    rust::resolve_rust_bindings(&mut resolver, graphs, &mut bindings)?;
506    // Only unique outcomes referenced by the occurrence map survive this step.
507    let mut shared = BTreeSet::new();
508    let retained = bindings.iter().fold(0_u64, |bytes, (path, calls)| {
509        calls.values().fold(
510            bytes
511                .saturating_add(path.len() as u64)
512                .saturating_add((calls.len() as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES)),
513            |bytes, outcome| {
514                if shared.insert(Arc::as_ptr(outcome)) {
515                    bytes.saturating_add(outcome.retained_bytes())
516                } else {
517                    bytes
518                }
519            },
520        )
521    });
522    Ok((bindings, retained))
523}
524
525impl<'g> BindingResolver<'_, 'g, '_> {
526    /// Bind every query to this resolver's IDs and reserve retained metadata atomically.
527    fn query(
528        &mut self,
529        source: &str,
530        reference: &ImportReference,
531        member: Option<&str>,
532    ) -> Result<BindingQuery, CliError> {
533        self.check_budget()?;
534        let before = self.retained_staging_bytes();
535        let limit = self.retained_limit;
536        let control = self.control;
537        let retained = &mut self.retained_bytes;
538        BindingQuery::new(source, reference, member, &mut self.modules, |bytes| {
539            control.check(IndexWorkStage::SymbolParsing)?;
540            enforce_resolution_registry_budget_with_limit(before.saturating_add(bytes), limit)?;
541            *retained = retained.saturating_add(bytes);
542            Ok(())
543        })
544    }
545
546    /// Include every simultaneously live owner in the staging memory boundary.
547    fn check_budget(&self) -> Result<(), CliError> {
548        self.control.check(IndexWorkStage::SymbolParsing)?;
549        enforce_resolution_registry_budget_with_limit(
550            self.retained_staging_bytes(),
551            self.retained_limit,
552        )
553    }
554
555    /// Sum accounted owners without changing their reservation state.
556    fn retained_staging_bytes(&self) -> u64 {
557        self.entities
558            .retained_bytes
559            .saturating_add(self.candidates.retained_bytes)
560            .saturating_add(self.sources.retained_bytes)
561            .saturating_add(self.retained_bytes)
562    }
563
564    /// Admit a temporary owner before allocating, retaining prior accounting on refusal.
565    fn reserve_scratch(&mut self, bytes: u64) -> Result<(), CliError> {
566        let before = self.retained_bytes;
567        self.retained_bytes = before.saturating_add(bytes);
568        if let Err(error) = self.check_budget() {
569            self.retained_bytes = before;
570            return Err(error);
571        }
572        Ok(())
573    }
574
575    /// Release exactly one admitted temporary owner without erasing persistent state.
576    fn release_scratch(&mut self, bytes: u64) -> Result<(), CliError> {
577        self.retained_bytes = self.retained_bytes.checked_sub(bytes).ok_or_else(|| {
578            CliError::InvalidInput("resolution workspace accounting is unbalanced".to_string())
579        })?;
580        Ok(())
581    }
582
583    /// Release temporary construction on every exit and retain the completed owner once.
584    fn with_scratch<T>(
585        &mut self,
586        operation: impl FnOnce(&mut Self, &mut ResolverScratch) -> Result<(T, u64), CliError>,
587    ) -> Result<T, CliError> {
588        let mut scratch = ResolverScratch::default();
589        let result = operation(self, &mut scratch);
590        self.release_scratch(scratch.retained_bytes)?;
591        let (value, retained_bytes) = result?;
592        self.reserve_scratch(retained_bytes)?;
593        Ok(value)
594    }
595
596    /// Retain only a new key, checking both row and byte admission before cloning.
597    fn insert_cause(
598        &mut self,
599        causes: &mut CauseWorkspace<'_>,
600        key: &CanonicalResolutionKey,
601    ) -> Result<(), CliError> {
602        self.check_budget()?;
603        if causes.keys.contains(key) {
604            return Ok(());
605        }
606        if causes.keys.len() >= GraphLimits::MAX_ROWS as usize {
607            return Err(resolution_key_limit_failure(
608                causes.keys.len().saturating_add(1),
609            ));
610        }
611        causes.scratch.reserve(
612            self,
613            STAGED_GRAPH_ROW_BYTES.saturating_add(key.canonical_identity().len() as u64),
614        )?;
615        causes.keys.insert(key.clone());
616        Ok(())
617    }
618
619    /// Load one explicitly needed source, reusing fresh facts and captured bytes.
620    fn source(&mut self, path: &str) -> Result<Arc<BindingSource<'g>>, CliError> {
621        if let Some(source) = self.context.get(path) {
622            return Ok(Arc::clone(source));
623        }
624        self.check_budget()?;
625        let mut projection_bytes = 0;
626        let (source, projection) = if let Some((graph, Some(semantics))) =
627            self.graphs.get(path).copied()
628        {
629            let keys = self.entities.keys_by_graph.get(path).ok_or_else(|| {
630                CliError::InvalidInput("binding source has no admitted key projection".to_string())
631            })?;
632            (
633                SourceGraph::Staged {
634                    graph,
635                    semantics: Some(semantics),
636                },
637                SourceProjection::Staged(keys),
638            )
639        } else {
640            let source_limit = self
641                .retained_limit
642                .saturating_sub(self.entities.retained_bytes)
643                .saturating_sub(self.candidates.retained_bytes)
644                .saturating_sub(self.retained_bytes);
645            self.sources.set_retained_limit(source_limit)?;
646            let captured = self.sources.capture(path, self.control)?;
647            self.check_budget()?;
648            let keys = match derive_resolution_keys_with_context(
649                self.project,
650                self.packages.package_name(path),
651                &captured.graph,
652                super::resolution_projection_context(path, self.packages, self.configured)
653                    .with_source_semantics(&captured.semantics)
654                    .with_retained_bytes_limit(
655                        self.retained_limit
656                            .saturating_sub(self.retained_staging_bytes()),
657                    ),
658            ) {
659                Ok(keys) => keys,
660                Err(ResolutionProjectionError::Contract(failure)) => failure.into_parts().2,
661                Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
662                    return Err(resolution_key_limit_failure(requested));
663                }
664                Err(ResolutionProjectionError::MemoryLimit { limit, requested }) => {
665                    return Err(super::resolution_projection_memory_failure(
666                        limit, requested,
667                    ));
668                }
669                Err(ResolutionProjectionError::SourceEvidenceMismatch) => {
670                    return Err(CliError::InvalidInput(
671                        "captured binding source changed its ordinal proof".to_string(),
672                    ));
673                }
674            };
675            projection_bytes = resolution_retained_bytes(&keys);
676            self.retained_bytes = self.retained_bytes.saturating_add(projection_bytes);
677            (
678                SourceGraph::Captured(captured),
679                SourceProjection::Captured(keys),
680            )
681        };
682        let mut slots = BTreeMap::<String, Vec<usize>>::new();
683        let mut modules = BTreeMap::<String, BTreeSet<CanonicalResolutionKey>>::new();
684        if let Some(facts) = source.semantics() {
685            if facts.exports_complete() {
686                for (index, export) in facts.exports().iter().enumerate() {
687                    self.control.check(IndexWorkStage::SymbolParsing)?;
688                    if export.namespace == SourceExportNamespace::Value {
689                        let bytes = (4 * std::mem::size_of::<usize>()) as u64
690                            + if slots.contains_key(&export.name) {
691                                0
692                            } else {
693                                STAGED_GRAPH_ROW_BYTES.saturating_add(export.name.len() as u64)
694                            };
695                        self.reserve_scratch(bytes)?;
696                        slots.entry(export.name.clone()).or_default().push(index);
697                    }
698                }
699            }
700            for import in facts.imports() {
701                self.control.check(IndexWorkStage::SymbolParsing)?;
702                if let Some(module) = &import.module {
703                    if !modules.contains_key(module) {
704                        self.reserve_scratch(
705                            STAGED_GRAPH_ROW_BYTES.saturating_add(module.len() as u64),
706                        )?;
707                        modules.insert(module.clone(), BTreeSet::new());
708                    }
709                    for key in relation_keys(projection.get(), import.relation) {
710                        if !modules.get(module).is_some_and(|keys| keys.contains(key)) {
711                            self.reserve_scratch(
712                                STAGED_GRAPH_ROW_BYTES
713                                    .saturating_add(key.canonical_identity().len() as u64),
714                            )?;
715                            if let Some(keys) = modules.get_mut(module) {
716                                keys.insert(key.clone());
717                            }
718                        }
719                    }
720                }
721            }
722        }
723        let vector_rows = modules.values().map(BTreeSet::len).sum::<usize>();
724        self.reserve_scratch(
725            (vector_rows as u64)
726                .saturating_mul(std::mem::size_of::<CanonicalResolutionKey>() as u64),
727        )?;
728        let modules = modules
729            .into_iter()
730            .map(|(name, keys)| (name, keys.into_iter().collect::<Vec<_>>()))
731            .collect::<BTreeMap<_, _>>();
732        let parents = projectatlas_symbols::declaration_identity::qualified_symbol_parents_checked(
733            source.graph(),
734            &mut |bytes| self.reserve_scratch(bytes),
735        )?;
736        self.reserve_scratch(STAGED_GRAPH_ROW_BYTES.saturating_add(path.len() as u64))?;
737        drop(projection);
738        self.retained_bytes = self.retained_bytes.saturating_sub(projection_bytes);
739        let source = Arc::new(BindingSource {
740            source,
741            slots,
742            modules,
743            parents,
744        });
745        self.context.insert(path.to_string(), Arc::clone(&source));
746        self.check_budget()?;
747        Ok(source)
748    }
749
750    /// Fetch each additional canonical lookup once under the existing bounded API.
751    fn module_file(&mut self, keys: &[CanonicalResolutionKey]) -> Result<Option<String>, CliError> {
752        if self.loaded.is_some() {
753            let needed = self.with_scratch(|this, scratch| {
754                let mut needed = CauseWorkspace::new(scratch);
755                for key in keys {
756                    if !this
757                        .loaded
758                        .as_ref()
759                        .is_some_and(|loaded| loaded.contains(key))
760                    {
761                        this.insert_cause(&mut needed, key)?;
762                    }
763                }
764                let needed = needed.keys.into_iter().collect::<Vec<_>>();
765                if !needed.is_empty() {
766                    this.control.check(IndexWorkStage::SymbolParsing)?;
767                    let candidates = this
768                        .store
769                        .repository_resolution_candidates_for_keys_with_byte_limit(
770                            this.project,
771                            &needed,
772                            MAX_INCREMENTAL_RESOLUTION_ITEMS,
773                            this.retained_limit
774                                .saturating_sub(this.retained_staging_bytes()),
775                        )
776                        .map_err(super::resolution_read_error)?;
777                    if candidates.page.truncated {
778                        return Err(dependency_closure_limit(
779                            this.sources.root,
780                            std::iter::empty(),
781                            GraphLimits::MAX_ROWS as usize + 1,
782                        ));
783                    }
784                    this.candidates.retained_limit = this
785                        .retained_limit
786                        .saturating_sub(this.entities.retained_bytes)
787                        .saturating_sub(this.sources.retained_bytes)
788                        .saturating_sub(this.retained_bytes);
789                    append_persisted_resolution_candidates(
790                        this.candidates,
791                        this.project,
792                        this.generation,
793                        candidates,
794                        this.replaced,
795                        this.control,
796                    )?;
797                    this.check_budget()?;
798                }
799                let bytes = scratch.retained_bytes;
800                Ok((needed, bytes))
801            })?;
802            if let Some(loaded) = &mut self.loaded {
803                loaded.extend(needed);
804            }
805        }
806        let matches = registry_resolution_matches(
807            keys,
808            ResolutionCandidateKind::Any,
809            self.candidates,
810            &self.entities.entity_by_digest,
811            self.control,
812        )?;
813        let path = if matches.count == 1 {
814            matches.first.and_then(|entity| match entity.selector() {
815                EntitySelector::File { path } => Some(path.as_str().to_string()),
816                _ => None,
817            })
818        } else {
819            None
820        };
821        self.check_budget()?;
822        Ok(path)
823    }
824
825    /// Follow explicit module slots; a cycle, ambiguity or unsupported value abstains.
826    fn resolve(
827        &mut self,
828        query: BindingQuery,
829        visited: &mut BTreeMap<BindingQuery, usize>,
830        depth: usize,
831    ) -> Result<Arc<CallBindingResolution>, CliError> {
832        self.check_budget()?;
833        if let Some(outcome) = self
834            .queries
835            .get(&query)
836            .and_then(|depths| depths.get(&depth))
837        {
838            return Ok(Arc::clone(outcome));
839        }
840        if depth >= MAX_FORWARDING_DEPTH {
841            return Ok(Arc::new(CallBindingResolution::default()));
842        }
843        let outcome = self.with_scratch(|this, scratch| {
844            let mut outcome = CallBindingResolution::default();
845            let mut causes = CauseWorkspace::new(scratch);
846            causes
847                .scratch
848                .reserve(this, query.retained_bytes().saturating_mul(2))?;
849            visited.insert(query.clone(), depth);
850            let result: Result<(), CliError> = (|| {
851                let source = this.source(&query.source)?;
852                let keys = source
853                    .modules
854                    .get(query.module.as_ref())
855                    .map(Vec::as_slice)
856                    .unwrap_or_default();
857                for key in keys {
858                    this.insert_cause(&mut causes, key)?;
859                }
860                let key = document_file_resolution_key(this.project, &query.source)?;
861                this.insert_cause(&mut causes, &key)?;
862                if let Some(path) = this.module_file(keys)? {
863                    let path = path.as_str();
864                    let key = document_file_resolution_key(this.project, path)?;
865                    this.insert_cause(&mut causes, &key)?;
866                    let target = this.source(path)?;
867                    let selected = if let Some(name) = &query.imported {
868                        Some((name.as_str(), query.member.as_deref()))
869                    } else {
870                        query.member.as_deref().map(|member| {
871                            member
872                                .split_once('.')
873                                .map_or((member, None), |(name, member)| (name, Some(member)))
874                        })
875                    };
876                    if let Some((name, member)) = selected
877                        && let Some(indices) = target.slots.get(name)
878                        && indices.len() == 1
879                        && let Some(fact) = target
880                            .source
881                            .semantics()
882                            .and_then(|facts| facts.exports().get(indices[0]))
883                    {
884                        outcome.followed_export = true;
885                        match &fact.binding {
886                            SourceExportBinding::LocalDeclaration(index) if member.is_none() => {
887                                outcome.target = this.target(&target, *index)?;
888                            }
889                            SourceExportBinding::ReExport(reference) => {
890                                let forwarded_query = this.query(path, reference, member)?;
891                                if let Some(first) = visited.get(&forwarded_query).copied() {
892                                    // The parent owns cycle causes directly; no uncached set escapes
893                                    // with a charge that outlives its temporary result.
894                                    for (cycle, _) in
895                                        visited.iter().filter(|(_, position)| **position >= first)
896                                    {
897                                        let key = document_file_resolution_key(
898                                            this.project,
899                                            &cycle.source,
900                                        )?;
901                                        this.insert_cause(&mut causes, &key)?;
902                                    }
903                                } else {
904                                    let forwarded =
905                                        this.resolve(forwarded_query, visited, depth + 1)?;
906                                    outcome.target.clone_from(&forwarded.target);
907                                    for key in &forwarded.dependencies {
908                                        this.insert_cause(&mut causes, key)?;
909                                    }
910                                }
911                            }
912                            SourceExportBinding::LocalDeclaration(_)
913                            | SourceExportBinding::RustReExport(_)
914                            | SourceExportBinding::AmbiguousDeclarations(_)
915                            | SourceExportBinding::UnresolvedDeclarations(_)
916                            | SourceExportBinding::Unresolved => {}
917                        }
918                    }
919                }
920                Ok(())
921            })();
922            visited.remove(&query);
923            result?;
924            outcome.dependencies = causes.keys;
925            let retained_bytes = outcome
926                .retained_bytes()
927                .saturating_add(query.retained_bytes());
928            Ok((outcome, retained_bytes))
929        })?;
930        let outcome = Arc::new(outcome);
931        self.queries
932            .entry(query)
933            .or_default()
934            .insert(depth, Arc::clone(&outcome));
935        Ok(outcome)
936    }
937
938    /// Hydrate the exact existing canonical declaration selected by admitted source ordinals.
939    fn target(
940        &mut self,
941        source: &BindingSource<'_>,
942        index: usize,
943    ) -> Result<Option<GraphEntity>, CliError> {
944        let graph = source.source.graph();
945        let Some(symbol) = graph.symbols.get(index) else {
946            return Ok(None);
947        };
948        if matches!(
949            symbol.kind,
950            SymbolKind::Type
951                | SymbolKind::Interface
952                | SymbolKind::Trait
953                | SymbolKind::Import
954                | SymbolKind::Module
955        ) {
956            return Ok(None);
957        }
958        let parent = source.parents.get(index).cloned().flatten();
959        let expected = source_symbol_entity(self.project, self.generation, graph, symbol, parent)?;
960        if let Some(entity) = self.entities.entity_by_digest.get(expected.key().digest()) {
961            return Ok(Some(entity.clone()));
962        }
963        if let Some(entity) = self
964            .candidates
965            .supplemental_entities_by_digest
966            .get(expected.key().digest())
967        {
968            return Ok(Some(entity.clone()));
969        }
970        if let Some(entity) = self.hydrated.get(expected.key().digest()) {
971            return Ok(entity.clone());
972        }
973        let Some(base_generation) = self.base_generation else {
974            return Ok(None);
975        };
976        self.control.check(IndexWorkStage::SymbolParsing)?;
977        let budget = projectatlas_db::RepositoryGraphReadBudget::new(
978            1,
979            1,
980            projectatlas_db::RepositoryGraphReadBudget::MAX_DECODED_BYTES,
981            1,
982            1,
983        )
984        .map_err(invalid_graph_contract)?;
985        let digest = expected.key().digest().to_string();
986        let stored = self.store.repository_graph_entity_bounded(
987            expected.key(),
988            base_generation,
989            budget,
990            Some(self.control),
991        )?;
992        self.control.check(IndexWorkStage::SymbolParsing)?;
993        let entity = stored
994            .rows
995            .into_iter()
996            .next()
997            .filter(|entity| entity.selector() == expected.selector())
998            .map(|_stored| expected);
999        self.retained_bytes = self
1000            .retained_bytes
1001            .saturating_add(STAGED_GRAPH_ROW_BYTES)
1002            .saturating_add(digest.len() as u64)
1003            .saturating_add(entity.as_ref().map_or(0, entity_retained_bytes));
1004        self.hydrated.insert(digest, entity.clone());
1005        self.check_budget()?;
1006        Ok(entity)
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::{BindingQuery, ModuleInterner, STAGED_GRAPH_ROW_BYTES};
1013    use projectatlas_core::{IndexCancellation, IndexWorkControl};
1014    use projectatlas_symbols::SourceExportBinding;
1015    use std::error::Error;
1016
1017    #[test]
1018    fn forwarding_query_module_identity_uses_values_across_allocations()
1019    -> Result<(), Box<dyn Error>> {
1020        let mut modules = ModuleInterner::default();
1021        let first =
1022            projectatlas_symbols::parse_import_references("import {run} from './target.js';");
1023        let second =
1024            projectatlas_symbols::parse_import_references("import {run} from './target.js';");
1025        let first_reference = first.first().ok_or("first native import missing")?;
1026        let second_reference = second.first().ok_or("second native import missing")?;
1027        if std::sync::Arc::ptr_eq(
1028            &first_reference.shared_module(),
1029            &second_reference.shared_module(),
1030        ) {
1031            return Err("independent imports unexpectedly share one allocation".into());
1032        }
1033        let first = BindingQuery::new("barrel.js", first_reference, None, &mut modules, |_| {
1034            Ok::<(), std::convert::Infallible>(())
1035        })?;
1036        let second = BindingQuery::new("barrel.js", second_reference, None, &mut modules, |_| {
1037            Ok::<(), std::convert::Infallible>(())
1038        })?;
1039        if first != second || first.cmp(&second) != std::cmp::Ordering::Equal {
1040            return Err("equal module values did not retain exact query identity".into());
1041        }
1042        let mut cache = std::collections::BTreeMap::new();
1043        cache.insert(first.clone(), 7);
1044        if cache.get(&second) != Some(&7) {
1045            return Err("equal allocation missed query cache".into());
1046        }
1047        let mut visited = std::collections::BTreeMap::new();
1048        visited.insert(first, 3);
1049        if visited.get(&second) != Some(&3)
1050            || modules.values.len() != 1
1051            || modules.allocations.len() != 2
1052        {
1053            return Err("visited identity or retained module witnesses changed".into());
1054        }
1055        Ok(())
1056    }
1057
1058    #[test]
1059    fn module_identity_admission_preserves_exact_values_and_owned_allocations()
1060    -> Result<(), Box<dyn Error>> {
1061        let mut modules = ModuleInterner::default();
1062        let first: std::sync::Arc<str> = format!("./{}a", "x".repeat(262_144)).into();
1063        let rejected = modules.intern(std::sync::Arc::clone(&first), |bytes| {
1064            super::enforce_resolution_registry_budget_with_limit(bytes, STAGED_GRAPH_ROW_BYTES)
1065        });
1066        if rejected.is_ok() || !modules.values.is_empty() || !modules.allocations.is_empty() {
1067            return Err("refused module admission retained metadata".into());
1068        }
1069        let mut admitted = Vec::new();
1070        let mut admit = |bytes| {
1071            admitted.push(bytes);
1072            Ok::<(), std::convert::Infallible>(())
1073        };
1074        let id = modules.intern(std::sync::Arc::clone(&first), &mut admit)?;
1075        if modules.intern(std::sync::Arc::clone(&first), &mut admit)? != id {
1076            return Err("repeated allocation changed module identity".into());
1077        }
1078        let equal: std::sync::Arc<str> = std::sync::Arc::from(first.as_ref());
1079        let retained = std::sync::Arc::downgrade(&equal);
1080        if modules.intern(equal, &mut admit)? != id || retained.upgrade().is_none() {
1081            return Err("equal value or retained allocation witness changed".into());
1082        }
1083        let different: std::sync::Arc<str> = format!("./{}b", "x".repeat(262_144)).into();
1084        if modules.intern(different, &mut admit)? == id {
1085            return Err("different module values acquired one identity".into());
1086        }
1087        if admitted
1088            != [
1089                STAGED_GRAPH_ROW_BYTES * 2,
1090                STAGED_GRAPH_ROW_BYTES,
1091                STAGED_GRAPH_ROW_BYTES * 2,
1092            ]
1093            || modules.values.len() != 2
1094            || modules.allocations.len() != 3
1095        {
1096            return Err("module metadata admission changed".into());
1097        }
1098        let cancellation = IndexCancellation::new();
1099        cancellation.cancel();
1100        let control = IndexWorkControl::new(cancellation, None);
1101        let result = modules.intern(std::sync::Arc::from("./canceled.js"), |_| {
1102            control.check(projectatlas_core::IndexWorkStage::SymbolParsing)
1103        });
1104        if result.is_ok() || modules.values.len() != 2 || modules.allocations.len() != 3 {
1105            return Err("canceled module admission retained metadata".into());
1106        }
1107        Ok(())
1108    }
1109
1110    #[test]
1111    fn forwarding_queries_share_source_modules_before_cache_lookup() -> Result<(), Box<dyn Error>> {
1112        const SOURCE: &str = "barrel.js";
1113        let module = format!("./{}", "x".repeat(262_144));
1114        let slots = (0..32)
1115            .map(|index| format!("run as e{index}"))
1116            .collect::<Vec<_>>()
1117            .join(",");
1118        let content = format!("export {{{slots}}} from \"{module}\";");
1119        let control = IndexWorkControl::new(IndexCancellation::new(), None);
1120        let (_, _, facts) = projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
1121            SOURCE,
1122            Some("javascript"),
1123            &content,
1124            &control,
1125        )?
1126        .into_parts();
1127        if facts.exports().len() != 32 {
1128            return Err("native reexport slots missing".into());
1129        }
1130        let mut modules = ModuleInterner::default();
1131        for export in facts.exports() {
1132            let SourceExportBinding::ReExport(reference) = &export.binding else {
1133                return Err("native reexport evidence missing".into());
1134            };
1135            let query = BindingQuery::new(SOURCE, reference, None, &mut modules, |_| {
1136                Ok::<(), std::convert::Infallible>(())
1137            })?;
1138            if query.module.as_ptr() != reference.module().as_ptr() {
1139                return Err("query copied source-owned module before cache lookup".into());
1140            }
1141            let copied = query.clone();
1142            if copied.module.as_ptr() != query.module.as_ptr() {
1143                return Err("resolver frame copied shared query module".into());
1144            }
1145        }
1146        Ok(())
1147    }
1148}