Skip to main content

sqry_core/graph/unified/
resolution.rs

1//! Shared, file-aware symbol resolution for unified graph snapshots.
2//!
3//! This module centralizes strict single-symbol lookup and ordered candidate
4//! discovery so MCP, LSP, and CLI consumers do not drift semantically.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::graph::unified::string::id::StringId;
11
12use crate::graph::node::Language;
13use crate::graph::unified::concurrent::GraphSnapshot;
14use crate::graph::unified::file::id::FileId;
15use crate::graph::unified::node::id::NodeId;
16use crate::graph::unified::node::kind::NodeKind;
17
18/// File scoping policy for a symbol query.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum FileScope<'a> {
21    /// Search without file restriction.
22    Any,
23    /// Restrict lookup to a concrete file path.
24    Path(&'a Path),
25    /// Restrict lookup to a resolved file id.
26    FileId(FileId),
27}
28
29/// Resolution mode for a symbol query.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum ResolutionMode {
32    /// Only exact qualified-name and exact simple-name buckets are eligible.
33    Strict,
34    /// Allow bounded canonical `::` suffix candidates after exact buckets.
35    AllowSuffixCandidates,
36}
37
38/// Symbol lookup input.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct SymbolQuery<'a> {
41    /// Raw symbol text from the caller.
42    pub symbol: &'a str,
43    /// File scoping policy.
44    pub file_scope: FileScope<'a>,
45    /// Resolution mode.
46    pub mode: ResolutionMode,
47}
48
49/// Resolved file scope once path normalization has completed.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum ResolvedFileScope {
52    /// No file restriction.
53    Any,
54    /// Restriction to one indexed file.
55    File(FileId),
56}
57
58/// File-scope resolution failure.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FileScopeError {
61    /// Requested file is not indexed in the current graph snapshot.
62    FileNotIndexed,
63}
64
65/// Normalized symbol query used internally by the resolver.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct NormalizedSymbolQuery {
68    /// Canonical graph symbol text.
69    pub symbol: String,
70    /// Resolved file scope.
71    pub file_scope: ResolvedFileScope,
72    /// Resolution mode.
73    pub mode: ResolutionMode,
74}
75
76/// Candidate bucket that produced a symbol match.
77///
78/// This is the formal baseline for future binding work. All resolution
79/// consumers that will feed into `sqry-bind` should use the witness-bearing
80/// API ([`GraphSnapshot::find_symbol_candidates_with_witness`],
81/// [`GraphSnapshot::resolve_symbol_with_witness`]) to preserve bucket
82/// provenance.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum SymbolCandidateBucket {
85    /// Exact qualified-name bucket.
86    ExactQualified,
87    /// Exact simple-name bucket.
88    ExactSimple,
89    /// Bounded canonical suffix bucket.
90    CanonicalSuffix,
91}
92
93/// Witness for one candidate produced during symbol lookup.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SymbolCandidateWitness {
96    /// Matching node id.
97    pub node_id: NodeId,
98    /// Bucket that produced the candidate.
99    pub bucket: SymbolCandidateBucket,
100}
101
102/// Single-node resolution outcome.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum SymbolResolutionOutcome {
105    /// Exactly one node matched.
106    Resolved(NodeId),
107    /// No node matched.
108    NotFound,
109    /// Requested file is valid but absent from indexed graph data.
110    FileNotIndexed,
111    /// More than one node matched after deterministic ordering.
112    Ambiguous(Vec<NodeId>),
113}
114
115/// Candidate enumeration outcome.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SymbolCandidateOutcome {
118    /// Ordered candidates from the first non-empty bucket.
119    Candidates(Vec<NodeId>),
120    /// No node matched.
121    NotFound,
122    /// Requested file is valid but absent from indexed graph data.
123    FileNotIndexed,
124}
125
126/// Witness-bearing symbol resolution result.
127///
128/// Formal baseline for the binding query facade. Captures not just the
129/// resolution outcome but the bucket provenance and candidate witnesses
130/// that produced it. Future `sqry-bind` work builds on this seam.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SymbolResolutionWitness {
133    /// Normalized query when file scoping resolved successfully.
134    pub normalized_query: Option<NormalizedSymbolQuery>,
135    /// Resolution outcome (the same value `resolve_symbol` returns).
136    pub outcome: SymbolResolutionOutcome,
137    /// Winning bucket for successful or ambiguous resolutions.
138    pub selected_bucket: Option<SymbolCandidateBucket>,
139    /// Ordered candidate witnesses from the first non-empty bucket.
140    pub candidates: Vec<SymbolCandidateWitness>,
141    /// Interned `StringId` of the normalized query symbol.
142    ///
143    /// Populated by `find_symbol_candidates_with_witness` via a read-only
144    /// interner lookup (`snapshot.strings().get(normalized_symbol)`).
145    /// `None` when the symbol text is not present in the interner (i.e.,
146    /// the symbol has never been indexed). This is used by
147    /// `BindingPlane::resolve_shared`'s post-hoc step reconstruction to
148    /// emit a meaningful `StringId` in `Unresolved` steps instead of the
149    /// `StringId(0)` sentinel.
150    pub symbol: Option<StringId>,
151    /// Ordered step trace from the resolver.
152    ///
153    /// Empty by default; populated by the `resolve_shared()` helper
154    /// extracted in P2U07. Consumers that only care about the outcome
155    /// can ignore this field — it exists so P2U07 can emit the full
156    /// step vocabulary without changing the return type.
157    ///
158    /// See [`crate::graph::unified::bind::witness::step::ResolutionStep`].
159    pub steps: Vec<crate::graph::unified::bind::witness::step::ResolutionStep>,
160}
161
162impl GraphSnapshot {
163    /// Resolves one symbol with explicit file-aware outcome classification.
164    #[must_use]
165    pub fn resolve_symbol(&self, query: &SymbolQuery<'_>) -> SymbolResolutionOutcome {
166        self.resolve_symbol_with_witness(query).outcome
167    }
168
169    /// Finds ordered candidates from the first eligible non-empty bucket.
170    #[must_use]
171    pub fn find_symbol_candidates(&self, query: &SymbolQuery<'_>) -> SymbolCandidateOutcome {
172        self.find_symbol_candidates_with_witness(query).outcome
173    }
174
175    /// Finds ordered candidates from the first eligible non-empty bucket,
176    /// preserving the bucket that produced them.
177    #[must_use]
178    pub fn find_symbol_candidates_with_witness(
179        &self,
180        query: &SymbolQuery<'_>,
181    ) -> SymbolCandidateSearchWitness {
182        let resolved_file_scope = match self.resolve_file_scope(&query.file_scope) {
183            Ok(scope) => scope,
184            Err(FileScopeError::FileNotIndexed) => {
185                return SymbolCandidateSearchWitness {
186                    normalized_query: None,
187                    outcome: SymbolCandidateOutcome::FileNotIndexed,
188                    selected_bucket: None,
189                    candidates: Vec::new(),
190                };
191            }
192        };
193
194        let normalized_query = self.normalize_symbol_query(query, &resolved_file_scope);
195
196        if let Some((selected_bucket, candidates)) =
197            self.first_candidate_bucket_with_witness(&normalized_query, resolved_file_scope)
198        {
199            return SymbolCandidateSearchWitness {
200                normalized_query: Some(normalized_query),
201                outcome: SymbolCandidateOutcome::Candidates(
202                    candidates
203                        .iter()
204                        .map(|candidate| candidate.node_id)
205                        .collect(),
206                ),
207                selected_bucket: Some(selected_bucket),
208                candidates,
209            };
210        }
211
212        SymbolCandidateSearchWitness {
213            normalized_query: Some(normalized_query),
214            outcome: SymbolCandidateOutcome::NotFound,
215            selected_bucket: None,
216            candidates: Vec::new(),
217        }
218    }
219
220    /// Resolve one symbol while preserving witness metadata about the winning
221    /// candidate bucket and ordered candidates.
222    #[must_use]
223    pub fn resolve_symbol_with_witness(&self, query: &SymbolQuery<'_>) -> SymbolResolutionWitness {
224        let candidate_witness = self.find_symbol_candidates_with_witness(query);
225        let outcome = match &candidate_witness.outcome {
226            SymbolCandidateOutcome::Candidates(candidates) => match candidates.as_slice() {
227                [] => SymbolResolutionOutcome::NotFound,
228                [node_id] => SymbolResolutionOutcome::Resolved(*node_id),
229                _ => SymbolResolutionOutcome::Ambiguous(candidates.clone()),
230            },
231            SymbolCandidateOutcome::NotFound => SymbolResolutionOutcome::NotFound,
232            SymbolCandidateOutcome::FileNotIndexed => SymbolResolutionOutcome::FileNotIndexed,
233        };
234
235        // Read-only interner lookup: does NOT intern at query time.
236        let symbol = candidate_witness
237            .normalized_query
238            .as_ref()
239            .and_then(|nq| self.strings().get(&nq.symbol));
240
241        SymbolResolutionWitness {
242            normalized_query: candidate_witness.normalized_query,
243            outcome,
244            selected_bucket: candidate_witness.selected_bucket,
245            candidates: candidate_witness.candidates,
246            symbol,
247            steps: Vec::new(),
248        }
249    }
250
251    /// Resolves an external file scope into an indexed file scope.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`FileScopeError::FileNotIndexed`] when the requested file scope
256    /// is not present in the loaded graph indices.
257    pub fn resolve_file_scope(
258        &self,
259        file_scope: &FileScope<'_>,
260    ) -> Result<ResolvedFileScope, FileScopeError> {
261        match *file_scope {
262            FileScope::Any => Ok(ResolvedFileScope::Any),
263            FileScope::Path(path) => self
264                .files()
265                .get(path)
266                .filter(|file_id| !self.indices().by_file(*file_id).is_empty())
267                .map_or(Err(FileScopeError::FileNotIndexed), |file_id| {
268                    Ok(ResolvedFileScope::File(file_id))
269                }),
270            FileScope::FileId(file_id) => {
271                let is_indexed = self.files().resolve(file_id).is_some()
272                    && !self.indices().by_file(file_id).is_empty();
273                if is_indexed {
274                    Ok(ResolvedFileScope::File(file_id))
275                } else {
276                    Err(FileScopeError::FileNotIndexed)
277                }
278            }
279        }
280    }
281
282    /// Normalizes a raw symbol query into canonical graph form.
283    #[must_use]
284    pub fn normalize_symbol_query(
285        &self,
286        query: &SymbolQuery<'_>,
287        file_scope: &ResolvedFileScope,
288    ) -> NormalizedSymbolQuery {
289        let normalized_symbol = match *file_scope {
290            ResolvedFileScope::Any => query.symbol.to_string(),
291            ResolvedFileScope::File(file_id) => {
292                self.files().language_for_file(file_id).map_or_else(
293                    || query.symbol.to_string(),
294                    |language| canonicalize_graph_qualified_name(language, query.symbol),
295                )
296            }
297        };
298
299        NormalizedSymbolQuery {
300            symbol: normalized_symbol,
301            file_scope: *file_scope,
302            mode: query.mode,
303        }
304    }
305
306    fn exact_qualified_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
307        self.strings()
308            .get(&query.symbol)
309            .map_or_else(Vec::new, |string_id| {
310                self.indices().by_qualified_name(string_id).to_vec()
311            })
312    }
313
314    fn exact_simple_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
315        self.strings()
316            .get(&query.symbol)
317            .map_or_else(Vec::new, |string_id| {
318                self.indices().by_name(string_id).to_vec()
319            })
320    }
321
322    fn bounded_suffix_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
323        if !query.symbol.contains("::") {
324            return Vec::new();
325        }
326
327        let Some(leaf_symbol) = query.symbol.rsplit("::").next() else {
328            return Vec::new();
329        };
330        let Some(leaf_id) = self.strings().get(leaf_symbol) else {
331            return Vec::new();
332        };
333        let suffix_pattern = format!("::{}", query.symbol);
334
335        self.indices()
336            .by_name(leaf_id)
337            .iter()
338            .copied()
339            .filter(|node_id| {
340                self.get_node(*node_id)
341                    .and_then(|entry| entry.qualified_name)
342                    .and_then(|qualified_name_id| self.strings().resolve(qualified_name_id))
343                    .is_some_and(|qualified_name| {
344                        qualified_name.as_ref() == query.symbol
345                            || qualified_name.as_ref().ends_with(&suffix_pattern)
346                    })
347            })
348            .collect()
349    }
350
351    fn filtered_bucket(
352        &self,
353        mut bucket: Vec<NodeId>,
354        file_scope: ResolvedFileScope,
355    ) -> Vec<NodeId> {
356        if let ResolvedFileScope::File(file_id) = file_scope {
357            let file_nodes = self.indices().by_file(file_id);
358            bucket.retain(|node_id| file_nodes.contains(node_id));
359        }
360
361        bucket.sort_by(|left, right| {
362            self.candidate_sort_key(*left)
363                .cmp(&self.candidate_sort_key(*right))
364        });
365        bucket.dedup();
366        bucket
367    }
368
369    fn first_candidate_bucket_with_witness(
370        &self,
371        query: &NormalizedSymbolQuery,
372        file_scope: ResolvedFileScope,
373    ) -> Option<(SymbolCandidateBucket, Vec<SymbolCandidateWitness>)> {
374        for bucket in [
375            SymbolCandidateBucket::ExactQualified,
376            SymbolCandidateBucket::ExactSimple,
377            SymbolCandidateBucket::CanonicalSuffix,
378        ] {
379            if bucket == SymbolCandidateBucket::CanonicalSuffix
380                && !matches!(query.mode, ResolutionMode::AllowSuffixCandidates)
381            {
382                continue;
383            }
384
385            let candidates = self.bucket_witnesses(query, file_scope, bucket);
386            if !candidates.is_empty() {
387                return Some((bucket, candidates));
388            }
389        }
390
391        None
392    }
393
394    fn bucket_witnesses(
395        &self,
396        query: &NormalizedSymbolQuery,
397        file_scope: ResolvedFileScope,
398        bucket: SymbolCandidateBucket,
399    ) -> Vec<SymbolCandidateWitness> {
400        let raw_bucket = match bucket {
401            SymbolCandidateBucket::ExactQualified => self.exact_qualified_bucket(query),
402            SymbolCandidateBucket::ExactSimple => self.exact_simple_bucket(query),
403            SymbolCandidateBucket::CanonicalSuffix => self.bounded_suffix_bucket(query),
404        };
405
406        self.filtered_bucket(raw_bucket, file_scope)
407            .into_iter()
408            .map(|node_id| SymbolCandidateWitness { node_id, bucket })
409            .collect()
410    }
411
412    /// Resolve a symbol to one [`NodeId`] with a typed ambiguity error.
413    ///
414    /// This is the single, shared resolver used by every CLI, LSP, and MCP
415    /// surface that must collapse a user-supplied symbol name to one
416    /// canonical node. It accepts both bare names (`NeedTags`) and
417    /// fully-qualified names (`main.SelectorSource.NeedTags` or
418    /// `main::SelectorSource::NeedTags`):
419    ///
420    /// * For a fully-qualified name, the resolver normalizes native
421    ///   delimiters (`.`) to graph-canonical `::` form and looks up the
422    ///   exact-qualified bucket. A unique match is the **only** acceptable
423    ///   resolution — there is no fuzzy fallback to simple-name candidates
424    ///   even if the qualified form has zero hits, because qualified names
425    ///   are a user contract.
426    /// * For a bare name, the resolver tries the exact-simple bucket and
427    ///   resolves the unique match. If two or more nodes share the simple
428    ///   name (e.g. a struct field and a local variable), it returns
429    ///   [`SymbolResolveError::Ambiguous`] with the candidate list.
430    ///
431    /// The candidate list is sorted lexicographically by
432    /// `(qualified_name, file_path, start_line, start_column)` and capped
433    /// at [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`].
434    ///
435    /// # Errors
436    ///
437    /// * [`SymbolResolveError::NotFound`] — no nodes matched after both
438    ///   raw-form and dot-normalized lookups.
439    /// * [`SymbolResolveError::Ambiguous`] — two or more nodes matched
440    ///   the requested name in the most-specific eligible bucket.
441    ///
442    /// # File scope
443    ///
444    /// `file_scope` follows the same semantics as
445    /// [`SymbolQuery::file_scope`]:
446    ///
447    /// * [`FileScope::Any`] — global resolution. Used by `sqry impact`,
448    ///   `sqry-mcp dependency_impact`, etc.
449    /// * [`FileScope::Path`] / [`FileScope::FileId`] — file-scoped
450    ///   resolution. Used by `sqry explain` and similar
451    ///   single-file-anchored commands.
452    pub fn resolve_global_symbol_ambiguity_aware(
453        &self,
454        symbol: &str,
455        file_scope: FileScope<'_>,
456    ) -> Result<NodeId, SymbolResolveError> {
457        // Strict mode rejects suffix candidates — only exact-qualified
458        // and exact-simple buckets are eligible. That's correct here:
459        // canonical-suffix matching is a fuzzy fallback that has no place
460        // in a "resolve to one canonical node" contract.
461        let primary = self.resolve_symbol(&SymbolQuery {
462            symbol,
463            file_scope,
464            mode: ResolutionMode::Strict,
465        });
466
467        let outcome = match primary {
468            // Successful resolution short-circuits before the dot-norm fallback.
469            SymbolResolutionOutcome::Resolved(_) | SymbolResolutionOutcome::Ambiguous(_) => primary,
470            // Dot-normalized fallback: a user passing
471            // `pkg.subpkg.fn` against a graph that internally stores
472            // `pkg::subpkg::fn` (Go, Python, Java, etc.) lands here. We
473            // only attempt the rewrite when the symbol has dots and no
474            // existing `::`, to avoid shadowing native-form symbols.
475            SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
476                if symbol.contains('.') && !symbol.contains("::") {
477                    let normalized = symbol.replace('.', "::");
478                    self.resolve_symbol(&SymbolQuery {
479                        symbol: &normalized,
480                        file_scope,
481                        mode: ResolutionMode::Strict,
482                    })
483                } else {
484                    primary
485                }
486            }
487        };
488
489        match outcome {
490            SymbolResolutionOutcome::Resolved(node_id) => Ok(node_id),
491            SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
492                Err(SymbolResolveError::NotFound {
493                    name: symbol.to_string(),
494                })
495            }
496            SymbolResolutionOutcome::Ambiguous(candidates) => Err(SymbolResolveError::Ambiguous(
497                self.build_ambiguous_symbol_error(symbol, &candidates),
498            )),
499        }
500    }
501
502    /// Materialize a list of node ids into a stable, capped
503    /// [`AmbiguousSymbolError`] payload.
504    fn build_ambiguous_symbol_error(
505        &self,
506        symbol: &str,
507        candidates: &[NodeId],
508    ) -> AmbiguousSymbolError {
509        let mut materialized: Vec<AmbiguousSymbolCandidate> = candidates
510            .iter()
511            .filter_map(|node_id| self.materialize_ambiguous_candidate(*node_id))
512            .collect();
513
514        // Stable lexicographic ordering on the wire payload — independent
515        // of bucket ordering / arena insertion order. Kept here (not in
516        // the resolver) so the cap-and-truncate decision is taken on the
517        // user-visible projection.
518        materialized.sort_by(|left, right| {
519            left.qualified_name
520                .cmp(&right.qualified_name)
521                .then(left.file_path.cmp(&right.file_path))
522                .then(left.start_line.cmp(&right.start_line))
523                .then(left.start_column.cmp(&right.start_column))
524        });
525
526        let truncated = materialized.len() > AMBIGUOUS_SYMBOL_CANDIDATE_CAP;
527        materialized.truncate(AMBIGUOUS_SYMBOL_CANDIDATE_CAP);
528
529        AmbiguousSymbolError {
530            name: symbol.to_string(),
531            candidates: materialized,
532            truncated,
533        }
534    }
535
536    fn materialize_ambiguous_candidate(&self, node_id: NodeId) -> Option<AmbiguousSymbolCandidate> {
537        let entry = self.get_node(node_id)?;
538        let strings = self.strings();
539        let files = self.files();
540
541        let simple_name = strings
542            .resolve(entry.name)
543            .map_or_else(String::new, |s| s.to_string());
544        let qualified_name = entry
545            .qualified_name
546            .and_then(|id| strings.resolve(id))
547            .map_or_else(|| simple_name.clone(), |s| s.to_string());
548        let file_path = files
549            .resolve(entry.file)
550            .map_or_else(String::new, |p| p.display().to_string());
551
552        Some(AmbiguousSymbolCandidate {
553            qualified_name,
554            kind: entry.kind.as_str().to_string(),
555            file_path,
556            start_line: entry.start_line,
557            start_column: entry.start_column,
558        })
559    }
560
561    fn candidate_sort_key(&self, node_id: NodeId) -> CandidateSortKey {
562        let Some(entry) = self.get_node(node_id) else {
563            return CandidateSortKey::default_for(node_id);
564        };
565
566        let file_path = self
567            .files()
568            .resolve(entry.file)
569            .map_or_else(String::new, |path| path.to_string_lossy().into_owned());
570        let qualified_name = entry
571            .qualified_name
572            .and_then(|string_id| self.strings().resolve(string_id))
573            .map_or_else(String::new, |value| value.to_string());
574        let simple_name = self
575            .strings()
576            .resolve(entry.name)
577            .map_or_else(String::new, |value| value.to_string());
578
579        CandidateSortKey {
580            file_path,
581            start_line: entry.start_line,
582            start_column: entry.start_column,
583            end_line: entry.end_line,
584            end_column: entry.end_column,
585            kind: entry.kind.as_str().to_string(),
586            qualified_name,
587            simple_name,
588            node_id,
589        }
590    }
591}
592
593/// Witness-bearing candidate-search result.
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct SymbolCandidateSearchWitness {
596    /// Normalized query when file scoping resolved successfully.
597    pub normalized_query: Option<NormalizedSymbolQuery>,
598    /// Legacy candidate-search outcome.
599    pub outcome: SymbolCandidateOutcome,
600    /// Winning bucket when a non-empty bucket exists.
601    pub selected_bucket: Option<SymbolCandidateBucket>,
602    /// Ordered candidate witnesses from the first non-empty bucket.
603    pub candidates: Vec<SymbolCandidateWitness>,
604}
605
606/// Maximum number of candidates surfaced by [`AmbiguousSymbolError`].
607///
608/// The cap prevents pathological responses on simple names that happen to
609/// match hundreds of nodes (e.g. `init`); when the bucket contains more
610/// than this many candidates the rest are dropped and
611/// [`AmbiguousSymbolError::truncated`] is set to `true`.
612pub const AMBIGUOUS_SYMBOL_CANDIDATE_CAP: usize = 20;
613
614/// One candidate surfaced by an [`AmbiguousSymbolError`].
615///
616/// The fields are deliberately denormalized strings/integers — the wire
617/// envelope on the CLI/MCP boundary serializes this struct directly via
618/// `serde`, and consumers (humans + agents) read the displayed fields
619/// without having to look them up against the live snapshot.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621pub struct AmbiguousSymbolCandidate {
622    /// Canonical qualified name with `::` separators (or simple name when
623    /// the node has no qualified name).
624    pub qualified_name: String,
625    /// Lowercase node-kind label (`"function"`, `"property"`, `"variable"`, …).
626    pub kind: String,
627    /// Display path of the source file the candidate is defined in.
628    pub file_path: String,
629    /// One-based start line of the candidate's definition span.
630    pub start_line: u32,
631    /// Zero-based start column of the candidate's definition span.
632    pub start_column: u32,
633}
634
635/// Typed payload for an ambiguous symbol resolution.
636///
637/// Surfaced by [`GraphSnapshot::resolve_global_symbol_ambiguity_aware`] when
638/// a bare symbol name resolves to multiple nodes. Consumers (CLI / MCP /
639/// LSP) serialize this directly into their wire envelope under the stable
640/// error code `sqry::ambiguous_symbol`.
641///
642/// Candidates are sorted by `(qualified_name, file_path, start_line,
643/// start_column)` lexicographically and capped at
644/// [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`]. When the cap fires, `truncated` is
645/// set to `true` so consumers can surface the cap to the user.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647pub struct AmbiguousSymbolError {
648    /// The original (un-normalized) symbol the caller asked for.
649    pub name: String,
650    /// Bounded list of candidate definitions, deterministically ordered.
651    pub candidates: Vec<AmbiguousSymbolCandidate>,
652    /// `true` when more than [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`] candidates
653    /// matched and the tail was dropped.
654    pub truncated: bool,
655}
656
657impl std::fmt::Display for AmbiguousSymbolError {
658    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659        write!(
660            f,
661            "Symbol '{}' is ambiguous; specify the qualified name",
662            self.name
663        )
664    }
665}
666
667/// Single-result resolver outcome surfaced to CLI / MCP boundaries.
668///
669/// Distinct from [`SymbolResolutionOutcome`] because the boundary error
670/// shape needs typed metadata (kind, file, span) per candidate, not just
671/// `NodeId`s. CLI/MCP layers downcast through `anyhow::Error` chains and
672/// convert this to the `sqry::ambiguous_symbol` envelope verbatim.
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum SymbolResolveError {
675    /// No node matched the requested symbol.
676    NotFound {
677        /// The symbol the caller asked for.
678        name: String,
679    },
680    /// Multiple nodes matched and the resolver refuses to choose.
681    Ambiguous(AmbiguousSymbolError),
682}
683
684impl std::fmt::Display for SymbolResolveError {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        match self {
687            Self::NotFound { name } => write!(f, "Symbol '{name}' not found in graph"),
688            Self::Ambiguous(err) => write!(f, "{err}"),
689        }
690    }
691}
692
693impl std::error::Error for SymbolResolveError {}
694
695#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
696struct CandidateSortKey {
697    file_path: String,
698    start_line: u32,
699    start_column: u32,
700    end_line: u32,
701    end_column: u32,
702    kind: String,
703    qualified_name: String,
704    simple_name: String,
705    node_id: NodeId,
706}
707
708impl CandidateSortKey {
709    fn default_for(node_id: NodeId) -> Self {
710        Self {
711            file_path: String::new(),
712            start_line: 0,
713            start_column: 0,
714            end_line: 0,
715            end_column: 0,
716            kind: String::new(),
717            qualified_name: String::new(),
718            simple_name: String::new(),
719            node_id,
720        }
721    }
722}
723
724/// Canonicalize a language-native qualified name into graph-internal `::` form.
725#[must_use]
726pub fn canonicalize_graph_qualified_name(language: Language, symbol: &str) -> String {
727    if should_skip_qualified_name_normalization(symbol) {
728        return symbol.to_string();
729    }
730
731    if language == Language::R {
732        return canonicalize_r_qualified_name(symbol);
733    }
734
735    let mut normalized = symbol.to_string();
736    for delimiter in native_delimiters(language) {
737        if normalized.contains(delimiter) {
738            normalized = normalized.replace(delimiter, "::");
739        }
740    }
741    normalized
742}
743
744/// Returns `true` when a qualified name is already in graph-canonical form.
745#[must_use]
746pub(crate) fn is_canonical_graph_qualified_name(language: Language, symbol: &str) -> bool {
747    should_skip_qualified_name_normalization(symbol)
748        || canonicalize_graph_qualified_name(language, symbol) == symbol
749}
750
751fn should_skip_qualified_name_normalization(symbol: &str) -> bool {
752    symbol.starts_with('<')
753        || symbol.contains('/')
754        || symbol.starts_with("wasm::")
755        || symbol.starts_with("ffi::")
756        || symbol.starts_with("extern::")
757        || symbol.starts_with("native::")
758}
759
760fn canonicalize_r_qualified_name(symbol: &str) -> String {
761    let search_start = usize::from(symbol.starts_with('.'));
762    let Some(relative_split_index) = symbol[search_start..].rfind('.') else {
763        return symbol.to_string();
764    };
765
766    let split_index = search_start + relative_split_index;
767    let prefix = &symbol[..split_index];
768    let suffix = &symbol[split_index + 1..];
769    if suffix.is_empty() {
770        return symbol.to_string();
771    }
772
773    format!("{prefix}::{suffix}")
774}
775
776/// Convert a canonical graph qualified name into native language display form.
777#[must_use]
778pub fn display_graph_qualified_name(
779    language: Language,
780    qualified: &str,
781    kind: NodeKind,
782    is_static: bool,
783) -> String {
784    if should_skip_qualified_name_normalization(qualified) {
785        return qualified.to_string();
786    }
787
788    match language {
789        Language::Ruby => display_ruby_qualified_name(qualified, kind, is_static),
790        Language::Php => display_php_qualified_name(qualified, kind),
791        _ => native_display_separator(language).map_or_else(
792            || qualified.to_string(),
793            |separator| qualified.replace("::", separator),
794        ),
795    }
796}
797
798pub(crate) fn native_delimiters(language: Language) -> &'static [&'static str] {
799    match language {
800        Language::JavaScript
801        | Language::Python
802        | Language::TypeScript
803        | Language::Java
804        | Language::CSharp
805        | Language::Kotlin
806        | Language::Scala
807        | Language::Go
808        | Language::Css
809        | Language::Sql
810        | Language::Dart
811        | Language::Lua
812        | Language::Perl
813        | Language::Groovy
814        | Language::Elixir
815        | Language::R
816        | Language::Haskell
817        | Language::Html
818        | Language::Svelte
819        | Language::Vue
820        | Language::Terraform
821        | Language::Puppet
822        | Language::Pulumi
823        | Language::Http
824        | Language::Plsql
825        | Language::Apex
826        | Language::Abap
827        | Language::ServiceNow
828        | Language::Swift
829        | Language::Zig
830        | Language::Json => &["."],
831        Language::Ruby => &["#", "."],
832        Language::Php => &["\\", "->"],
833        Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
834    }
835}
836
837fn native_display_separator(language: Language) -> Option<&'static str> {
838    match language {
839        Language::C
840        | Language::Cpp
841        | Language::Rust
842        | Language::Shell
843        | Language::Php
844        | Language::Ruby => None,
845        _ => Some("."),
846    }
847}
848
849fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
850    if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
851        return qualified.to_string();
852    }
853
854    match kind {
855        NodeKind::Method => {
856            replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
857        }
858        NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
859            replace_last_separator(qualified, "#", false)
860        }
861        _ => qualified.to_string(),
862    }
863}
864
865fn should_display_ruby_member_variable(qualified: &str) -> bool {
866    let Some((_, suffix)) = qualified.rsplit_once("::") else {
867        return false;
868    };
869
870    if suffix.starts_with("@@")
871        || suffix
872            .chars()
873            .next()
874            .is_some_and(|character| character.is_ascii_uppercase())
875    {
876        return false;
877    }
878
879    suffix.starts_with('@')
880        || suffix
881            .chars()
882            .next()
883            .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
884}
885
886fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
887    if !qualified.contains("::") {
888        return qualified.to_string();
889    }
890
891    if matches!(kind, NodeKind::Method | NodeKind::Property) {
892        return replace_last_separator(qualified, "::", true);
893    }
894
895    qualified.replace("::", "\\")
896}
897
898fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
899    let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
900        return qualified.to_string();
901    };
902
903    let display_prefix = if preserve_prefix {
904        prefix.replace("::", "\\")
905    } else {
906        prefix.to_string()
907    };
908
909    if display_prefix.is_empty() {
910        suffix.to_string()
911    } else {
912        format!("{display_prefix}{final_separator}{suffix}")
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use std::path::{Path, PathBuf};
919
920    use crate::graph::node::Language;
921    use crate::graph::unified::concurrent::CodeGraph;
922    use crate::graph::unified::node::id::NodeId;
923    use crate::graph::unified::node::kind::NodeKind;
924    use crate::graph::unified::storage::arena::NodeEntry;
925
926    use super::{
927        FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope, SymbolCandidateBucket,
928        SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
929        canonicalize_graph_qualified_name, display_graph_qualified_name,
930    };
931
932    struct TestNode {
933        node_id: NodeId,
934    }
935
936    #[test]
937    fn test_resolve_symbol_exact_qualified_same_file() {
938        let mut graph = CodeGraph::new();
939        let file_path = abs_path("src/lib.rs");
940        let symbol = add_node(
941            &mut graph,
942            NodeKind::Function,
943            "target",
944            Some("pkg::target"),
945            &file_path,
946            Some(Language::Rust),
947            10,
948            2,
949        );
950
951        let snapshot = graph.snapshot();
952        let query = SymbolQuery {
953            symbol: "pkg::target",
954            file_scope: FileScope::Path(&file_path),
955            mode: ResolutionMode::Strict,
956        };
957
958        assert_eq!(
959            snapshot.resolve_symbol(&query),
960            SymbolResolutionOutcome::Resolved(symbol.node_id)
961        );
962    }
963
964    #[test]
965    fn test_resolve_symbol_exact_simple_same_file_wins() {
966        let mut graph = CodeGraph::new();
967        let requested_path = abs_path("src/requested.rs");
968        let other_path = abs_path("src/other.rs");
969
970        let requested = add_node(
971            &mut graph,
972            NodeKind::Function,
973            "target",
974            Some("requested::target"),
975            &requested_path,
976            Some(Language::Rust),
977            4,
978            0,
979        );
980        let _other = add_node(
981            &mut graph,
982            NodeKind::Function,
983            "target",
984            Some("other::target"),
985            &other_path,
986            Some(Language::Rust),
987            1,
988            0,
989        );
990
991        let snapshot = graph.snapshot();
992        let query = SymbolQuery {
993            symbol: "target",
994            file_scope: FileScope::Path(&requested_path),
995            mode: ResolutionMode::Strict,
996        };
997
998        assert_eq!(
999            snapshot.resolve_symbol(&query),
1000            SymbolResolutionOutcome::Resolved(requested.node_id)
1001        );
1002    }
1003
1004    #[test]
1005    fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
1006        let mut graph = CodeGraph::new();
1007        let requested_path = abs_path("src/requested.rs");
1008        let other_path = abs_path("src/other.rs");
1009
1010        let _requested_index_anchor = add_node(
1011            &mut graph,
1012            NodeKind::Function,
1013            "anchor",
1014            Some("requested::anchor"),
1015            &requested_path,
1016            Some(Language::Rust),
1017            1,
1018            0,
1019        );
1020        let _other = add_node(
1021            &mut graph,
1022            NodeKind::Function,
1023            "target",
1024            Some("other::target"),
1025            &other_path,
1026            Some(Language::Rust),
1027            3,
1028            0,
1029        );
1030
1031        let snapshot = graph.snapshot();
1032        let query = SymbolQuery {
1033            symbol: "target",
1034            file_scope: FileScope::Path(&requested_path),
1035            mode: ResolutionMode::Strict,
1036        };
1037
1038        assert_eq!(
1039            snapshot.resolve_symbol(&query),
1040            SymbolResolutionOutcome::NotFound
1041        );
1042    }
1043
1044    #[test]
1045    fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
1046        let mut graph = CodeGraph::new();
1047        let indexed_path = abs_path("src/indexed.rs");
1048        let unindexed_path = abs_path("src/unindexed.rs");
1049
1050        add_node(
1051            &mut graph,
1052            NodeKind::Function,
1053            "indexed",
1054            Some("pkg::indexed"),
1055            &indexed_path,
1056            Some(Language::Rust),
1057            1,
1058            0,
1059        );
1060        graph
1061            .files_mut()
1062            .register_with_language(&unindexed_path, Some(Language::Rust))
1063            .unwrap();
1064
1065        let snapshot = graph.snapshot();
1066        let query = SymbolQuery {
1067            symbol: "indexed",
1068            file_scope: FileScope::Path(&unindexed_path),
1069            mode: ResolutionMode::Strict,
1070        };
1071
1072        assert_eq!(
1073            snapshot.resolve_symbol(&query),
1074            SymbolResolutionOutcome::FileNotIndexed
1075        );
1076    }
1077
1078    #[test]
1079    fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
1080        let mut graph = CodeGraph::new();
1081        let file_path = abs_path("src/lib.rs");
1082
1083        let first = add_node(
1084            &mut graph,
1085            NodeKind::Function,
1086            "dup",
1087            Some("pkg::dup"),
1088            &file_path,
1089            Some(Language::Rust),
1090            2,
1091            0,
1092        );
1093        let second = add_node(
1094            &mut graph,
1095            NodeKind::Method,
1096            "dup",
1097            Some("pkg::dup_method"),
1098            &file_path,
1099            Some(Language::Rust),
1100            8,
1101            0,
1102        );
1103
1104        let snapshot = graph.snapshot();
1105        let query = SymbolQuery {
1106            symbol: "dup",
1107            file_scope: FileScope::Path(&file_path),
1108            mode: ResolutionMode::Strict,
1109        };
1110
1111        assert_eq!(
1112            snapshot.resolve_symbol(&query),
1113            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
1114        );
1115    }
1116
1117    #[test]
1118    fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
1119        let mut graph = CodeGraph::new();
1120        let qualified_path = abs_path("src/qualified.rs");
1121        let simple_path = abs_path("src/simple.rs");
1122
1123        let qualified = add_node(
1124            &mut graph,
1125            NodeKind::Function,
1126            "target",
1127            Some("pkg::target"),
1128            &qualified_path,
1129            Some(Language::Rust),
1130            1,
1131            0,
1132        );
1133        let simple_only = add_node(
1134            &mut graph,
1135            NodeKind::Function,
1136            "pkg::target",
1137            None,
1138            &simple_path,
1139            Some(Language::Rust),
1140            1,
1141            0,
1142        );
1143
1144        let snapshot = graph.snapshot();
1145        let query = SymbolQuery {
1146            symbol: "pkg::target",
1147            file_scope: FileScope::Any,
1148            mode: ResolutionMode::AllowSuffixCandidates,
1149        };
1150
1151        assert_eq!(
1152            snapshot.find_symbol_candidates(&query),
1153            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
1154        );
1155        assert_ne!(qualified.node_id, simple_only.node_id);
1156    }
1157
1158    #[test]
1159    fn test_find_symbol_candidates_with_witness_reports_exact_qualified_bucket() {
1160        let mut graph = CodeGraph::new();
1161        let qualified_path = abs_path("src/qualified.rs");
1162        let simple_path = abs_path("src/simple.rs");
1163
1164        let qualified = add_node(
1165            &mut graph,
1166            NodeKind::Function,
1167            "target",
1168            Some("pkg::target"),
1169            &qualified_path,
1170            Some(Language::Rust),
1171            1,
1172            0,
1173        );
1174        let _simple_only = add_node(
1175            &mut graph,
1176            NodeKind::Function,
1177            "pkg::target",
1178            None,
1179            &simple_path,
1180            Some(Language::Rust),
1181            1,
1182            0,
1183        );
1184
1185        let snapshot = graph.snapshot();
1186        let query = SymbolQuery {
1187            symbol: "pkg::target",
1188            file_scope: FileScope::Any,
1189            mode: ResolutionMode::AllowSuffixCandidates,
1190        };
1191
1192        let witness = snapshot.find_symbol_candidates_with_witness(&query);
1193
1194        assert_eq!(
1195            witness.outcome,
1196            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
1197        );
1198        assert_eq!(
1199            witness.selected_bucket,
1200            Some(SymbolCandidateBucket::ExactQualified)
1201        );
1202        assert_eq!(
1203            witness.candidates,
1204            vec![super::SymbolCandidateWitness {
1205                node_id: qualified.node_id,
1206                bucket: SymbolCandidateBucket::ExactQualified,
1207            }]
1208        );
1209        assert_eq!(
1210            witness.normalized_query,
1211            Some(NormalizedSymbolQuery {
1212                symbol: "pkg::target".to_string(),
1213                file_scope: ResolvedFileScope::Any,
1214                mode: ResolutionMode::AllowSuffixCandidates,
1215            })
1216        );
1217    }
1218
1219    #[test]
1220    fn test_find_symbol_candidates_preserves_file_not_indexed() {
1221        let mut graph = CodeGraph::new();
1222        let indexed_path = abs_path("src/indexed.rs");
1223        let unindexed_path = abs_path("src/unindexed.rs");
1224
1225        add_node(
1226            &mut graph,
1227            NodeKind::Function,
1228            "target",
1229            Some("pkg::target"),
1230            &indexed_path,
1231            Some(Language::Rust),
1232            1,
1233            0,
1234        );
1235        let unindexed_file_id = graph
1236            .files_mut()
1237            .register_with_language(&unindexed_path, Some(Language::Rust))
1238            .unwrap();
1239
1240        let snapshot = graph.snapshot();
1241        let query = SymbolQuery {
1242            symbol: "target",
1243            file_scope: FileScope::FileId(unindexed_file_id),
1244            mode: ResolutionMode::AllowSuffixCandidates,
1245        };
1246
1247        assert_eq!(
1248            snapshot.find_symbol_candidates(&query),
1249            SymbolCandidateOutcome::FileNotIndexed
1250        );
1251    }
1252
1253    #[test]
1254    fn test_resolve_symbol_with_witness_reports_ambiguous_bucket_candidates() {
1255        let mut graph = CodeGraph::new();
1256        let file_path = abs_path("src/lib.rs");
1257
1258        let first = add_node(
1259            &mut graph,
1260            NodeKind::Function,
1261            "dup",
1262            Some("pkg::dup"),
1263            &file_path,
1264            Some(Language::Rust),
1265            2,
1266            0,
1267        );
1268        let second = add_node(
1269            &mut graph,
1270            NodeKind::Method,
1271            "dup",
1272            Some("pkg::dup_method"),
1273            &file_path,
1274            Some(Language::Rust),
1275            8,
1276            0,
1277        );
1278
1279        let snapshot = graph.snapshot();
1280        let query = SymbolQuery {
1281            symbol: "dup",
1282            file_scope: FileScope::Path(&file_path),
1283            mode: ResolutionMode::Strict,
1284        };
1285
1286        let witness = snapshot.resolve_symbol_with_witness(&query);
1287
1288        assert_eq!(
1289            witness.outcome,
1290            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
1291        );
1292        assert_eq!(
1293            witness.selected_bucket,
1294            Some(SymbolCandidateBucket::ExactSimple)
1295        );
1296        assert_eq!(
1297            witness.candidates,
1298            vec![
1299                super::SymbolCandidateWitness {
1300                    node_id: first.node_id,
1301                    bucket: SymbolCandidateBucket::ExactSimple,
1302                },
1303                super::SymbolCandidateWitness {
1304                    node_id: second.node_id,
1305                    bucket: SymbolCandidateBucket::ExactSimple,
1306                },
1307            ]
1308        );
1309    }
1310
1311    #[test]
1312    fn test_suffix_candidates_disabled_in_strict_mode() {
1313        let mut graph = CodeGraph::new();
1314        let file_path = abs_path("src/lib.rs");
1315
1316        let suffix_match = add_node(
1317            &mut graph,
1318            NodeKind::Function,
1319            "target",
1320            Some("outer::pkg::target"),
1321            &file_path,
1322            Some(Language::Rust),
1323            1,
1324            0,
1325        );
1326
1327        let snapshot = graph.snapshot();
1328        let strict_query = SymbolQuery {
1329            symbol: "pkg::target",
1330            file_scope: FileScope::Any,
1331            mode: ResolutionMode::Strict,
1332        };
1333        let suffix_query = SymbolQuery {
1334            mode: ResolutionMode::AllowSuffixCandidates,
1335            ..strict_query
1336        };
1337
1338        assert_eq!(
1339            snapshot.resolve_symbol(&strict_query),
1340            SymbolResolutionOutcome::NotFound
1341        );
1342        assert_eq!(
1343            snapshot.find_symbol_candidates(&suffix_query),
1344            SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
1345        );
1346    }
1347
1348    #[test]
1349    fn test_suffix_candidates_require_canonical_qualified_query() {
1350        let mut graph = CodeGraph::new();
1351        let file_path = abs_path("src/mod.py");
1352
1353        add_node(
1354            &mut graph,
1355            NodeKind::Function,
1356            "target",
1357            Some("pkg::target"),
1358            &file_path,
1359            Some(Language::Python),
1360            1,
1361            0,
1362        );
1363
1364        let snapshot = graph.snapshot();
1365        let query = SymbolQuery {
1366            symbol: "pkg.target",
1367            file_scope: FileScope::Any,
1368            mode: ResolutionMode::AllowSuffixCandidates,
1369        };
1370
1371        assert_eq!(
1372            snapshot.find_symbol_candidates(&query),
1373            SymbolCandidateOutcome::NotFound
1374        );
1375    }
1376
1377    #[test]
1378    fn test_suffix_candidates_filter_same_leaf_bucket_only() {
1379        let mut graph = CodeGraph::new();
1380        let file_path = abs_path("src/lib.rs");
1381
1382        let exact_suffix = add_node(
1383            &mut graph,
1384            NodeKind::Function,
1385            "target",
1386            Some("outer::pkg::target"),
1387            &file_path,
1388            Some(Language::Rust),
1389            2,
1390            0,
1391        );
1392        let another_suffix = add_node(
1393            &mut graph,
1394            NodeKind::Method,
1395            "target",
1396            Some("another::pkg::target"),
1397            &file_path,
1398            Some(Language::Rust),
1399            4,
1400            0,
1401        );
1402        let unrelated = add_node(
1403            &mut graph,
1404            NodeKind::Function,
1405            "target",
1406            Some("pkg::different::target"),
1407            &file_path,
1408            Some(Language::Rust),
1409            6,
1410            0,
1411        );
1412
1413        let snapshot = graph.snapshot();
1414        let query = SymbolQuery {
1415            symbol: "pkg::target",
1416            file_scope: FileScope::Any,
1417            mode: ResolutionMode::AllowSuffixCandidates,
1418        };
1419
1420        assert_eq!(
1421            snapshot.find_symbol_candidates(&query),
1422            SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
1423        );
1424        assert_ne!(unrelated.node_id, exact_suffix.node_id);
1425    }
1426
1427    #[test]
1428    fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
1429        let mut graph = CodeGraph::new();
1430        let file_path = abs_path("src/mod.py");
1431        let file_id = graph
1432            .files_mut()
1433            .register_with_language(&file_path, Some(Language::Python))
1434            .unwrap();
1435        let snapshot = graph.snapshot();
1436        let query = SymbolQuery {
1437            symbol: "pkg.mod.fn",
1438            file_scope: FileScope::Path(&file_path),
1439            mode: ResolutionMode::Strict,
1440        };
1441
1442        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1443
1444        assert_eq!(
1445            normalized,
1446            NormalizedSymbolQuery {
1447                symbol: "pkg::mod::fn".to_string(),
1448                file_scope: ResolvedFileScope::File(file_id),
1449                mode: ResolutionMode::Strict,
1450            }
1451        );
1452    }
1453
1454    #[test]
1455    fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
1456        let mut graph = CodeGraph::new();
1457        let file_path = abs_path("src/Program.cs");
1458        let file_id = graph
1459            .files_mut()
1460            .register_with_language(&file_path, Some(Language::CSharp))
1461            .unwrap();
1462        let snapshot = graph.snapshot();
1463        let query = SymbolQuery {
1464            symbol: "System.Console.WriteLine",
1465            file_scope: FileScope::Path(&file_path),
1466            mode: ResolutionMode::Strict,
1467        };
1468
1469        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1470
1471        assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
1472    }
1473
1474    #[test]
1475    fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
1476        let mut graph = CodeGraph::new();
1477        let file_path = abs_path("src/main.zig");
1478        let file_id = graph
1479            .files_mut()
1480            .register_with_language(&file_path, Some(Language::Zig))
1481            .unwrap();
1482        let snapshot = graph.snapshot();
1483        let query = SymbolQuery {
1484            symbol: "std.os.linux.exit",
1485            file_scope: FileScope::Path(&file_path),
1486            mode: ResolutionMode::Strict,
1487        };
1488
1489        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1490
1491        assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
1492    }
1493
1494    #[test]
1495    fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
1496        let graph = CodeGraph::new();
1497        let snapshot = graph.snapshot();
1498        let query = SymbolQuery {
1499            symbol: "pkg.mod.fn",
1500            file_scope: FileScope::Any,
1501            mode: ResolutionMode::Strict,
1502        };
1503
1504        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
1505
1506        assert_eq!(
1507            normalized,
1508            NormalizedSymbolQuery {
1509                symbol: "pkg.mod.fn".to_string(),
1510                file_scope: ResolvedFileScope::Any,
1511                mode: ResolutionMode::Strict,
1512            }
1513        );
1514    }
1515
1516    #[test]
1517    fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1518        let mut graph = CodeGraph::new();
1519        let file_path = abs_path("src/mod.py");
1520
1521        add_node(
1522            &mut graph,
1523            NodeKind::Function,
1524            "fn",
1525            Some("pkg::mod::fn"),
1526            &file_path,
1527            Some(Language::Python),
1528            1,
1529            0,
1530        );
1531
1532        let snapshot = graph.snapshot();
1533        let query = SymbolQuery {
1534            symbol: "pkg.mod.fn",
1535            file_scope: FileScope::Any,
1536            mode: ResolutionMode::AllowSuffixCandidates,
1537        };
1538
1539        assert_eq!(
1540            snapshot.resolve_symbol(&query),
1541            SymbolResolutionOutcome::NotFound
1542        );
1543    }
1544
1545    #[test]
1546    fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1547        let mut graph = CodeGraph::new();
1548        let file_path = abs_path("src/lib.rs");
1549        let expected = add_node(
1550            &mut graph,
1551            NodeKind::Function,
1552            "fn",
1553            Some("pkg::mod::fn"),
1554            &file_path,
1555            Some(Language::Rust),
1556            1,
1557            0,
1558        );
1559
1560        let snapshot = graph.snapshot();
1561        let query = SymbolQuery {
1562            symbol: "pkg::mod::fn",
1563            file_scope: FileScope::Any,
1564            mode: ResolutionMode::Strict,
1565        };
1566
1567        assert_eq!(
1568            snapshot.resolve_symbol(&query),
1569            SymbolResolutionOutcome::Resolved(expected.node_id)
1570        );
1571    }
1572
1573    #[test]
1574    fn test_candidate_order_uses_metadata_then_node_id() {
1575        let mut graph = CodeGraph::new();
1576        let file_path = abs_path("src/lib.rs");
1577
1578        let first = add_node(
1579            &mut graph,
1580            NodeKind::Function,
1581            "dup",
1582            Some("pkg::dup_a"),
1583            &file_path,
1584            Some(Language::Rust),
1585            1,
1586            0,
1587        );
1588        let second = add_node(
1589            &mut graph,
1590            NodeKind::Function,
1591            "dup",
1592            Some("pkg::dup_b"),
1593            &file_path,
1594            Some(Language::Rust),
1595            1,
1596            0,
1597        );
1598
1599        let snapshot = graph.snapshot();
1600        let query = SymbolQuery {
1601            symbol: "dup",
1602            file_scope: FileScope::Any,
1603            mode: ResolutionMode::Strict,
1604        };
1605
1606        assert_eq!(
1607            snapshot.find_symbol_candidates(&query),
1608            SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1609        );
1610    }
1611
1612    #[test]
1613    fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1614        let mut graph = CodeGraph::new();
1615        let file_path = abs_path("src/lib.rs");
1616
1617        let function_node = add_node(
1618            &mut graph,
1619            NodeKind::Function,
1620            "shared",
1621            Some("pkg::shared_fn"),
1622            &file_path,
1623            Some(Language::Rust),
1624            1,
1625            0,
1626        );
1627        let variable_node = add_node(
1628            &mut graph,
1629            NodeKind::Variable,
1630            "shared",
1631            Some("pkg::shared_var"),
1632            &file_path,
1633            Some(Language::Rust),
1634            1,
1635            0,
1636        );
1637
1638        let snapshot = graph.snapshot();
1639        let query = SymbolQuery {
1640            symbol: "shared",
1641            file_scope: FileScope::Any,
1642            mode: ResolutionMode::Strict,
1643        };
1644
1645        assert_eq!(
1646            snapshot.find_symbol_candidates(&query),
1647            SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1648        );
1649    }
1650
1651    fn add_node(
1652        graph: &mut CodeGraph,
1653        kind: NodeKind,
1654        name: &str,
1655        qualified_name: Option<&str>,
1656        file_path: &Path,
1657        language: Option<Language>,
1658        start_line: u32,
1659        start_column: u32,
1660    ) -> TestNode {
1661        let name_id = graph.strings_mut().intern(name).unwrap();
1662        let qualified_name_id =
1663            qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1664        let file_id = graph
1665            .files_mut()
1666            .register_with_language(file_path, language)
1667            .unwrap();
1668
1669        let entry = NodeEntry::new(kind, name_id, file_id)
1670            .with_qualified_name_opt(qualified_name_id)
1671            .with_location(start_line, start_column, start_line, start_column + 1);
1672
1673        let node_id = graph.nodes_mut().alloc(entry).unwrap();
1674        graph
1675            .indices_mut()
1676            .add(node_id, kind, name_id, qualified_name_id, file_id);
1677
1678        TestNode { node_id }
1679    }
1680
1681    trait NodeEntryExt {
1682        fn with_qualified_name_opt(
1683            self,
1684            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1685        ) -> Self;
1686    }
1687
1688    impl NodeEntryExt for NodeEntry {
1689        fn with_qualified_name_opt(
1690            mut self,
1691            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1692        ) -> Self {
1693            self.qualified_name = qualified_name;
1694            self
1695        }
1696    }
1697
1698    fn abs_path(relative: &str) -> PathBuf {
1699        PathBuf::from("/resolver-tests").join(relative)
1700    }
1701
1702    #[test]
1703    fn test_display_graph_qualified_name_dot_language() {
1704        let display = display_graph_qualified_name(
1705            Language::CSharp,
1706            "MyApp::User::GetName",
1707            NodeKind::Method,
1708            false,
1709        );
1710        assert_eq!(display, "MyApp.User.GetName");
1711    }
1712
1713    #[test]
1714    fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1715        assert_eq!(
1716            canonicalize_graph_qualified_name(Language::R, ".private_func"),
1717            ".private_func"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1723        assert_eq!(
1724            canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1725            "as.data.frame::myclass"
1726        );
1727    }
1728
1729    #[test]
1730    fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1731        assert_eq!(
1732            canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1733            ".DollarNames::myclass"
1734        );
1735    }
1736
1737    #[test]
1738    fn test_display_graph_qualified_name_ruby_instance_method() {
1739        let display = display_graph_qualified_name(
1740            Language::Ruby,
1741            "Admin::Users::Controller::show",
1742            NodeKind::Method,
1743            false,
1744        );
1745        assert_eq!(display, "Admin::Users::Controller#show");
1746    }
1747
1748    #[test]
1749    fn test_display_graph_qualified_name_ruby_singleton_method() {
1750        let display = display_graph_qualified_name(
1751            Language::Ruby,
1752            "Admin::Users::Controller::show",
1753            NodeKind::Method,
1754            true,
1755        );
1756        assert_eq!(display, "Admin::Users::Controller.show");
1757    }
1758
1759    #[test]
1760    fn test_display_graph_qualified_name_ruby_member_variable() {
1761        let display = display_graph_qualified_name(
1762            Language::Ruby,
1763            "Admin::Users::Controller::username",
1764            NodeKind::Variable,
1765            false,
1766        );
1767        assert_eq!(display, "Admin::Users::Controller#username");
1768    }
1769
1770    #[test]
1771    fn test_display_graph_qualified_name_ruby_instance_variable() {
1772        let display = display_graph_qualified_name(
1773            Language::Ruby,
1774            "Admin::Users::Controller::@current_user",
1775            NodeKind::Variable,
1776            false,
1777        );
1778        assert_eq!(display, "Admin::Users::Controller#@current_user");
1779    }
1780
1781    #[test]
1782    fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1783        let display = display_graph_qualified_name(
1784            Language::Ruby,
1785            "Admin::Users::Controller::DEFAULT_ROLE",
1786            NodeKind::Variable,
1787            false,
1788        );
1789        assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1790    }
1791
1792    #[test]
1793    fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1794        let display = display_graph_qualified_name(
1795            Language::Ruby,
1796            "Admin::Users::Controller::@@count",
1797            NodeKind::Variable,
1798            false,
1799        );
1800        assert_eq!(display, "Admin::Users::Controller::@@count");
1801    }
1802
1803    #[test]
1804    fn test_display_graph_qualified_name_php_namespace_function() {
1805        let display = display_graph_qualified_name(
1806            Language::Php,
1807            "App::Services::send_mail",
1808            NodeKind::Function,
1809            false,
1810        );
1811        assert_eq!(display, "App\\Services\\send_mail");
1812    }
1813
1814    #[test]
1815    fn test_display_graph_qualified_name_php_method() {
1816        let display = display_graph_qualified_name(
1817            Language::Php,
1818            "App::Services::Mailer::deliver",
1819            NodeKind::Method,
1820            false,
1821        );
1822        assert_eq!(display, "App\\Services\\Mailer::deliver");
1823    }
1824
1825    #[test]
1826    fn test_display_graph_qualified_name_preserves_path_like_symbols() {
1827        let display = display_graph_qualified_name(
1828            Language::Go,
1829            "route::GET::/health",
1830            NodeKind::Endpoint,
1831            false,
1832        );
1833        assert_eq!(display, "route::GET::/health");
1834    }
1835
1836    #[test]
1837    fn test_display_graph_qualified_name_preserves_ffi_symbols() {
1838        let display = display_graph_qualified_name(
1839            Language::Haskell,
1840            "ffi::C::sin",
1841            NodeKind::Function,
1842            false,
1843        );
1844        assert_eq!(display, "ffi::C::sin");
1845    }
1846
1847    #[test]
1848    fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
1849        let display = display_graph_qualified_name(
1850            Language::Python,
1851            "native::cffi::calculate",
1852            NodeKind::Function,
1853            false,
1854        );
1855        assert_eq!(display, "native::cffi::calculate");
1856    }
1857
1858    #[test]
1859    fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
1860        let display = display_graph_qualified_name(
1861            Language::Php,
1862            "native::ffi::crypto_encrypt",
1863            NodeKind::Function,
1864            false,
1865        );
1866        assert_eq!(display, "native::ffi::crypto_encrypt");
1867    }
1868
1869    #[test]
1870    fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
1871        let display = display_graph_qualified_name(
1872            Language::Java,
1873            "native::panama::nativeLinker",
1874            NodeKind::Function,
1875            false,
1876        );
1877        assert_eq!(display, "native::panama::nativeLinker");
1878    }
1879
1880    #[test]
1881    fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
1882        assert_eq!(
1883            canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
1884            "wasm::module.wasm"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
1890        assert_eq!(
1891            canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
1892            "native::binding.node"
1893        );
1894    }
1895
1896    #[test]
1897    fn test_display_graph_qualified_name_preserves_wasm_symbols() {
1898        let display = display_graph_qualified_name(
1899            Language::TypeScript,
1900            "wasm::module.wasm",
1901            NodeKind::Module,
1902            false,
1903        );
1904        assert_eq!(display, "wasm::module.wasm");
1905    }
1906
1907    #[test]
1908    fn test_display_graph_qualified_name_preserves_native_symbols() {
1909        let display = display_graph_qualified_name(
1910            Language::TypeScript,
1911            "native::binding.node",
1912            NodeKind::Module,
1913            false,
1914        );
1915        assert_eq!(display, "native::binding.node");
1916    }
1917
1918    #[test]
1919    fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
1920        assert_eq!(
1921            canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
1922            "Foo::bar"
1923        );
1924    }
1925
1926    // ── P2U06 tests ──────────────────────────────────────────────────────────
1927
1928    /// `SymbolResolutionWitness` constructed by `resolve_symbol_with_witness`
1929    /// must carry an empty `steps` field (P2U07 is the emission point).
1930    #[test]
1931    fn p2u06_witness_steps_field_defaults_to_empty() {
1932        let mut graph = CodeGraph::new();
1933        let file_path = abs_path("src/lib.rs");
1934
1935        let symbol = add_node(
1936            &mut graph,
1937            NodeKind::Function,
1938            "my_fn",
1939            Some("pkg::my_fn"),
1940            &file_path,
1941            Some(Language::Rust),
1942            1,
1943            0,
1944        );
1945
1946        let snapshot = graph.snapshot();
1947        let query = SymbolQuery {
1948            symbol: "pkg::my_fn",
1949            file_scope: FileScope::Any,
1950            mode: ResolutionMode::Strict,
1951        };
1952
1953        let witness = snapshot.resolve_symbol_with_witness(&query);
1954        assert_eq!(
1955            witness.outcome,
1956            SymbolResolutionOutcome::Resolved(symbol.node_id)
1957        );
1958        assert!(
1959            witness.steps.is_empty(),
1960            "P2U06 initialises steps to Vec::new(); emission is deferred to P2U07"
1961        );
1962    }
1963
1964    /// `steps` field is assignable and holds `ResolutionStep` values; `Eq` is
1965    /// preserved on the struct even after the new field is added.
1966    #[test]
1967    fn p2u06_witness_steps_field_is_eq_compatible() {
1968        use crate::graph::unified::bind::witness::step::ResolutionStep;
1969        use crate::graph::unified::file::id::FileId;
1970
1971        let step = ResolutionStep::EnterFileScope {
1972            file: FileId::new(0),
1973        };
1974
1975        let witness = super::SymbolResolutionWitness {
1976            normalized_query: None,
1977            outcome: super::SymbolResolutionOutcome::NotFound,
1978            selected_bucket: None,
1979            candidates: Vec::new(),
1980            symbol: None,
1981            steps: vec![step.clone()],
1982        };
1983        let expected = super::SymbolResolutionWitness {
1984            normalized_query: None,
1985            outcome: super::SymbolResolutionOutcome::NotFound,
1986            selected_bucket: None,
1987            candidates: Vec::new(),
1988            symbol: None,
1989            steps: vec![step],
1990        };
1991        assert_eq!(witness, expected);
1992    }
1993
1994    /// `steps` field survives a `Clone`.
1995    #[test]
1996    fn p2u06_witness_steps_field_clones_correctly() {
1997        use crate::graph::unified::bind::witness::step::ResolutionStep;
1998        use crate::graph::unified::node::id::NodeId;
1999
2000        let step = ResolutionStep::Chose {
2001            node: NodeId::new(99, 2),
2002        };
2003        let witness = super::SymbolResolutionWitness {
2004            normalized_query: None,
2005            outcome: super::SymbolResolutionOutcome::NotFound,
2006            selected_bucket: None,
2007            candidates: Vec::new(),
2008            symbol: None,
2009            steps: vec![step],
2010        };
2011        let cloned = witness.clone();
2012        assert_eq!(witness.steps.len(), 1);
2013        assert_eq!(cloned.steps.len(), 1);
2014        assert_eq!(witness, cloned);
2015    }
2016
2017    // ── C_AMBIGUOUS tests (typed AmbiguousSymbolError surface) ─────────────
2018
2019    /// Bare-name lookup with two same-name nodes returns the typed
2020    /// [`super::SymbolResolveError::Ambiguous`] payload with both candidates
2021    /// in stable lex order.
2022    #[test]
2023    fn resolve_global_symbol_ambiguity_aware_returns_ambiguous_for_simple_name_collision() {
2024        let mut graph = CodeGraph::new();
2025        let file_path = abs_path("src/main.go");
2026
2027        let property_node = add_node(
2028            &mut graph,
2029            NodeKind::Property,
2030            "NeedTags",
2031            Some("main::SelectorSource::NeedTags"),
2032            &file_path,
2033            Some(Language::Go),
2034            4,
2035            6,
2036        );
2037        let variable_node = add_node(
2038            &mut graph,
2039            NodeKind::Variable,
2040            "NeedTags",
2041            Some("main::unrelated::NeedTags"),
2042            &file_path,
2043            Some(Language::Go),
2044            25,
2045            6,
2046        );
2047
2048        let snapshot = graph.snapshot();
2049        let result =
2050            snapshot.resolve_global_symbol_ambiguity_aware("NeedTags", super::FileScope::Any);
2051
2052        let err = result.expect_err("two same-name nodes must produce Ambiguous");
2053        let super::SymbolResolveError::Ambiguous(payload) = err else {
2054            panic!("expected Ambiguous variant, got {err:?}");
2055        };
2056        assert_eq!(payload.name, "NeedTags");
2057        assert!(!payload.truncated);
2058        assert_eq!(payload.candidates.len(), 2);
2059
2060        // Stable lex sort by qualified_name puts SelectorSource before unrelated.
2061        assert_eq!(
2062            payload.candidates[0].qualified_name,
2063            "main::SelectorSource::NeedTags"
2064        );
2065        assert_eq!(payload.candidates[0].kind, "property");
2066        assert_eq!(payload.candidates[0].start_line, 4);
2067        assert_eq!(payload.candidates[0].start_column, 6);
2068
2069        assert_eq!(
2070            payload.candidates[1].qualified_name,
2071            "main::unrelated::NeedTags"
2072        );
2073        assert_eq!(payload.candidates[1].kind, "variable");
2074        assert_eq!(payload.candidates[1].start_line, 25);
2075
2076        assert_ne!(property_node.node_id, variable_node.node_id);
2077    }
2078
2079    /// Fully-qualified-name lookup resolves unambiguously when the
2080    /// qualified bucket contains exactly one node.
2081    #[test]
2082    fn resolve_global_symbol_ambiguity_aware_resolves_qualified_name_uniquely() {
2083        let mut graph = CodeGraph::new();
2084        let file_path = abs_path("src/main.go");
2085
2086        let property_node = add_node(
2087            &mut graph,
2088            NodeKind::Property,
2089            "NeedTags",
2090            Some("main::SelectorSource::NeedTags"),
2091            &file_path,
2092            Some(Language::Go),
2093            4,
2094            6,
2095        );
2096        let _variable_node = add_node(
2097            &mut graph,
2098            NodeKind::Variable,
2099            "NeedTags",
2100            Some("main::unrelated::NeedTags"),
2101            &file_path,
2102            Some(Language::Go),
2103            25,
2104            6,
2105        );
2106
2107        let snapshot = graph.snapshot();
2108        let result = snapshot.resolve_global_symbol_ambiguity_aware(
2109            "main::SelectorSource::NeedTags",
2110            super::FileScope::Any,
2111        );
2112        assert_eq!(result, Ok(property_node.node_id));
2113    }
2114
2115    /// The qualified-name normalization path accepts native dot
2116    /// delimiters (Go, Python, Java, …) and resolves to the
2117    /// `::`-canonical node.
2118    #[test]
2119    fn resolve_global_symbol_ambiguity_aware_normalizes_dot_delimiter() {
2120        let mut graph = CodeGraph::new();
2121        let file_path = abs_path("src/main.go");
2122
2123        let property_node = add_node(
2124            &mut graph,
2125            NodeKind::Property,
2126            "NeedTags",
2127            Some("main::SelectorSource::NeedTags"),
2128            &file_path,
2129            Some(Language::Go),
2130            4,
2131            6,
2132        );
2133        let _variable_node = add_node(
2134            &mut graph,
2135            NodeKind::Variable,
2136            "NeedTags",
2137            Some("main::unrelated::NeedTags"),
2138            &file_path,
2139            Some(Language::Go),
2140            25,
2141            6,
2142        );
2143
2144        let snapshot = graph.snapshot();
2145        let result = snapshot.resolve_global_symbol_ambiguity_aware(
2146            "main.SelectorSource.NeedTags",
2147            super::FileScope::Any,
2148        );
2149        assert_eq!(result, Ok(property_node.node_id));
2150    }
2151
2152    /// Missing symbol returns the typed `NotFound` variant carrying the
2153    /// caller's input symbol verbatim.
2154    #[test]
2155    fn resolve_global_symbol_ambiguity_aware_returns_not_found_for_missing_symbol() {
2156        let graph = CodeGraph::new();
2157        let snapshot = graph.snapshot();
2158        let result =
2159            snapshot.resolve_global_symbol_ambiguity_aware("does_not_exist", super::FileScope::Any);
2160        assert_eq!(
2161            result,
2162            Err(super::SymbolResolveError::NotFound {
2163                name: "does_not_exist".to_string(),
2164            })
2165        );
2166    }
2167
2168    /// More than [`super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP`] candidates trips
2169    /// the truncation flag and caps the candidate list at the constant.
2170    #[test]
2171    fn resolve_global_symbol_ambiguity_aware_caps_candidates_at_truncation_limit() {
2172        let mut graph = CodeGraph::new();
2173        let file_path = abs_path("src/main.go");
2174        let total = super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP + 5;
2175
2176        for index in 0..total {
2177            // Vary the qualified name so each candidate is distinct under
2178            // the lexicographic sort used by the materializer.
2179            let qualified = format!("pkg::module_{index:03}::collide");
2180            add_node(
2181                &mut graph,
2182                NodeKind::Function,
2183                "collide",
2184                Some(qualified.as_str()),
2185                &file_path,
2186                Some(Language::Go),
2187                u32::try_from(index + 1).unwrap_or(1),
2188                0,
2189            );
2190        }
2191
2192        let snapshot = graph.snapshot();
2193        let err = snapshot
2194            .resolve_global_symbol_ambiguity_aware("collide", super::FileScope::Any)
2195            .expect_err("collisions across many nodes must surface as Ambiguous");
2196        let super::SymbolResolveError::Ambiguous(payload) = err else {
2197            panic!("expected Ambiguous variant");
2198        };
2199
2200        assert!(payload.truncated, "truncated flag must be set above cap");
2201        assert_eq!(
2202            payload.candidates.len(),
2203            super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP
2204        );
2205        // Stable lex sort: module_000, module_001, ... module_019.
2206        assert_eq!(
2207            payload.candidates[0].qualified_name,
2208            "pkg::module_000::collide"
2209        );
2210    }
2211
2212    /// File-scoped resolver narrows ambiguity to candidates inside the
2213    /// requested file.
2214    #[test]
2215    fn resolve_global_symbol_ambiguity_aware_respects_file_scope() {
2216        let mut graph = CodeGraph::new();
2217        let scope_file = abs_path("src/in_scope.go");
2218        let other_file = abs_path("src/other.go");
2219
2220        let scoped_property = add_node(
2221            &mut graph,
2222            NodeKind::Property,
2223            "Same",
2224            Some("main::Owner::Same"),
2225            &scope_file,
2226            Some(Language::Go),
2227            10,
2228            6,
2229        );
2230        let _outside = add_node(
2231            &mut graph,
2232            NodeKind::Property,
2233            "Same",
2234            Some("main::Other::Same"),
2235            &other_file,
2236            Some(Language::Go),
2237            10,
2238            6,
2239        );
2240
2241        let snapshot = graph.snapshot();
2242        let result = snapshot
2243            .resolve_global_symbol_ambiguity_aware("Same", super::FileScope::Path(&scope_file));
2244        assert_eq!(result, Ok(scoped_property.node_id));
2245    }
2246}