Skip to main content

brink_analyzer/
resolve.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use brink_format::DefinitionId;
4use brink_ir::{
5    Diagnostic, DiagnosticCode, FileId, Import, LocalSymbol, RefKind, ResolutionMap, ResolvedRef,
6    Scope, SymbolIndex, SymbolInfo, SymbolKind, SymbolManifest, Visibility,
7    is_reserved_root_module,
8};
9
10use crate::manifest::local_definition_id;
11
12/// Per-file import coverage, shared verbatim by resolution ([`ImportScope`])
13/// and the E025 import-required checker (`modules::import_covers`): the set
14/// of modules imported **qualified** (`IMPORT mod`, which licenses
15/// `module.name` access to any public export) and the `(module, name)` pairs
16/// brought into scope by **bare** imports (`IMPORT { name } FROM mod`, which
17/// is name-precise — it does *not* license every other export of `mod`).
18///
19/// A single source of truth for this distinction: an earlier version of
20/// `ImportScope` collapsed every import to just its module name, so a bare
21/// `IMPORT { other } FROM mod` was (wrongly) treated as importing *all* of
22/// `mod`, silently disagreeing with `import_covers`'s name-precise gate.
23///
24/// # Dual-reading a bare item's trailing segment (issue #1592)
25///
26/// `use story::market::barter;` lowers to a bare import whose sole item is
27/// `barter` "from" module `story::market` (`lower_native::import`'s
28/// item-is-the-leaf reading) — but Rust's `use`, which charter §13.2
29/// commits to lifting verbatim, dual-reads that trailing segment: `barter`
30/// may be an *item* `story::market` exports, or it may itself name the
31/// **module** `story::market::barter` (a real submodule, licensing
32/// **module-qualified** access to its own public exports — "a trailing
33/// segment that resolves to a module licenses that module, exactly as
34/// Rust's `use` does", per the #1592 ruling, `docs/decision-log.md`
35/// 2026-07-27). Same shape for every entry in a nested list (`use a::{b,
36/// c};` dual-reads `b` and `c` independently, exactly as `use a::b;`
37/// dual-reads `b`).
38///
39/// This is a **per-file** query (`resolve(FileId)`'s incremental contract:
40/// "reads only the symbol index and this file's own manifest — never
41/// another file's content") and has no way to know here whether
42/// `module::name` is a real declared module elsewhere in the project — that
43/// whole-project view exists only in `modules::check`. So both readings are
44/// licensed **unconditionally**: the item pairing is inserted into `bare`
45/// exactly as before, and `module::name` is *also* inserted into
46/// `qualified` as a phantom qualified-module candidate. This is a pure
47/// no-op unless some file's symbol genuinely carries that exact module
48/// name — `classify` only ever matches a *candidate's own* `info.module`
49/// against this set, so an unreal phantom module simply never matches
50/// anything. **Precedence decision (issue #1592, "decide and document"):
51/// both readings apply — there is no exclusion.** They populate disjoint,
52/// non-conflicting sets (`bare` is name-precise; `qualified` is
53/// module-wide), so a name that resolves as *both* an item of `module` and
54/// a module in its own right gets both: the item is bare-importable under
55/// its own name (via `bare`), and the submodule's public exports become
56/// reachable **qualified** — `barter::haggle`, never bare `haggle` (via
57/// `qualified`). This mirrors Rust's own per-namespace `use` semantics
58/// (a module and a value can share a name without conflict) without this
59/// codebase needing to model namespaces explicitly. `modules::check`'s
60/// `E088` is the check that validates *this* file's readings against real
61/// project-wide module/export data and diagnoses when a trailing segment
62/// resolves to **neither**.
63///
64/// ⚠ **Corrected 2026-08-05 (issue #2287), widened 2026-08-22 (issue
65/// #2298).** This doc previously claimed the submodule reading makes its
66/// exports "bare-visible" / "referenceable by bare name" — that was an
67/// over-read of the 2026-07-27 ruling and was itself the reported bug: a
68/// `qualified` entry alone must never license a *bare* reference
69/// (`is_qualified_import_only`, enforced via the shared
70/// `lookup_bare_excluding_qualified_only` helper both `lookup_divert`'s
71/// `Knot`/`Stitch`/`Label`/`Variable`+`Constant` steps and
72/// `resolve_function`'s knot-as-tunnel-function step now go through —
73/// #2287 shipped only the `Knot`-in-a-divert case; #2298 closed the
74/// deliberate remainder). `qualified_modules.contains(module)` still
75/// legitimately grants every *other* reference kind (calls resolving to an
76/// `External`/`Variable`/`Constant`, struct literals, etc.)
77/// `Candidacy::Imported` for their own qualified-syntax forms via the
78/// ordinary flat `classify`/`lookup_by_name` path; nothing about that
79/// changed.
80#[must_use]
81pub(crate) fn import_coverage_for_file(
82    imports: &[Import],
83) -> (BTreeSet<String>, BTreeSet<(&str, &str)>) {
84    let mut qualified = BTreeSet::new();
85    let mut bare = BTreeSet::new();
86    for import in imports {
87        if import.bare {
88            for item in &import.items {
89                bare.insert((import.module.as_str(), item.name.as_str()));
90                // Dual-reading (issue #1592, doc above): `item.name` might
91                // itself name a submodule of `import.module` rather than an
92                // item of it. `::`-joining is native's real module-path
93                // separator (`brink_db::modules::native_module_path`), but
94                // `#@module(...)` places no structural constraint on an ink
95                // module's own name either (it accepts any non-empty
96                // string, `::`-joined or not — see
97                // `modules::known_module_names`'s doc, corrected by the
98                // #1686 review), so this is *not* a structural ink no-op.
99                // It is a no-op only for the corpus this compiler actually
100                // has to stay byte-identical for — no `#@module`/`IMPORT`/
101                // `use` construct appears anywhere in the oracle/tier1
102                // corpus at all (`modules`'s own Compat doc).
103                //
104                // Unconditional on `item.alias`, deliberately: this phantom
105                // candidate is inserted whether or not the item was
106                // aliased, so an aliased trailing segment that resolves to
107                // a module (`use a::b as c;` where `b` is a submodule)
108                // still licenses qualified access to `b`'s exports under
109                // `b`'s *own* name (`b::item`, issue #2287), even though
110                // `c` binds nothing useful. That shape
111                // has no sound alias representation at all (aliasing a
112                // whole module's export set, not one name) and is
113                // diagnosed loudly at the whole-project level instead —
114                // `modules::check`'s `E129` fires when this pass's
115                // `is_module` reading and `item.alias.is_some()` coincide.
116                qualified.insert(format!("{}::{}", import.module, item.name));
117            }
118        } else {
119            qualified.insert(import.module.clone());
120        }
121    }
122    (qualified, bare)
123}
124
125/// Per-file import context threaded into resolution (M-2d,
126/// docs/modules-spec.md §2; issue #790) so a bare reference with multiple
127/// cross-module candidates binds to the module *this file* actually imports —
128/// "names cross module boundaries only via import" — rather than to the flat
129/// duplicate-winner.
130///
131/// The default (empty) scope reproduces the pre-M-2d flat behavior exactly:
132/// the entire strict-ink and single-module world has at most one candidate
133/// per (name, kind), so [`lookup_by_name`]'s fast path returns it unchanged
134/// and this context never influences the result. It only ever disambiguates
135/// the genuinely-new case unlocked by relaxing the #784/#793 stopgap: two
136/// *declared* modules publicly defining the same name, now coexisting in the
137/// index instead of one being suppressed.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct ImportScope {
140    /// The referring file's own **declared** module (`None` for an
141    /// undeclared stem-module / the legacy world). A candidate declared in
142    /// this same module is bare-visible without any import.
143    pub file_module: Option<String>,
144    /// Modules this file imports **qualified** (`IMPORT mod`) — licenses
145    /// `module.name` access to any public export of that module.
146    pub qualified_modules: BTreeSet<String>,
147    /// `(module, name)` pairs this file imports **bare**
148    /// (`IMPORT { name } FROM mod`) — name-precise, matching
149    /// `modules::import_covers` exactly so resolution and the E025
150    /// import-required diagnostic can never diverge. `BTreeSet` for
151    /// determinism. Keyed by the imported item's own (source-module) name
152    /// regardless of any local alias — an aliased import still *covers* its
153    /// source name for cross-module licensing purposes (§2: the file did
154    /// import it), it just isn't the name resolution binds bare (see
155    /// `aliases`).
156    pub bare_imports: BTreeSet<(String, String)>,
157    /// Local alias → `(module, source_name)` for every bare import item that
158    /// named one (`IMPORT { name AS alias } FROM mod` / `use mod::name as
159    /// alias;`, issue #1590). `index.by_name` is keyed by definitions' own
160    /// spellings only, so a plain [`lookup_by_name`] lookup can never find an
161    /// alias — this table is the indirection [`lookup_by_name`] falls back to
162    /// once the direct-name lookup comes up empty. Additive, not
163    /// shadowing: aliasing doesn't revoke the source name's own bare
164    /// visibility (still governed by `bare_imports`/`classify` exactly as
165    /// before) — it only adds a second local spelling for the same import.
166    /// See the doc comment on [`lookup_by_name`] for the alias-vs-original
167    /// licensing ruling. `BTreeMap` for determinism.
168    pub aliases: BTreeMap<String, (String, String)>,
169}
170
171impl ImportScope {
172    /// Build the scope for one file from its resolved (declared) module and
173    /// its HIR `IMPORT` list.
174    #[must_use]
175    pub fn new(file_module: Option<String>, imports: &[Import]) -> Self {
176        let (qualified, bare) = import_coverage_for_file(imports);
177        let mut aliases = BTreeMap::new();
178        for import in imports {
179            if !import.bare {
180                continue;
181            }
182            for item in &import.items {
183                if let Some(alias) = &item.alias {
184                    aliases.insert(alias.clone(), (import.module.clone(), item.name.clone()));
185                }
186            }
187        }
188        Self {
189            file_module,
190            qualified_modules: qualified,
191            bare_imports: bare
192                .into_iter()
193                .map(|(module, name)| (module.to_string(), name.to_string()))
194                .collect(),
195            aliases,
196        }
197    }
198}
199
200/// How a candidate definition relates to the referring file's import scope.
201#[derive(Clone, Copy, PartialEq, Eq)]
202enum Candidacy {
203    /// Bare-visible without an import: the legacy world (`module == None`) or
204    /// a definition in the referrer's own declared module.
205    InScope,
206    /// A public definition in a **declared** module this file imports.
207    Imported,
208    /// Neither — a cross-module definition this file has no line of sight to.
209    Other,
210}
211
212/// Classify a candidate against the referring file's import scope (M-2d).
213fn classify(scope: &ImportScope, info: &SymbolInfo) -> Candidacy {
214    match &info.module {
215        // Undeclared stem-module / legacy soup — always bare-visible, so the
216        // pre-modules corpus is untouched.
217        None => Candidacy::InScope,
218        Some(module) => {
219            if scope.file_module.as_deref() == Some(module.as_str()) {
220                Candidacy::InScope
221            } else if info.visibility == Visibility::Public
222                && (scope.qualified_modules.contains(module)
223                    || scope
224                        .bare_imports
225                        .contains(&(module.clone(), info.name.clone())))
226            {
227                Candidacy::Imported
228            } else {
229                Candidacy::Other
230            }
231        }
232    }
233}
234
235/// Resolve all unresolved references across files.
236///
237/// Per-file concatenation of [`resolve_file`], preserving input file order.
238/// The production orchestrator (`analyze_with_options`) drives the per-file
239/// [`crate::resolve`] query directly; this whole-project wrapper survives as
240/// a test convenience.
241#[cfg(test)]
242pub fn resolve_refs(
243    index: &SymbolIndex,
244    files: &[(FileId, &SymbolManifest)],
245) -> (ResolutionMap, Vec<Diagnostic>) {
246    let mut map = ResolutionMap::new();
247    let mut diagnostics = Vec::new();
248
249    let scope = ImportScope::default();
250    for &(file_id, manifest) in files {
251        let (file_map, file_diags) = resolve_file(index, &scope, file_id, manifest);
252        map.extend(file_map);
253        diagnostics.extend(file_diags);
254    }
255
256    (map, diagnostics)
257}
258
259/// Resolve one file's unresolved references against the project-wide index.
260///
261/// Reads only the symbol index and this file's own manifest — never another
262/// file's content. This is the per-file dependency seam the query pipeline
263/// relies on (substrate spec §4, layer 2 — `resolve(FileId)`).
264///
265/// Local (param/temp) lookups read `manifest.locals` — this file's own
266/// side table — rather than the project-wide index (issue #517): a knot's
267/// body lives in exactly one file, so a local can never legitimately be
268/// declared in one file and referenced from another. Scoping the lookup to
269/// this file's own locals both restores correct behavior for cross-file
270/// duplicate-scoped-locals (slice-A finding 4 — no more merged-index
271/// aliasing) and lets the project-wide `resolution_index` drop locals
272/// entirely, so a body edit that adds/removes a `~ temp` in file Y no
273/// longer invalidates file X's `resolve` memo.
274pub fn resolve_file(
275    index: &SymbolIndex,
276    scope: &ImportScope,
277    file_id: FileId,
278    manifest: &SymbolManifest,
279) -> (ResolutionMap, Vec<Diagnostic>) {
280    let mut map = ResolutionMap::new();
281    let mut diagnostics = Vec::new();
282    let locals = &manifest.locals;
283
284    for uref in &manifest.unresolved {
285        match uref.kind {
286            RefKind::Divert => {
287                resolve_divert(
288                    index,
289                    scope,
290                    locals,
291                    file_id,
292                    uref,
293                    &mut map,
294                    &mut diagnostics,
295                );
296            }
297            RefKind::Variable => {
298                resolve_variable(
299                    index,
300                    scope,
301                    locals,
302                    file_id,
303                    uref,
304                    &mut map,
305                    &mut diagnostics,
306                );
307            }
308            RefKind::Function => {
309                resolve_function(
310                    index,
311                    scope,
312                    locals,
313                    file_id,
314                    uref,
315                    &mut map,
316                    &mut diagnostics,
317                );
318            }
319            RefKind::List => {
320                resolve_list_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
321            }
322            RefKind::Struct => {
323                resolve_struct_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
324            }
325            RefKind::Type => {
326                resolve_type_ref(index, scope, file_id, uref, &mut map);
327            }
328        }
329    }
330
331    (map, diagnostics)
332}
333
334fn resolve_divert(
335    index: &SymbolIndex,
336    scope: &ImportScope,
337    locals: &[LocalSymbol],
338    file_id: FileId,
339    uref: &brink_ir::UnresolvedRef,
340    map: &mut ResolutionMap,
341    diagnostics: &mut Vec<Diagnostic>,
342) {
343    if let Some(id) = lookup_divert(index, scope, locals, uref) {
344        map.push(ResolvedRef {
345            file: file_id,
346            range: uref.range,
347            target: id,
348        });
349        check_divert_arity(index, file_id, uref, id, diagnostics);
350    } else {
351        diagnostics.push(unresolved_diag(
352            index,
353            scope,
354            file_id,
355            uref.range,
356            &uref.path,
357            DiagnosticCode::E024,
358            &[
359                SymbolKind::Knot,
360                SymbolKind::Stitch,
361                SymbolKind::Label,
362                SymbolKind::Variable,
363                SymbolKind::Constant,
364            ],
365        ));
366    }
367}
368
369/// Check a divert-with-args site's argument count against its resolved
370/// target's declared parameter count (`E176`, issue #2156) — `E031`'s
371/// sibling for the divert call shape (`-> knot(args)`, a tunnel call, or a
372/// thread-start), extended to a construct `check_arity` never covered:
373/// `RefKind::Divert` refs always carried `arg_count: None` until this issue
374/// (`brink_ir::symbols::project::Projector::walk_divert_target`), so
375/// `check_arity` — gated on `arg_count.is_some()` — could never fire for a
376/// divert on either dialect regardless of how many arguments were given.
377///
378/// Scoped to a resolution naming a `Knot`/`Stitch`/`Label` — the only
379/// symbol kinds with their own declared parameter row — mirroring
380/// `resolve_function`'s own `check_arity` call sites, which likewise check
381/// only `External`/`Knot` resolutions and skip `Variable`/local ones. A
382/// divert resolving to a `Variable` (`-> x` where `x` holds a stored divert
383/// target, e.g. `docs/…/WritingWithInk.md`'s "Advanced: sending divert
384/// targets as parameters") or to a divert-typed local `Param` (`-> return_to`
385/// inside `=== knot(-> return_to) ===`) is an indirection whose real
386/// target's arity is not known statically at this site — `index.symbols`
387/// has no declared parameter row for either kind, so checking against it
388/// would misfire on legitimate code instead of silently doing nothing
389/// useful.
390fn check_divert_arity(
391    index: &SymbolIndex,
392    file_id: FileId,
393    uref: &brink_ir::UnresolvedRef,
394    target: DefinitionId,
395    diagnostics: &mut Vec<Diagnostic>,
396) {
397    let Some(call_arg_count) = uref.arg_count else {
398        return;
399    };
400    let Some(info) = index.symbols.get(&target) else {
401        return;
402    };
403    if !matches!(
404        info.kind,
405        SymbolKind::Knot | SymbolKind::Stitch | SymbolKind::Label
406    ) {
407        return;
408    }
409    let expected = info.params.len();
410    if call_arg_count != expected {
411        diagnostics.push(Diagnostic {
412            file: file_id,
413            range: uref.range,
414            message: format!(
415                "{}: `{}` expects {} argument(s), got {}",
416                DiagnosticCode::E176.title(),
417                uref.path,
418                expected,
419                call_arg_count,
420            ),
421            code: DiagnosticCode::E176,
422        });
423    }
424}
425
426fn lookup_divert(
427    index: &SymbolIndex,
428    scope: &ImportScope,
429    locals: &[LocalSymbol],
430    uref: &brink_ir::UnresolvedRef,
431) -> Option<DefinitionId> {
432    let path = &uref.path;
433
434    // Module-qualified (`-> barter::haggle`, issue #2287): resolved
435    // entirely separately from ink's own dotted `knot.stitch` addressing
436    // below — a module qualifier licenses reaching a top-level flow through
437    // an imported *module*, never a stitch/label path, and a miss here must
438    // not fall through to the single-segment resolution below (that
439    // fallback treating the *whole* qualified path as an opaque bare name
440    // is exactly how issue #2287's bug (b) let `-> haggle` slip through
441    // after the qualified form failed).
442    if uref.module_qualified {
443        return lookup_qualified_divert(index, scope, path);
444    }
445
446    // Dotted path — try exact qualified lookup, then qualify with current knot
447    if path.contains('.') {
448        if let Some(id) =
449            lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
450        {
451            return Some(id);
452        }
453        // Try qualifying with current knot scope (e.g., `a_package.forest` → `adventure.a_package.forest`)
454        if let Some(knot) = &uref.scope.knot {
455            let qualified = format!("{knot}.{path}");
456            if let Some(id) = lookup_by_name(
457                index,
458                scope,
459                &qualified,
460                &[SymbolKind::Stitch, SymbolKind::Label],
461            ) {
462                return Some(id);
463            }
464        }
465        return None;
466    }
467
468    // Single segment — ink's hierarchical resolution:
469    // 1. Stitch or label in current knot
470    if let Some(knot) = &uref.scope.knot {
471        let qualified = format!("{knot}.{path}");
472        if let Some(id) = lookup_by_name(
473            index,
474            scope,
475            &qualified,
476            &[SymbolKind::Stitch, SymbolKind::Label],
477        ) {
478            return Some(id);
479        }
480        // Label in current stitch (knot.stitch.label)
481        if let Some(stitch) = &uref.scope.stitch
482            && let Some(id) = lookup_by_name(
483                index,
484                scope,
485                &format!("{knot}.{stitch}.{path}"),
486                &[SymbolKind::Label],
487            )
488        {
489            return Some(id);
490        }
491    }
492
493    // 2. Knot at top level — a *bare* divert name (no qualifier segment at
494    // all in source) must resolve only to a same-module candidate or one a
495    // symbol-level/glob `use` actually named (issue #2287): unlike the flat
496    // `lookup_by_name`/`classify` most other reference kinds use (which
497    // also treats a **qualified**-module import as licensing bare access,
498    // since e.g. a function-call callee may itself be spelled either way),
499    // a bare divert has no qualifier to license through — a candidate
500    // reachable only via a qualified-module import must stay unresolved
501    // here, or `-> haggle` would silently accept exactly the over-permissive
502    // reading #2287 reported as bug (b).
503    if let Some(id) = lookup_knot_bare(index, scope, path) {
504        return Some(id);
505    }
506
507    // 3. Top-level stitch (bare name, no parent knot). Issue #2298: same
508    // `is_qualified_import_only` exclusion as step 2, via the kind-generic
509    // [`lookup_bare_excluding_qualified_only`] — a stitch reachable only
510    // through a qualified-module import must not resolve a bare divert any
511    // more than a knot may. Currently unreachable for native (a `flow`
512    // always classifies `SymbolKind::Knot`, never `Stitch`, so no
513    // top-level-stitch candidate can carry a real cross-module `module` for
514    // this exclusion to fire against) — latent, not dead: fixed here
515    // alongside steps 5–6 so the whole post-knot tail obeys one rule
516    // instead of the Knot-only gate #2296 shipped, per issue #2298's scope.
517    if let Some(id) =
518        lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Stitch])
519    {
520        return Some(id);
521    }
522
523    // 4. Label anywhere in current knot (search by suffix). Deliberately
524    // untouched by issue #2298: `knot` here is always `uref.scope.knot`,
525    // i.e. the *referring file's own* currently-open knot — a stitch or
526    // label found this way is always declared alongside it in the same
527    // file, hence the same declared module, hence `Candidacy::InScope`
528    // regardless of any import. There is no cross-module reading for
529    // `lookup_label_in_knot` to leak through, so the exclusion has nothing
530    // to guard here.
531    if let Some(knot) = &uref.scope.knot
532        && let Some(id) = lookup_label_in_knot(index, scope, knot, path)
533    {
534        return Some(id);
535    }
536
537    // 5. Top-level label — stored as bare name (visible from any scope).
538    // Issue #2298: same reasoning as step 3.
539    if let Some(id) = lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Label])
540    {
541        return Some(id);
542    }
543
544    // 6. Variable divert target (`VAR x = -> knot`, then `-> x`). Issue
545    // #2298: same `is_qualified_import_only` exclusion as steps 2–3/5, via
546    // the shared kind-generic lookup — plus the `SymbolKind::Constant`
547    // omission recorded on issue #2083's thread (crediting that report):
548    // `resolve_function`'s own Variable-divert-target-shaped lookup (its
549    // "Try variables and constants" step) was widened to `[Variable,
550    // Constant]` by #2947 for the call-site gap #2083 reported, but this
551    // divert-target step was left `Variable`-only as a "next-wave
552    // candidate" — a `CONST x = -> knot` could never be diverted to via
553    // `-> x` even though the call-site twin already accepts a const-bound
554    // value. Fixed here in the same pass as the exclusion, not reordered
555    // to locals-first the way #2947 reordered `resolve_function`: step 7
556    // below already runs after this one, so — as for the `Variable` kind
557    // this step has always matched — a global `CONST x = -> knot` now wins
558    // over a same-named local param at a divert site, where before this
559    // widening the local won by falling through to step 7. That shadow
560    // order is deliberate and pinned by
561    // `global_constant_divert_target_shadows_a_same_named_local` below.
562    // Attribution: issue #2298's own body names only Stitch/Label/Variable
563    // at this step — the `Constant` addition is issue #2083's thread's
564    // next-wave candidate (its 2026-08-21 comment), taken in the same
565    // pass, not something #2298 asked for.
566    if let Some(id) = lookup_bare_excluding_qualified_only(
567        index,
568        scope,
569        path,
570        &[SymbolKind::Variable, SymbolKind::Constant],
571    ) {
572        return Some(id);
573    }
574
575    // 7. Divert parameter in scope (`=== knot(-> x) ===` then `-> x`)
576    lookup_local_in_scope(locals, path, &uref.scope)
577}
578
579/// Is `info` reachable *only* through a **qualified-module** import
580/// (`use ...::mod;` / `import mod;`, `scope.qualified_modules`) with no
581/// corresponding bare (symbol-level/glob) import of this exact name (issue
582/// #2287)? A qualified-module import licenses `module::name`/`module.name`
583/// access to any public export — [`classify`]'s `Candidacy::Imported`
584/// correctly grants that for reference kinds whose own syntax can carry a
585/// qualifier — but it must never *also* license the bare `name` spelling,
586/// which only a symbol-level or glob import brings into scope. `false` for
587/// a candidate with no module at all (the legacy/undeclared-module world,
588/// always bare-visible) and for one already bare-imported (both readings
589/// can hold at once per #1592's dual-reading ruling; the bare one still
590/// wins).
591fn is_qualified_import_only(scope: &ImportScope, info: &SymbolInfo) -> bool {
592    let Some(module) = &info.module else {
593        return false;
594    };
595    info.visibility == Visibility::Public
596        && scope.qualified_modules.contains(module)
597        && !scope
598            .bare_imports
599            .contains(&(module.clone(), info.name.clone()))
600}
601
602/// Bare (single-segment, no qualifier at all in source) top-level-`Knot`
603/// divert lookup (issue #2287). Deliberately duplicates
604/// [`lookup_by_name_direct`]'s own fast-path/tie-break structure — same
605/// std-reserved-root skip, same "`!multiple` sole match wins regardless of
606/// candidacy" byte-identity guarantee that lets a totally *unimported*
607/// cross-module `Knot` still resolve here exactly as before (its specific
608/// diagnostic — `E087` private-cross-module or `E025` import-required — is
609/// `modules::check`'s separate, more precise gate to raise on the
610/// resulting `ResolvedRef`, not this function's job to pre-empt) — with
611/// exactly one new exclusion, structurally parallel to the std skip: a
612/// candidate reachable *only* via a qualified-module import
613/// ([`is_qualified_import_only`]) is skipped before it can ever win, since
614/// a module import must never license the bare spelling (bug (b)). Not a
615/// thin wrapper over `lookup_by_name`/`classify` because that fast path
616/// applies its own exclusion **before** counting a candidate into
617/// `first_match`/`multiple`, exactly where this one must too — bolting the
618/// exclusion on afterward would still let a qualified-only candidate win
619/// as the "sole" match.
620///
621/// Issue #2298 widened this beyond diverts: `resolve_function`'s "try
622/// knots" step (ink allows a knot as a function via tunnels — the same
623/// divert-shaped addressing space, unlike a call resolving to an
624/// `External`/`Variable`/`Constant`, which stays on the ordinary
625/// `classify`-based rule per this module's top-of-file doc) reproduced bug
626/// (b) for a bare *call* `haggle()`, so it now shares this exact lookup
627/// rather than drifting its own copy. Thin wrapper over
628/// [`lookup_bare_excluding_qualified_only`], kept as its own named function
629/// (rather than inlining `&[SymbolKind::Knot]` at both call sites) because
630/// this name is what the doc comments elsewhere in this file point readers
631/// at.
632fn lookup_knot_bare(index: &SymbolIndex, scope: &ImportScope, name: &str) -> Option<DefinitionId> {
633    lookup_bare_excluding_qualified_only(index, scope, name, &[SymbolKind::Knot])
634}
635
636/// The kind-generic form of [`lookup_knot_bare`] (issue #2298): the same
637/// bare, qualified-import-only-excluding lookup, parameterized over the
638/// symbol-kind set so [`lookup_divert`]'s later steps (`Stitch`/`Label`/
639/// `Variable`+`Constant`) can share this one implementation too, instead of
640/// each hand-copying [`lookup_knot_bare_direct`]'s structure the way that
641/// function itself once hand-copied [`lookup_by_name_direct`]'s.
642fn lookup_bare_excluding_qualified_only(
643    index: &SymbolIndex,
644    scope: &ImportScope,
645    name: &str,
646    kinds: &[SymbolKind],
647) -> Option<DefinitionId> {
648    if let Some(id) = lookup_bare_excluding_qualified_only_direct(index, scope, name, kinds) {
649        return Some(id);
650    }
651    // Alias fallback (issue #1590), mirroring `lookup_by_name`'s own: an
652    // aliased bare-import item (`use ...::haggle as h;`) is never in
653    // `index.by_name` under its local alias, so the direct lookup above
654    // can never find it — `scope.aliases` is the indirection. The alias
655    // entry's mere presence already proves this file bare-imported that
656    // exact `(module, source_name)` pair (`ImportScope::new` only ever
657    // populates it from a bare import item that named an alias), so no
658    // further candidacy check is needed here, exactly as `lookup_by_name`
659    // performs none either.
660    let (module, source_name) = scope.aliases.get(name)?;
661    let ids = index.by_name.get(source_name.as_str())?;
662    ids.iter().find_map(|id| {
663        let info = index.symbols.get(id)?;
664        (kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
665            .then_some(*id)
666    })
667}
668
669/// The direct (non-alias) half of [`lookup_bare_excluding_qualified_only`] —
670/// see that function's own doc, and [`lookup_knot_bare`]'s original doc, for
671/// why this duplicates [`lookup_by_name_direct`]'s structure rather than
672/// reusing it.
673fn lookup_bare_excluding_qualified_only_direct(
674    index: &SymbolIndex,
675    scope: &ImportScope,
676    name: &str,
677    kinds: &[SymbolKind],
678) -> Option<DefinitionId> {
679    let ids = index.by_name.get(name)?;
680    let mut first_match = None;
681    let mut first_in_scope = None;
682    let mut first_imported = None;
683    let mut multiple = false;
684    for id in ids {
685        let Some(info) = index.symbols.get(id) else {
686            continue;
687        };
688        if !kinds.contains(&info.kind) {
689            continue;
690        }
691        let candidacy = classify(scope, info);
692        if candidacy == Candidacy::Other
693            && info.module.as_deref().is_some_and(is_reserved_root_module)
694        {
695            continue;
696        }
697        if is_qualified_import_only(scope, info) {
698            continue;
699        }
700        if first_match.is_none() {
701            first_match = Some(*id);
702        } else {
703            multiple = true;
704        }
705        match candidacy {
706            Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
707            Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
708            _ => {}
709        }
710    }
711    if !multiple {
712        return first_match;
713    }
714    first_in_scope.or(first_imported).or(first_match)
715}
716
717/// Does `module` (a candidate's real, fully `::`-joined declared module
718/// name) match a divert's own written qualifier segment(s) — the prefix of
719/// `-> qualifier::name` before the final `::`? Exact match covers the
720/// common single-level case (`barter` naming module `story::market::barter`
721/// end-to-end would require `qualifier == module`); the suffix form covers
722/// a qualifier that only spells the module's own trailing component(s),
723/// the shape `use story::market::barter;`'s dual-reading licenses (issue
724/// #1592) and issue #2287's own repro exercises.
725fn module_matches_qualifier(module: &str, qualifier: &str) -> bool {
726    module == qualifier || module.ends_with(&format!("::{qualifier}"))
727}
728
729/// Module-qualified divert lookup (`-> barter::haggle`, issue #2287):
730/// `path` is `uref.path` when `uref.module_qualified` is set —
731/// `::`-joined, never `.`-joined, so it splits cleanly into the qualifier
732/// prefix and the bare target name. Resolves only against a top-level
733/// `Knot` (the sole symbol kind a native `flow` divert target names) whose
734/// real declared module matches the qualifier
735/// ([`module_matches_qualifier`]) and which this file imported *qualified*
736/// (`scope.qualified_modules` — a symbol-level/glob import does not carry
737/// this licensing power for the qualified spelling, mirroring
738/// [`classify`]'s own module-qualified-import reading).
739fn lookup_qualified_divert(
740    index: &SymbolIndex,
741    scope: &ImportScope,
742    path: &str,
743) -> Option<DefinitionId> {
744    let (qualifier, name) = path.rsplit_once("::")?;
745    let ids = index.by_name.get(name)?;
746    ids.iter().find_map(|id| {
747        let info = index.symbols.get(id)?;
748        if info.kind != SymbolKind::Knot || info.visibility != Visibility::Public {
749            return None;
750        }
751        let module = info.module.as_deref()?;
752        if !module_matches_qualifier(module, qualifier) {
753            return None;
754        }
755        scope.qualified_modules.contains(module).then_some(*id)
756    })
757}
758
759fn resolve_variable(
760    index: &SymbolIndex,
761    scope: &ImportScope,
762    locals: &[LocalSymbol],
763    file_id: FileId,
764    uref: &brink_ir::UnresolvedRef,
765    map: &mut ResolutionMap,
766    diagnostics: &mut Vec<Diagnostic>,
767) {
768    let path = &uref.path;
769
770    match lookup_variable(index, scope, locals, uref) {
771        VarResult::Found(id) => {
772            map.push(ResolvedRef {
773                file: file_id,
774                range: uref.range,
775                target: id,
776            });
777        }
778        VarResult::Ambiguous => {
779            diagnostics.push(ambiguous_diag(file_id, uref.range, path));
780        }
781        VarResult::NotFound => {
782            // NS-A1 (`docs/stdlib-spec.md` §1.4): an otherwise-unresolved
783            // bare `none` is the brink-dialect Option absence literal —
784            // same "skip resolution, no diagnostic here" treatment as the
785            // T1b stdlib call names in `resolve_function`. Every user
786            // symbol interpretation above wins first (a LIST item, VAR,
787            // temp, … named `none` shadows the literal, E035-warned at its
788            // declaration); `strict-ink` rejection is the dialect gate's
789            // job, and the bare-`none`-needs-context declaration rule is
790            // E107 (`option_rules`).
791            if path == "none" {
792                return;
793            }
794
795            // Issue #2856 point 3: `is_builtin_function` is checked here —
796            // as a post-lookup fallback — not before `lookup_variable`
797            // runs, so a declared `VAR`/list item/knot/… of the same name
798            // (`manifest.rs`'s `E035` "name shadows a built-in function"
799            // warning already documents this as legal, shadowing behavior)
800            // wins resolution first. Only a genuinely unresolved reference
801            // to one of these classic uppercase ink intrinsics
802            // (`TURNS_SINCE`/`RANDOM`/…) falls through to this silent skip
803            // — mirroring `is_t1b_stdlib_name`'s already-correct ordering
804            // below in `resolve_function`. Before this fix the check ran
805            // unconditionally first, so a declared symbol could never
806            // shadow it: `VAR RANDOM = 42` / `{RANDOM}` silently dropped
807            // the reference (no resolution, no diagnostic) instead of
808            // reading 42 — reproduced end-to-end via `brink-cli`.
809            if is_builtin_function(path) {
810                return;
811            }
812            diagnostics.push(unresolved_diag(
813                index,
814                scope,
815                file_id,
816                uref.range,
817                path,
818                DiagnosticCode::E025,
819                &[],
820            ));
821        }
822    }
823}
824
825enum VarResult {
826    Found(DefinitionId),
827    Ambiguous,
828    NotFound,
829}
830
831/// Hierarchical variable lookup — returns the first match in priority order.
832fn lookup_variable(
833    index: &SymbolIndex,
834    scope: &ImportScope,
835    locals: &[LocalSymbol],
836    uref: &brink_ir::UnresolvedRef,
837) -> VarResult {
838    let path = &uref.path;
839
840    // 1. Locals (params/temps) in scope — they shadow globals
841    if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
842        return VarResult::Found(id);
843    }
844
845    // 2. Global variables / constants
846    if let Some(id) = lookup_by_name(
847        index,
848        scope,
849        path,
850        &[SymbolKind::Variable, SymbolKind::Constant],
851    ) {
852        return VarResult::Found(id);
853    }
854
855    // 3. List items by bare name
856    match lookup_list_item_bare(index, path) {
857        BareItemResult::Unique(id) => return VarResult::Found(id),
858        BareItemResult::Ambiguous => return VarResult::Ambiguous,
859        BareItemResult::NotFound => {}
860    }
861
862    // 4. Qualified list item (ListName.ItemName)
863    if path.contains('.')
864        && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
865    {
866        return VarResult::Found(id);
867    }
868
869    // 5. List names
870    if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
871        return VarResult::Found(id);
872    }
873
874    // 6. Knots and top-level stitches (visit counts)
875    if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Knot, SymbolKind::Stitch]) {
876        return VarResult::Found(id);
877    }
878
879    // 7. Stitches in current knot scope
880    if let Some(knot) = &uref.scope.knot
881        && let Some(id) = lookup_by_name(
882            index,
883            scope,
884            &format!("{knot}.{path}"),
885            &[SymbolKind::Stitch],
886        )
887    {
888        return VarResult::Found(id);
889    }
890
891    // 8. Qualified stitch/label (e.g. `knot.stitch` or `knot.stitch.label` visit count)
892    if path.contains('.') {
893        if let Some(id) =
894            lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
895        {
896            return VarResult::Found(id);
897        }
898        // Try `knot.label` where label is stored as `knot.*.label` (label inside a stitch)
899        if let Some((knot, label)) = path.split_once('.')
900            && !label.contains('.')
901            && let Some(id) = lookup_label_in_knot(index, scope, knot, label)
902        {
903            return VarResult::Found(id);
904        }
905    }
906
907    // 9. Labels in current knot
908    if let Some(knot) = &uref.scope.knot
909        && let Some(id) = lookup_label_in_knot(index, scope, knot, path)
910    {
911        return VarResult::Found(id);
912    }
913
914    // 10. Labels at top level (no knot scope)
915    if uref.scope.knot.is_none()
916        && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Label])
917    {
918        return VarResult::Found(id);
919    }
920
921    // 11. TM-4b resolution fallback (docs/typed-mode-spec.md §6): every
922    // static dotted-path interpretation above (steps 3-10: list items,
923    // lists, knots/stitches, labels — "ink's static dotted paths... resolved
924    // first and win") has failed. If the path has more than one segment and
925    // its *head* segment alone resolves to a local (param/temp) or a global
926    // variable/constant, this is field access on that variable
927    // (`p.x`/`p.x.y`) rather than an unresolved static path — the resolved
928    // target is the head variable itself; the trailing segment(s) are field
929    // names carried structurally by the HIR `Path`, not by this resolution.
930    // Struct field-name validity (does `x` exist on `p`'s declared shape?) is
931    // a separate construction-time concern (`brink-analyzer::structs`), not
932    // resolution's. A single-segment path can never reach here having
933    // already failed steps 1-2 above (which already check locals/globals for
934    // the *whole* path), so this never fires for a bare variable reference.
935    if let Some((head, _rest)) = path.split_once('.') {
936        if let Some(id) = lookup_local_in_scope(locals, head, &uref.scope) {
937            return VarResult::Found(id);
938        }
939        if let Some(id) = lookup_by_name(
940            index,
941            scope,
942            head,
943            &[SymbolKind::Variable, SymbolKind::Constant],
944        ) {
945            return VarResult::Found(id);
946        }
947    }
948
949    VarResult::NotFound
950}
951
952/// Resolve a call-path reference (`RefKind::Function`).
953///
954/// **Range contract (issue #1561):** every `ResolvedRef` pushed below
955/// carries `range: uref.range` unchanged — never narrowed to a sub-segment
956/// (receiver-only, method-only). By construction (`brink_ir::symbols::
957/// project::Projector::walk_expr`'s `Expr::Call` arm) `uref.range` is
958/// already the callee `Path`'s own whole span, so this function's only
959/// obligation is to *not disturb it*. That whole-path range is the exact
960/// `(FileId, TextRange)` lookup key `lir::lower::expr::lower_call` and
961/// `ufcs_receiver_path`, `strict::check_void_root`, `coalesce`'s
962/// operand classifier, `ufcs::value_receiver_def`, and
963/// `infer::body::infer_call` all independently key on — see
964/// [`brink_ir::ResolvedRef::range`]'s doc for the full consumer list and
965/// the cross-layer regression test. A narrower range here (e.g. to support
966/// a rename edit) is a silent miscompile for all four; narrowing for a
967/// rename belongs at `brink-ide`'s own consumption layer instead
968/// (`ufcs_hover`'s segment-narrowing helpers are the established pattern —
969/// see #1550/#1554).
970fn resolve_function(
971    index: &SymbolIndex,
972    scope: &ImportScope,
973    locals: &[LocalSymbol],
974    file_id: FileId,
975    uref: &brink_ir::UnresolvedRef,
976    map: &mut ResolutionMap,
977    diagnostics: &mut Vec<Diagnostic>,
978) {
979    let path = &uref.path;
980
981    // Try externals first
982    if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::External]) {
983        map.push(ResolvedRef {
984            file: file_id,
985            range: uref.range,
986            target: id,
987        });
988        check_arity(index, file_id, uref, id, diagnostics);
989        return;
990    }
991
992    // Try knots (ink allows knots as functions via tunnels). Issue #2298:
993    // this is `lookup_knot_bare`, not the flat `lookup_by_name`/`classify`
994    // every other lookup in this function uses — a knot-as-tunnel-function
995    // call sits in the same divert-shaped addressing space `-> knot`
996    // itself does (`lookup_divert`'s step 2), so a bare call `haggle()`
997    // after only a module-qualified import (`use story::market::barter;`,
998    // no symbol-level import of `haggle`) must be rejected exactly like
999    // the bare divert `-> haggle` already is (issue #2287 bug (b)) —
1000    // before this fix, this step's flat lookup let `Candidacy::Imported`
1001    // from the qualified-module reading win regardless, reproducing bug
1002    // (b) for calls instead of diverts. The `External`/`List`/
1003    // `Variable`+`Constant` lookups below stay on the ordinary
1004    // `classify`-based rule deliberately: they are not divert-shaped, and
1005    // this module's top-of-file doc's "calls, struct literals, etc." carve-
1006    // out still holds for them — only a knot's own tunnel-call addressing
1007    // is special-cased here.
1008    if let Some(id) = lookup_knot_bare(index, scope, path) {
1009        map.push(ResolvedRef {
1010            file: file_id,
1011            range: uref.range,
1012            target: id,
1013        });
1014        check_arity(index, file_id, uref, id, diagnostics);
1015        return;
1016    }
1017
1018    // A real **call site** (`arg_count.is_some()`) naming a reserved builtin
1019    // (`is_builtin_function`'s classic uppercase intrinsics or
1020    // `is_t1b_stdlib_name`'s T1b verbs) must not let a `List`/`Variable`
1021    // symbol of that same name claim the call below — issue #2856 review:
1022    // `VAR MAX = 10` + `{MAX(1, 2)}` compiled clean and then died at
1023    // runtime with `RuntimeError::NotCallable("int")`, because the
1024    // unconditional `SymbolKind::Variable` lookup just below had no
1025    // call-site guard, unlike the list-item bare-name gate a few lines down
1026    // (`arg_count.is_none()`, issue #2830) that this mirrors. A `VAR`/`LIST`
1027    // is not itself callable the way a knot or a variable holding a divert
1028    // target is — routing a reserved-name call through to the real builtin
1029    // fallback below (`recognize_builtin`/`lower_t1b_stdlib_call`) is a
1030    // clean compile with well-defined behavior, exactly matching this same
1031    // name's pre-#2856 behavior, instead of a `CallVariable`/`ListFromInt`
1032    // emitted against a value that was never meant to be called. Read-site
1033    // shadowing (`resolve_variable`'s bare `{MAX}`) is untouched — this
1034    // guard only narrows the *callee* lookup at a call site.
1035    let reserved_call_site =
1036        uref.arg_count.is_some() && (is_builtin_function(path) || is_t1b_stdlib_name(path));
1037
1038    // Try list names (ink allows `list(n)` as type conversion)
1039    if !reserved_call_site && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List])
1040    {
1041        map.push(ResolvedRef {
1042            file: file_id,
1043            range: uref.range,
1044            target: id,
1045        });
1046        return;
1047    }
1048
1049    // Try locals (temps/params used as function names, e.g. `{storyletFunction(args)}`)
1050    // — checked BEFORE the global variable/constant lookup below, so a local
1051    // (param/temp/`let`) shadows a same-named global at a call site exactly
1052    // as it does at a bare-read site (`lookup_variable`'s step 1) and at a
1053    // dotted callee's head (the B3a arm below:
1054    // `lookup_local_in_scope(..).or_else(|| lookup_by_name(.., [Variable,
1055    // Constant]))`). Before #2083's fix this arm ran *after* the global
1056    // lookup, so a global could shadow a local at a call site while a bare
1057    // read of the same name resolved the local — one name calling one symbol
1058    // and reading another.
1059    //
1060    // ⚠ issue #2867 (open, unruled): unlike the `List` arm above and the
1061    // `Variable`/`Constant` arm below, this lookup is NOT covered by
1062    // `reserved_call_site`. Per
1063    // `push_local`'s own call sites (`brink_ir::symbols::project`),
1064    // `lookup_local_in_scope` can only ever return a `LocalSymbol` of kind
1065    // `SymbolKind::Param` (knot/stitch/function params) or
1066    // `SymbolKind::Temp` (`~ temp`, `for`-loop bindings, lambda params) —
1067    // that is the complete enumeration of kinds this arm can claim a call
1068    // site with. Neither kind is gated: both unconditionally claim this
1069    // arm today. `~ temp MAX = 10` + `{MAX(1, 2)}` under strict-ink compiles clean
1070    // (no `E035`, no `E183`) and then faults at runtime with
1071    // `RuntimeError::NotCallable("int")` — no diagnostic of any kind,
1072    // unlike the sibling `VAR`/`List` cases, which fall through to the
1073    // real builtin instead. Pinned (not yet fixed) by
1074    // `crates/brink-compiler/tests/issue_2856_builtin_shadow.rs`'s
1075    // `local_named_builtin_faults_at_runtime_not_callable` (temp) and
1076    // `param_named_builtin_faults_at_runtime_not_callable` (param). Fixing
1077    // this is a maintainer ruling (issue #2867's "Ask"): gate locals like
1078    // `VAR`/`List` so the call falls through to the real builtin, or
1079    // refuse with a compile diagnostic naming the local and its type —
1080    // deliberately not decided here.
1081    if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
1082        map.push(ResolvedRef {
1083            file: file_id,
1084            range: uref.range,
1085            target: id,
1086        });
1087        return;
1088    }
1089
1090    // Try variables and constants (ink allows calling a variable holding a
1091    // function ref; the native surface additionally allows a `const` to hold
1092    // one — #1862's bare-name form and #1774's lambda-literal decl default,
1093    // docs/t1c-spec.md §2a). `resolve_variable`'s own bare-read lookup
1094    // (`lookup_variable` above) searches `[Variable, Constant]` together,
1095    // with locals shadowing both (its step 1) — this call-site lookup now
1096    // mirrors both halves of that shape: the same kind list, in the same
1097    // locals-first order (see the arm above). It had been left
1098    // `Variable`-only, so a CONST-bound fn value's call site could never
1099    // resolve here — issue #2083's root cause. Root-caused to this one-line
1100    // gap in `brink-analyzer` itself (confirmed to reproduce identically via
1101    // a direct `brink_analyzer::resolve`/`analyze()` call, with no
1102    // `brink-db` involved at all — the issue's own suspicion that this was
1103    // an incremental-resolution bug in `brink-db`'s `resolve_query` was a
1104    // misdiagnosis: the brink-ir test cited as clean-path evidence never
1105    // actually asserted on `analyze()`'s resolution diagnostics, only on
1106    // the declaring global's own lowered default shape).
1107    if !reserved_call_site
1108        && let Some(id) = lookup_by_name(
1109            index,
1110            scope,
1111            path,
1112            &[SymbolKind::Variable, SymbolKind::Constant],
1113        )
1114    {
1115        map.push(ResolvedRef {
1116            file: file_id,
1117            range: uref.range,
1118            target: id,
1119        });
1120        return;
1121    }
1122
1123    // Bare list item name colliding with a stdlib verb (issue #2830): a
1124    // `RefKind::Function` reference whose path names a real declared list
1125    // item (e.g. `pop`, when some `LIST` declares an item literally called
1126    // `pop`) must resolve to that item — an author-declared symbol always
1127    // shadows a same-named stdlib builtin, exactly as `resolve_variable`'s
1128    // `lookup_variable` step 3 already does for `RefKind::Variable` refs.
1129    // Without this step, `is_t1b_stdlib_name` below silently claims the
1130    // name first (it has no way to know a real symbol exists) and the ref
1131    // is dropped with neither a resolution nor a diagnostic — the
1132    // `completeness` proptest counterexample this fixes.
1133    //
1134    // `arg_count.is_none()` restricts this to a `#fn(target)` literal site
1135    // (project_manifest's documented distinction — `#fn` binds a *prefix*
1136    // of the param row, so it never carries a call arity), the same
1137    // discriminator the UFCS branch below already uses. A real **call
1138    // site** (`arg_count.is_some()`, e.g. `push(arr, 5)`) must keep falling
1139    // through to `is_t1b_stdlib_name` and the t1b stdlib call lowering —
1140    // resolving it to the list item here would make a stdlib call compile
1141    // clean and then fault at runtime with `UnresolvedDefinition` when LIR
1142    // lowering looks it up as a stdlib verb instead of a list item.
1143    if uref.arg_count.is_none() {
1144        match lookup_list_item_bare(index, path) {
1145            BareItemResult::Unique(id) => {
1146                map.push(ResolvedRef {
1147                    file: file_id,
1148                    range: uref.range,
1149                    target: id,
1150                });
1151                return;
1152            }
1153            BareItemResult::Ambiguous => {
1154                diagnostics.push(ambiguous_diag(file_id, uref.range, path));
1155                return;
1156            }
1157            BareItemResult::NotFound => {}
1158        }
1159    }
1160
1161    // T1b stdlib slice 1 (docs/t1b-surface-spec.md §5): `len`/`keys`/
1162    // `values`/`contains`/`push`/`insert`/`remove`/`remove_at` with no
1163    // matching user symbol are the brink-dialect builtins, handled at LIR
1164    // lowering —
1165    // same "skip resolution, no diagnostic here" treatment as
1166    // `is_builtin_function` below. Dialect-agnostic at this layer (an
1167    // author-defined symbol of the same name always wins regardless of
1168    // dialect, matched by the lookups above before this is reached);
1169    // `strict-ink` rejection of an unresolved use is a separate diagnostic
1170    // (`brink-analyzer::dialect_gate`, which — unlike this resolution pass —
1171    // does know the dialect).
1172    //
1173    // Issue #2856 point 3: `is_builtin_function` (the classic uppercase ink
1174    // intrinsics — `TURNS_SINCE`/`RANDOM`/…) is checked here too, alongside
1175    // `is_t1b_stdlib_name`, rather than unconditionally before every lookup
1176    // above as it was before this fix. `manifest.rs`'s `E035` ("name
1177    // shadows a built-in function") already documents both name sets as
1178    // author-shadowable with a warning, worded identically for each — but
1179    // only `is_t1b_stdlib_name` actually honored that here; the
1180    // `is_builtin_function` check ran first and unconditionally, so a
1181    // declared external/knot/list/variable/local of the same name was
1182    // never consulted at a *call* site either. Confirmed end-to-end via
1183    // `brink-cli`: without this fix a real silent drop occurred, not merely
1184    // a proptest-generator gap (the generator only ever emits lowercase
1185    // identifiers, so it could never reach `is_builtin_function`'s
1186    // all-uppercase names to begin with — see `arb_ident` in
1187    // `proptest_resolve.rs`).
1188    if is_t1b_stdlib_name(path) || is_builtin_function(path) {
1189        return;
1190    }
1191
1192    // B3a UFCS-shaped callee (issue #1482, D1–D5 RULED 2026-07-26): every
1193    // static dotted-path interpretation above has failed, but the path's
1194    // *head* segment alone names a value in scope — this is method-call
1195    // syntax on that value (`g.greet(3)`), not an unresolved static path.
1196    // Exactly the TM-4b fallback `lookup_variable`'s step 11 already applies
1197    // to a dotted *value* reference (`p.x.y`), applied to the callee
1198    // position: the resolved target is the head value itself, and the
1199    // trailing segment is carried structurally by the HIR `Path`.
1200    //
1201    // Which of the two meanings that trailing segment has (a callable field
1202    // of the receiver's type, or a free function to desugar onto) is a
1203    // *type-directed* question this resolution pass cannot answer — so it
1204    // resolves the receiver and stays silent, and `brink-analyzer::ufcs`
1205    // owns the verdict and every diagnostic for the site (`E140`–`E143`).
1206    // Suppressing `E025` here is what keeps a legal method call
1207    // diagnostic-free and an illegal one from being reported twice.
1208    //
1209    // Inert for the ink corpus by construction: ink's own `FunctionCall`
1210    // lowering always builds a single-segment callee path, so no ink source
1211    // can reach this branch (see `ufcs`' module doc).
1212    //
1213    // `arg_count.is_some()` narrows this to a real **call site**. A
1214    // `RefKind::Function` reference is also recorded for a `#fn(target)`
1215    // literal's target, and that one always carries `arg_count: None`
1216    // (`project_manifest`'s own documented distinction — `#fn` binds a
1217    // *prefix* of the param row, so it has no call arity). A dotted `#fn`
1218    // target is not method-call syntax and has no UFCS verdict; it must
1219    // keep failing as an unresolved reference here rather than silently
1220    // resolving to its head value.
1221    if uref.arg_count.is_some()
1222        && let Some((head, _rest)) = path.split_once('.')
1223        && let Some(id) = lookup_local_in_scope(locals, head, &uref.scope).or_else(|| {
1224            lookup_by_name(
1225                index,
1226                scope,
1227                head,
1228                &[SymbolKind::Variable, SymbolKind::Constant],
1229            )
1230        })
1231    {
1232        map.push(ResolvedRef {
1233            file: file_id,
1234            range: uref.range,
1235            target: id,
1236        });
1237        return;
1238    }
1239
1240    diagnostics.push(unresolved_diag(
1241        index,
1242        scope,
1243        file_id,
1244        uref.range,
1245        path,
1246        DiagnosticCode::E025,
1247        &[SymbolKind::Knot],
1248    ));
1249}
1250
1251/// Check that the number of arguments at the call site matches the target's parameter count.
1252fn check_arity(
1253    index: &SymbolIndex,
1254    file_id: FileId,
1255    uref: &brink_ir::UnresolvedRef,
1256    target: DefinitionId,
1257    diagnostics: &mut Vec<Diagnostic>,
1258) {
1259    let Some(call_arg_count) = uref.arg_count else {
1260        return;
1261    };
1262    let Some(info) = index.symbols.get(&target) else {
1263        return;
1264    };
1265    let expected = info.params.len();
1266    if call_arg_count != expected {
1267        diagnostics.push(Diagnostic {
1268            file: file_id,
1269            range: uref.range,
1270            message: format!(
1271                "{}: `{}` expects {} argument(s), got {}",
1272                DiagnosticCode::E031.title(),
1273                uref.path,
1274                expected,
1275                call_arg_count,
1276            ),
1277            code: DiagnosticCode::E031,
1278        });
1279    }
1280}
1281
1282fn resolve_list_ref(
1283    index: &SymbolIndex,
1284    scope: &ImportScope,
1285    file_id: FileId,
1286    uref: &brink_ir::UnresolvedRef,
1287    map: &mut ResolutionMap,
1288    diagnostics: &mut Vec<Diagnostic>,
1289) {
1290    let path = &uref.path;
1291
1292    // Try qualified list item (ListName.ItemName)
1293    if path.contains('.')
1294        && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
1295    {
1296        map.push(ResolvedRef {
1297            file: file_id,
1298            range: uref.range,
1299            target: id,
1300        });
1301        return;
1302    }
1303
1304    // Try bare list item name
1305    match lookup_list_item_bare(index, path) {
1306        BareItemResult::Unique(id) => {
1307            map.push(ResolvedRef {
1308                file: file_id,
1309                range: uref.range,
1310                target: id,
1311            });
1312            return;
1313        }
1314        BareItemResult::Ambiguous => {
1315            diagnostics.push(ambiguous_diag(file_id, uref.range, path));
1316            return;
1317        }
1318        BareItemResult::NotFound => {}
1319    }
1320
1321    // Try list name
1322    if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
1323        map.push(ResolvedRef {
1324            file: file_id,
1325            range: uref.range,
1326            target: id,
1327        });
1328        return;
1329    }
1330
1331    diagnostics.push(unresolved_diag(
1332        index,
1333        scope,
1334        file_id,
1335        uref.range,
1336        path,
1337        DiagnosticCode::E025,
1338        &[],
1339    ));
1340}
1341
1342/// Resolve a struct construction literal's leading shape name (`Name#{…}`,
1343/// TM-4b, docs/typed-mode-spec.md §6) against declared `SymbolKind::Struct`
1344/// symbols. Always a bare (undotted) name — the construction-literal grammar
1345/// only ever puts a single identifier before `#{` — so this is a direct
1346/// by-name lookup, no hierarchical/qualified fallback chain like diverts or
1347/// variables need.
1348fn resolve_struct_ref(
1349    index: &SymbolIndex,
1350    scope: &ImportScope,
1351    file_id: FileId,
1352    uref: &brink_ir::UnresolvedRef,
1353    map: &mut ResolutionMap,
1354    diagnostics: &mut Vec<Diagnostic>,
1355) {
1356    let path = &uref.path;
1357    if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Struct]) {
1358        map.push(ResolvedRef {
1359            file: file_id,
1360            range: uref.range,
1361            target: id,
1362        });
1363        return;
1364    }
1365    diagnostics.push(unresolved_diag(
1366        index,
1367        scope,
1368        file_id,
1369        uref.range,
1370        path,
1371        DiagnosticCode::E068,
1372        &[],
1373    ));
1374}
1375
1376/// Resolve a TM-2 type annotation's bare nominal leaf name (issue #2249) —
1377/// a struct field's declared type, or a `VAR`/`CONST`/`temp` annotation —
1378/// against declared `SymbolKind::Struct` symbols, exactly like
1379/// [`resolve_struct_ref`]'s lookup.
1380///
1381/// **Deliberately no diagnostic on a miss**, unlike `resolve_struct_ref`'s
1382/// `E068`: a `RefKind::Type` reference's `path` is not guaranteed to name a
1383/// struct at all — `int`, `float`, `List`, … are equally legal `Named`
1384/// leaves (`brink_ir::TypeExpr::Named`'s own doc), so "no declared struct
1385/// named this" is the overwhelmingly common, entirely legal case, not an
1386/// error. `annotations::check` (`E061`) is the dedicated "is this a
1387/// recognized type at all" diagnostic, run separately over the same HIR —
1388/// this function only ever *feeds* lowering's struct-shape chase
1389/// (`lir::lower::structs::record_global_annotation`,
1390/// `lir::lower::context::record_temp_annotation`) a resolved identity when
1391/// one exists, mirroring those two callers' own prior silent-`None`
1392/// posture (`ShapeTable::resolve` before this issue).
1393///
1394/// This *is* still narrower than `E061`'s own vocabulary check
1395/// (`annotations::declared_struct_names`): that lookup is project-flat,
1396/// with no referrer-scoping or std-exclusion at all, so a std-only struct
1397/// name is "recognized" for `E061`'s purposes regardless of importer —
1398/// unlike this function's `lookup_by_name`, which *does* exclude an
1399/// unimported std candidate. A `~ temp c: Cue` naming only a mounted std
1400/// module's `Cue`, with no project-side homonym or import, therefore still
1401/// raises no diagnostic anywhere today: `E061` accepts it (the name is
1402/// declared *somewhere*), and this resolution silently misses (by design,
1403/// per the paragraph above) rather than raising a new one. Issue #2249
1404/// leaves that specific compounding gap unruled — `annotations::TypeNames`
1405/// becoming referrer-scoped would be the natural fix, tracked there.
1406fn resolve_type_ref(
1407    index: &SymbolIndex,
1408    scope: &ImportScope,
1409    file_id: FileId,
1410    uref: &brink_ir::UnresolvedRef,
1411    map: &mut ResolutionMap,
1412) {
1413    if let Some(id) = lookup_by_name(index, scope, &uref.path, &[SymbolKind::Struct]) {
1414        map.push(ResolvedRef {
1415            file: file_id,
1416            range: uref.range,
1417            target: id,
1418        });
1419    }
1420}
1421
1422// ─── Lookup helpers ─────────────────────────────────────────────────
1423
1424/// Look up a local variable (param or temp) by bare name within the given
1425/// scope, among *this file's own* locals (issue #517 — locals never resolve
1426/// across files, since a knot's body lives in exactly one file).
1427///
1428/// A local matches if its name equals the bare name AND its scope is compatible:
1429/// same knot, and either same stitch or a knot-level param (stitch=None) which
1430/// is visible in all stitches. When multiple candidates match (e.g. a param and
1431/// a temp with the same name), picks the closest-preceding declaration.
1432fn lookup_local_in_scope(
1433    locals: &[LocalSymbol],
1434    bare_name: &str,
1435    scope: &Scope,
1436) -> Option<DefinitionId> {
1437    let mut best: Option<&LocalSymbol> = None;
1438
1439    for local in locals {
1440        if local.name != bare_name {
1441            continue;
1442        }
1443        // Knot must match
1444        if local.scope.knot != scope.knot {
1445            continue;
1446        }
1447        // A knot-level local (stitch=None) is visible in all stitches.
1448        // A stitch-level local is only visible in that stitch.
1449        if local.scope.stitch.is_some() && local.scope.stitch != scope.stitch {
1450            continue;
1451        }
1452        // Pick closest-preceding by range start
1453        match best {
1454            Some(prev) if local.range.start() > prev.range.start() => {
1455                best = Some(local);
1456            }
1457            None => {
1458                best = Some(local);
1459            }
1460            _ => {}
1461        }
1462    }
1463
1464    best.map(|local| local_definition_id(&local.scope, &local.name, local.kind))
1465}
1466
1467/// Ink built-in functions that are resolved at LIR lowering, not by the
1468/// symbol index.
1469///
1470/// Delegates to `brink_ir::lir::is_builtin_function` (issue #2863) rather
1471/// than hand-keeping its own copy of the 22-name list: this crate already
1472/// depends on `brink-ir` (the edge only runs one way — `brink-ir` cannot
1473/// depend back on `brink-analyzer`), so `brink-ir` is where the canonical
1474/// list lives, alongside the `recognize_builtin` table it must agree with
1475/// by construction. Before this fix, the two crates hand-kept two
1476/// independent copies of this same list — the exact drift risk that PR
1477/// #2859's review flagged: the lists agreed on *content* (22 names each)
1478/// and a resolution-*order* bug still reached production, because content
1479/// equality alone doesn't prove the two call sites consult the list at the
1480/// same point in resolution. A single shared implementation removes the
1481/// "one copy edited, the other forgotten" half of that risk; the *order*
1482/// half is a separate, per-call-site invariant, pinned end-to-end by
1483/// `crates/brink-compiler/tests/issue_2856_builtin_shadow.rs` and by this
1484/// crate's own `crates/internal/brink-analyzer/tests/
1485/// reserved_names_cross_crate.rs`.
1486///
1487/// `pub` rather than `pub(crate)` (module `resolve` itself stays private) so
1488/// `lib.rs`'s `#[doc(hidden)] pub mod test_support` can re-export it —
1489/// issue #2856: the `completeness` proptest
1490/// (`tests/proptest_resolve.rs`) needs the REAL name set this function
1491/// checks, not a hand-duplicated copy.
1492pub fn is_builtin_function(name: &str) -> bool {
1493    brink_ir::lir::is_builtin_function(name)
1494}
1495
1496/// T1b stdlib slice 1 function names (`docs/t1b-surface-spec.md` §5) plus
1497/// the TM-3-completion pure conversion intrinsics `int`/`float`/`string`
1498/// (`docs/typed-mode-spec.md` §4, maintainer ruling 2026-07-13, issue #659,
1499/// "per the stdlib slice-1 pattern"). Lowercase free functions,
1500/// brink-dialect-gated.
1501///
1502/// Delegates to `brink_ir::lir::is_t1b_stdlib_name` (issue #2863) for the
1503/// same reason as [`is_builtin_function`] just above — this used to be a
1504/// second hand-kept copy of `brink_ir`'s LIR-lowering list
1505/// (`lir::lower::expr::is_t1b_stdlib_name`); now there is exactly one
1506/// place this list is spelled out, and this function is a thin delegate to
1507/// it.
1508///
1509/// `resolve_function`'s lookup chain (externals, knots, lists, variables,
1510/// locals) always runs first, so an author-defined symbol of the same name
1511/// resolves normally — shadowing the builtin (§5's ruling) — and only a
1512/// resolution *failure* additionally checks this list before falling back
1513/// to the builtin (silently, no diagnostic) instead of emitting E025.
1514/// `is_builtin_function` now honors the identical ordering (issue #2856
1515/// point 3 — before that fix it was checked unconditionally, first, so a
1516/// declared symbol could never shadow one of *those* names; see that
1517/// function's own doc for the confirmed silent-drop this caused).
1518///
1519/// `pub` rather than `pub(crate)` for the same `test_support` re-export
1520/// reason as `is_builtin_function` above.
1521pub fn is_t1b_stdlib_name(name: &str) -> bool {
1522    brink_ir::lir::is_t1b_stdlib_name(name)
1523}
1524
1525/// `pub(crate)`: reused by `signature.rs` (issue #712) to resolve a `#fn`
1526/// creation-site target to its declaring knot when computing a VAR/CONST
1527/// global's declaration-derived `fn(T…): R` type — the same "function knot
1528/// by bare name" lookup [`resolve_function`] itself does first, without
1529/// needing this pass's locals/scope machinery (a `#fn` target at global-
1530/// initializer position has no enclosing body to scope against).
1531///
1532/// Alias ruling (issue #1590): `use mod::name as alias;` / `IMPORT { name AS
1533/// alias } FROM mod` binds `alias` to `name`'s target *in addition to* `name`
1534/// itself — not instead of it. The alias never shadows or revokes the source
1535/// spelling's own bare visibility (still decided by [`classify`] against
1536/// `scope.bare_imports`, unchanged by this fallback). This is a deliberate
1537/// departure from Rust's `use … as` (which drops the original binding):
1538/// [`lookup_by_name`]'s "byte-identity guarantee" fast path already returns
1539/// a **globally unique** name unconditionally, ignoring `ImportScope`
1540/// entirely — so a strict revoke-on-alias rule would only ever bite in the
1541/// rarer ambiguous-candidate case, silently keeping the source name resolvable
1542/// everywhere else. Rather than ship a rule that only sometimes holds, `AS`
1543/// stays purely additive: predictable in every case, in both dialects.
1544///
1545/// **Precedence when an alias collides with an in-scope direct name**:
1546/// [`lookup_by_name_direct`] always runs first, and this function only
1547/// consults `scope.aliases` when that direct lookup comes up empty. So if
1548/// `IMPORT { haggle AS start } FROM quest` is written in a file that also
1549/// defines a knot named `start`, every bare reference to `start` resolves to
1550/// the *local* `start` knot — the direct match wins silently, and the alias
1551/// is unreachable under that name. Nothing currently diagnoses this
1552/// collision (`E089` only dedupes among import items; it never checks
1553/// against file-local definitions); a shadowing diagnostic is tracked as a
1554/// follow-up rather than blocking this fix.
1555pub(crate) fn lookup_by_name(
1556    index: &SymbolIndex,
1557    scope: &ImportScope,
1558    name: &str,
1559    kinds: &[SymbolKind],
1560) -> Option<DefinitionId> {
1561    if let Some(id) = lookup_by_name_direct(index, scope, name, kinds) {
1562        return Some(id);
1563    }
1564
1565    // Alias fallback: `index.by_name` is keyed by definitions' own spellings
1566    // only, so a bare import's local alias — bound nowhere else — is never
1567    // found by the direct lookup above. Resolve it explicitly against the
1568    // specific `(module, source_name)` the import named; `kinds` and the
1569    // module still gate the match so an alias can never reach into the wrong
1570    // module or the wrong symbol kind.
1571    let (module, source_name) = scope.aliases.get(name)?;
1572    let ids = index.by_name.get(source_name.as_str())?;
1573    ids.iter().find_map(|id| {
1574        let info = index.symbols.get(id)?;
1575        (kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
1576            .then_some(*id)
1577    })
1578}
1579
1580/// The direct (non-alias) name lookup — everything [`lookup_by_name`] did
1581/// before issue #1590's alias fallback, plus the 2026-08-03 SUBTRACTION
1582/// RULING's std-invisibility gate (issue #2197, doc below) — no longer
1583/// byte-identical to that description, but byte-identical for every corpus
1584/// that never coexists with a `std::…` candidate (the whole
1585/// pre-stdlib-mount world).
1586fn lookup_by_name_direct(
1587    index: &SymbolIndex,
1588    scope: &ImportScope,
1589    name: &str,
1590    kinds: &[SymbolKind],
1591) -> Option<DefinitionId> {
1592    let ids = index.by_name.get(name)?;
1593
1594    let mut first_match: Option<DefinitionId> = None;
1595    let mut first_in_scope: Option<DefinitionId> = None;
1596    let mut first_imported: Option<DefinitionId> = None;
1597    let mut multiple = false;
1598
1599    for id in ids {
1600        let Some(info) = index.symbols.get(id) else {
1601            continue;
1602        };
1603        if !kinds.contains(&info.kind) {
1604            continue;
1605        }
1606        let candidacy = classify(scope, info);
1607        // Issue #2197, per #2080's SCOPE FENCE (`docs/decision-log.md`,
1608        // "Stdlib mounts into `Environment`'s manifest at the producer, as
1609        // plain source"): the mount puts std source into every project's
1610        // manifest, but "nothing in it is marked `pub` and no confinement
1611        // rule scopes what a project's own `use` may reach into it" — a
1612        // real `use std::…` still needs #1582's `pub` marker and #2167's
1613        // confinement, neither built yet. Until then, stdlib symbols are
1614        // reachable only via that not-yet-existing explicit import — there
1615        // is no implicit inclusion, so `classify` can never answer
1616        // `Imported` for a std candidate today (nothing under `std`
1617        // can be marked public yet) — every std candidate this file does
1618        // not itself
1619        // belong to (i.e. not `InScope`, which still covers a std file
1620        // referencing its own std-declared siblings) is `Other`. Skip it
1621        // entirely here, *before* it is counted into `first_match`/
1622        // `multiple` below: an `Other`-classified std candidate must never
1623        // win the flat-fallback tie-break a few lines down, which would
1624        // otherwise let a project silently resolve into the mounted
1625        // preset with no import at all — including when it is the *sole*
1626        // match, where the `!multiple` fast path below would otherwise
1627        // return it unconditionally. This interacts with M-2d's own
1628        // coexistence machinery (`is_cross_declared_module_collision`,
1629        // which is what lets a std candidate coexist in `by_name` in the
1630        // first place) by narrowing exactly one of its three resolution
1631        // tiers — `Other` — for the std case only; `InScope` and
1632        // `Imported` are untouched, so a std file's own internal
1633        // references, and a future real `use std::…` import once #1582/
1634        // #2167 ship, keep resolving normally.
1635        //
1636        // #2251: this exclusion is not actually std-specific — it applies
1637        // to "any reserved-root candidate this file does not itself
1638        // belong to", so it now checks `is_reserved_root_module` against
1639        // the whole `RESERVED_ROOTS` set rather than the single `std`
1640        // literal `is_std_module` used to compare against. Behavior is
1641        // unchanged today (the set has exactly one member), and a future
1642        // second mounted library gets this same exclusion for free.
1643        if candidacy == Candidacy::Other
1644            && info.module.as_deref().is_some_and(is_reserved_root_module)
1645        {
1646            continue;
1647        }
1648        if first_match.is_none() {
1649            first_match = Some(*id);
1650        } else {
1651            multiple = true;
1652        }
1653        match candidacy {
1654            Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
1655            Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
1656            _ => {}
1657        }
1658    }
1659
1660    // Fast path (byte-identity guarantee): with zero or one *non-std*
1661    // candidate of the requested kind — the entire strict-ink and
1662    // single-module world — the sole match is returned exactly as the
1663    // pre-M-2d flat lookup did, so the import scope never changes an
1664    // existing corpus's resolution. This is no longer quite "zero or one
1665    // candidate of the requested kind" (2026-08-03, issue #2197): a std
1666    // candidate that classifies `Other` is skipped above *before* it ever
1667    // reaches `first_match`/`multiple`, so a name with exactly one ordinary
1668    // candidate plus any number of coexisting std ones still takes this
1669    // fast path — and a name with std candidates *only* returns `None`
1670    // here, not the std candidate, unlike the byte-identical pre-#2197
1671    // description this comment used to give.
1672    if !multiple {
1673        return first_match;
1674    }
1675
1676    // Multiple cross-module candidates (only reachable now that the #784/#793
1677    // stopgap is relaxed and same-name public defs coexist): the referrer's
1678    // own-module / legacy candidate wins, else an imported public one, else
1679    // fall back to the flat first-winner — which keeps `modules::check`'s
1680    // E025 import-required diagnostic (keyed off the resolved target) firing
1681    // for a genuinely un-imported cross-module reference, exactly as before.
1682    first_in_scope.or(first_imported).or(first_match)
1683}
1684
1685/// The **scope-free** subset of [`lookup_by_name`]: the sole definition of
1686/// `name` whose kind is in `kinds`, or `None` when there is no such
1687/// definition **or more than one**.
1688///
1689/// This exists for callers that have no full [`ImportScope`] to hand — issue
1690/// #1909's UFCS-result typing runs inside `infer::body`, whose [`BodyCtx`]
1691/// (`brink-db`'s narrowed per-def HIR projection never holds a whole
1692/// `HirFile`) carries no file imports at all. `referrer_module` is the one
1693/// piece of that missing scope this function *can* still be handed — the
1694/// referring def's own declared module (issue #2233; `BodyCtx::
1695/// referrer_module`), the same value `ImportScope::file_module` would carry.
1696///
1697/// **It is a strict subset of what [`lookup_by_name`] answers, never a
1698/// second resolution rule** (issue #2216, unifying it with the
1699/// std-invisibility gate [`lookup_by_name_direct`] added for the scoped path
1700/// — 2026-08-03, issue #2197). This function still has no full
1701/// [`ImportScope`] to consult, so it cannot classify a candidate `Imported`
1702/// vs cross-module `Other` the way [`classify`] does — a candidate declared
1703/// in a mounted `std…` module is excluded here exactly as [`lookup_by_name`]
1704/// excludes it under the default (no-import) scope, **unless** it is
1705/// declared in the referrer's own module (`referrer_module`), which
1706/// reproduces exactly the one tier this function *can* fully classify
1707/// without an `ImportScope`: [`Candidacy::InScope`]'s "referrer and
1708/// candidate share a declared module" rule (issue #2233 — the fix for the
1709/// disagreement this doc used to call out; see below). This holds even when
1710/// the std candidate is the function's *sole* match: it is filtered out
1711/// before it can ever become `sole` whenever `referrer_module` doesn't match
1712/// it, so the name resolves as though that candidate did not exist, rather
1713/// than being returned.
1714///
1715/// For every other case, [`lookup_by_name_direct`]'s own "byte-identity
1716/// guarantee" fast path returns the sole non-std (or referrer-module-owned
1717/// std) candidate of the requested kinds *unconditionally, ignoring the rest
1718/// of the import scope*; the scope is consulted only once `multiple` is set.
1719/// So whenever this function returns `Some(id)`, [`lookup_by_name`] returns
1720/// the same `id` for any scope whose `file_module` equals this
1721/// `referrer_module` — pinned by `unique_lookup_agrees_with_scoped_lookup`
1722/// and, for the std case specifically, by
1723/// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`. When
1724/// it returns `None` on an ambiguous name, the caller must fall back to
1725/// whatever it did before rather than guess.
1726///
1727/// [`BodyCtx`]: crate::infer
1728pub(crate) fn lookup_unique_by_name(
1729    index: &SymbolIndex,
1730    name: &str,
1731    kinds: &[SymbolKind],
1732    referrer_module: Option<&str>,
1733) -> Option<DefinitionId> {
1734    let ids = index.by_name.get(name)?;
1735    let mut sole = None;
1736    for id in ids {
1737        let Some(info) = index.symbols.get(id) else {
1738            continue;
1739        };
1740        if !kinds.contains(&info.kind) {
1741            continue;
1742        }
1743        // Issue #2216, narrowed by #2233: this function has no full
1744        // `ImportScope`, so — mirroring `lookup_by_name_direct`'s
1745        // std-invisibility gate for the case where a candidate can never
1746        // classify `Imported` here — a std-mounted candidate must never win
1747        // the sole-match count, even alone, UNLESS it is declared in the
1748        // referrer's own module: that is exactly `Candidacy::InScope`'s
1749        // "referrer and candidate share a declared module" rule, the one
1750        // tier this function can reproduce without a full scope. A std
1751        // candidate in a *different* std module than the referrer's still
1752        // falls through to `Other` and is excluded, matching
1753        // `lookup_by_name`'s behavior for a referrer that has not imported
1754        // that sibling module.
1755        //
1756        // #2251: generalized from the single-root `is_std_module` to
1757        // `is_reserved_root_module` — same reasoning as
1758        // `lookup_by_name_direct`'s exclusion above.
1759        if info.module.as_deref().is_some_and(is_reserved_root_module)
1760            && info.module.as_deref() != referrer_module
1761        {
1762            continue;
1763        }
1764        if sole.is_some() {
1765            return None;
1766        }
1767        sole = Some(*id);
1768    }
1769    sole
1770}
1771
1772/// Result of a bare list item lookup.
1773///
1774/// `pub(crate)` (issue #628): the phase-0 `Sig` stub's list-literal type
1775/// inference (`external_check::resolve_list_item_name`) reuses this exact
1776/// bare-name resolution — the declaring LIST is always a project-global
1777/// lookup, never locally scoped, so that stub can call straight in without
1778/// threading an `ImportScope` through `signature()`.
1779pub(crate) enum BareItemResult {
1780    /// Exactly one match.
1781    Unique(DefinitionId),
1782    /// Multiple matches across different lists — caller must qualify.
1783    Ambiguous,
1784    /// No match found.
1785    NotFound,
1786}
1787
1788/// Look up a list item by its bare (unqualified) name.
1789/// Searches all `ListName.ItemName` entries for a suffix match.
1790/// Returns `Ambiguous` if multiple lists contain an item with this name.
1791pub(crate) fn lookup_list_item_bare(index: &SymbolIndex, bare_name: &str) -> BareItemResult {
1792    let suffix = format!(".{bare_name}");
1793    let mut found: Option<DefinitionId> = None;
1794    for (name, ids) in &index.by_name {
1795        if name.ends_with(&suffix) {
1796            for id in ids {
1797                if let Some(info) = index.symbols.get(id)
1798                    && info.kind == SymbolKind::ListItem
1799                {
1800                    if found.is_some() {
1801                        return BareItemResult::Ambiguous;
1802                    }
1803                    found = Some(*id);
1804                }
1805            }
1806        }
1807    }
1808    match found {
1809        Some(id) => BareItemResult::Unique(id),
1810        None => BareItemResult::NotFound,
1811    }
1812}
1813
1814/// Look up a label within a knot scope. Searches for `knot.label` and
1815/// `knot.*.label` patterns.
1816fn lookup_label_in_knot(
1817    index: &SymbolIndex,
1818    scope: &ImportScope,
1819    knot: &str,
1820    label: &str,
1821) -> Option<DefinitionId> {
1822    // Try knot.label
1823    let direct = format!("{knot}.{label}");
1824    if let Some(id) = lookup_by_name(index, scope, &direct, &[SymbolKind::Label]) {
1825        return Some(id);
1826    }
1827
1828    // Try knot.*.label (any stitch within this knot).
1829    // Collect all matches and pick the smallest `DefinitionId` for determinism,
1830    // since `HashMap` iteration order is not stable across processes.
1831    let suffix = format!(".{label}");
1832    let prefix = format!("{knot}.");
1833    let mut best: Option<DefinitionId> = None;
1834    for (name, ids) in &index.by_name {
1835        if name.starts_with(&prefix) && name.ends_with(&suffix) && name.matches('.').count() == 2 {
1836            for id in ids {
1837                if let Some(info) = index.symbols.get(id)
1838                    && info.kind == SymbolKind::Label
1839                {
1840                    best = Some(match best {
1841                        Some(prev) if prev.to_raw() <= id.to_raw() => prev,
1842                        _ => *id,
1843                    });
1844                }
1845            }
1846        }
1847    }
1848    best
1849}
1850
1851fn ambiguous_diag(file: FileId, range: rowan::TextRange, path: &str) -> Diagnostic {
1852    Diagnostic {
1853        file,
1854        range,
1855        message: format!(
1856            "{}: `{path}` — qualify with the list name (e.g., `ListName.{path}`)",
1857            DiagnosticCode::E027.title(),
1858        ),
1859        code: DiagnosticCode::E027,
1860    }
1861}
1862
1863/// True when `index` holds at least one declared symbol named `path`
1864/// whose module is a reserved peer root ([`is_reserved_root_module`]) — regardless
1865/// of `SymbolKind`, since the point here is not "which lookup would have
1866/// matched" but "does bare-name resolution's std-invisibility gate
1867/// (`lookup_by_name`, `lookup_unique_by_name`) explain why this path came
1868/// back empty".
1869///
1870/// Issue #2217: the gate that excludes std candidates from bare-name
1871/// resolution (`lookup_by_name`'s `Candidacy::Other` skip,
1872/// `lookup_unique_by_name`'s unconditional skip) applies identically to
1873/// the *embedded* stdlib mount and to a project's own file that legitimately
1874/// lives at a `std/…` path (`brink_environment::mount_stdlib`'s "project
1875/// source at the same key wins" carve-out — both mint the identical
1876/// `std::…` module identity via `native_module_path`, by design; see
1877/// `docs/modules-spec.md` §4 "peer roots"). Reconsidering `is_std_module`
1878/// to distinguish the two would undo that ruling, so this does not attempt
1879/// it — it only turns the resulting unexplained E024/E025/E068 into a
1880/// diagnosable one, without inventing a new diagnostic code.
1881fn is_std_shadowed_name(index: &SymbolIndex, path: &str) -> bool {
1882    index.by_name.get(path).is_some_and(|ids| {
1883        ids.iter().any(|id| {
1884            index
1885                .symbols
1886                .get(id)
1887                .is_some_and(|info| info.module.as_deref().is_some_and(is_reserved_root_module))
1888        })
1889    })
1890}
1891
1892/// Look for a candidate named `path`, of one of `kinds`, that resolution
1893/// skipped specifically because it is reachable only via a **qualified**
1894/// module import ([`is_qualified_import_only`]) — the "module-imported-
1895/// but-bare" row of the four-row resolution table issues #2287/#2296
1896/// worked through for diverts and #2298 extended to knot-as-tunnel-
1897/// function calls and `lookup_divert`'s remaining steps. When one exists,
1898/// [`unresolved_diag`] threads its module into the message so this row
1899/// gets the same "import it from `module`" framing `modules::check`'s own
1900/// E025 already gives the "no import at all" row (issue #2298 item 3),
1901/// instead of a bare, unexplained unresolved-reference message.
1902///
1903/// `kinds` must be exactly the symbol kinds the caller's own lookup chain
1904/// actually applied the `is_qualified_import_only` exclusion to — passing
1905/// a kind the caller never excluded (e.g. `External` at a function-call
1906/// site, which stays on the ordinary `classify` rule per this module's
1907/// top-of-file doc) would name a candidate that was never "skipped" at
1908/// all, misattributing an unrelated resolution failure. An empty slice
1909/// (every call site this exclusion does not apply to) always returns
1910/// `None`, leaving the message exactly as it was before this hint existed.
1911fn qualified_import_only_hint(
1912    index: &SymbolIndex,
1913    scope: &ImportScope,
1914    path: &str,
1915    kinds: &[SymbolKind],
1916) -> Option<String> {
1917    let ids = index.by_name.get(path)?;
1918    ids.iter().find_map(|id| {
1919        let info = index.symbols.get(id)?;
1920        if !kinds.contains(&info.kind) || !is_qualified_import_only(scope, info) {
1921            return None;
1922        }
1923        info.module.clone()
1924    })
1925}
1926
1927fn unresolved_diag(
1928    index: &SymbolIndex,
1929    scope: &ImportScope,
1930    file: FileId,
1931    range: rowan::TextRange,
1932    path: &str,
1933    code: DiagnosticCode,
1934    qualified_only_kinds: &[SymbolKind],
1935) -> Diagnostic {
1936    let message = if is_std_shadowed_name(index, path) {
1937        format!(
1938            "{}: `{path}` — a declaration of this name exists under the `std::` peer root \
1939             (either the mounted stdlib, or your own project file at a `std/…` path); bare \
1940             names under `std::` are invisible outside it by rule, not by mistake — reference \
1941             it with `use std::…` (docs/modules-spec.md §4)",
1942            code.title(),
1943        )
1944    } else if let Some(module) =
1945        qualified_import_only_hint(index, scope, path, qualified_only_kinds)
1946    {
1947        format!(
1948            "{}: `{path}` — exported by `{module}`, which this file imports only as a module; \
1949             a module import never brings bare names into scope — import it from `{module}` \
1950             (see modules-spec §2)",
1951            code.title(),
1952        )
1953    } else {
1954        format!("{}: `{path}`", code.title())
1955    };
1956    Diagnostic {
1957        file,
1958        range,
1959        message,
1960        code,
1961    }
1962}
1963
1964#[cfg(test)]
1965#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
1966mod tests {
1967    use brink_ir::{DeclaredSymbol, ImportItem, Scope, UnresolvedRef};
1968    use rowan::TextRange;
1969    use rowan::TextSize;
1970
1971    use super::*;
1972    use crate::manifest::merge_manifests;
1973
1974    fn range(offset: u32, len: u32) -> TextRange {
1975        TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
1976    }
1977
1978    fn make_manifest(
1979        knots: &[&str],
1980        stitches: &[&str],
1981        variables: &[&str],
1982        lists: &[(&str, &[&str])],
1983        externals: &[&str],
1984        labels: &[&str],
1985        unresolved: Vec<UnresolvedRef>,
1986    ) -> SymbolManifest {
1987        let mut manifest = SymbolManifest::default();
1988        let mut offset = 0u32;
1989
1990        for &name in knots {
1991            let r = range(offset, name.len() as u32);
1992            manifest.knots.push(DeclaredSymbol {
1993                name: name.to_string(),
1994                range: r,
1995                params: Vec::new(),
1996                detail: None,
1997                visibility: None,
1998                was: None,
1999            });
2000            offset += name.len() as u32 + 1;
2001        }
2002        for &name in stitches {
2003            let r = range(offset, name.len() as u32);
2004            manifest.stitches.push(DeclaredSymbol {
2005                name: name.to_string(),
2006                range: r,
2007                params: Vec::new(),
2008                detail: None,
2009                visibility: None,
2010                was: None,
2011            });
2012            offset += name.len() as u32 + 1;
2013        }
2014        for &name in variables {
2015            let r = range(offset, name.len() as u32);
2016            manifest.variables.push(DeclaredSymbol {
2017                name: name.to_string(),
2018                range: r,
2019                params: Vec::new(),
2020                detail: None,
2021                visibility: None,
2022                was: None,
2023            });
2024            offset += name.len() as u32 + 1;
2025        }
2026        for &(list_name, items) in lists {
2027            let r = range(offset, list_name.len() as u32);
2028            manifest.lists.push(DeclaredSymbol {
2029                name: list_name.to_string(),
2030                range: r,
2031                params: Vec::new(),
2032                detail: None,
2033                visibility: None,
2034                was: None,
2035            });
2036            offset += list_name.len() as u32 + 1;
2037            for &item in items {
2038                let qualified = format!("{list_name}.{item}");
2039                let r = range(offset, item.len() as u32);
2040                manifest.list_items.push(DeclaredSymbol {
2041                    name: qualified,
2042                    range: r,
2043                    params: Vec::new(),
2044                    detail: None,
2045                    visibility: None,
2046                    was: None,
2047                });
2048                offset += item.len() as u32 + 1;
2049            }
2050        }
2051        for &name in externals {
2052            let r = range(offset, name.len() as u32);
2053            manifest.externals.push(DeclaredSymbol {
2054                name: name.to_string(),
2055                range: r,
2056                params: Vec::new(),
2057                detail: None,
2058                visibility: None,
2059                was: None,
2060            });
2061            offset += name.len() as u32 + 1;
2062        }
2063        for &name in labels {
2064            let r = range(offset, name.len() as u32);
2065            manifest.labels.push(DeclaredSymbol {
2066                name: name.to_string(),
2067                range: r,
2068                params: Vec::new(),
2069                detail: None,
2070                visibility: None,
2071                was: None,
2072            });
2073            offset += name.len() as u32 + 1;
2074        }
2075        manifest.unresolved = unresolved;
2076        manifest
2077    }
2078
2079    fn uref(path: &str, kind: RefKind, knot: Option<&str>, stitch: Option<&str>) -> UnresolvedRef {
2080        uref_with_args(path, kind, knot, stitch, None)
2081    }
2082
2083    fn uref_with_args(
2084        path: &str,
2085        kind: RefKind,
2086        knot: Option<&str>,
2087        stitch: Option<&str>,
2088        arg_count: Option<usize>,
2089    ) -> UnresolvedRef {
2090        UnresolvedRef {
2091            path: path.to_string(),
2092            range: range(900, path.len() as u32),
2093            kind,
2094            scope: Scope {
2095                knot: knot.map(String::from),
2096                stitch: stitch.map(String::from),
2097            },
2098            arg_count,
2099            module_qualified: false,
2100        }
2101    }
2102
2103    #[test]
2104    fn single_knot_divert_resolves() {
2105        let manifest = make_manifest(
2106            &["start"],
2107            &[],
2108            &[],
2109            &[],
2110            &[],
2111            &[],
2112            vec![uref("start", RefKind::Divert, None, None)],
2113        );
2114        let files = vec![(FileId(0), &manifest)];
2115        let (index, merge_diags) = merge_manifests(&files);
2116        let (resolutions, resolve_diags) = resolve_refs(&index, &files);
2117
2118        assert!(merge_diags.is_empty());
2119        assert!(resolve_diags.is_empty());
2120        assert_eq!(resolutions.len(), 1);
2121        assert_eq!(resolutions[0].file, FileId(0));
2122    }
2123
2124    #[test]
2125    fn qualified_knot_stitch_divert_resolves() {
2126        let manifest = make_manifest(
2127            &["kitchen"],
2128            &["kitchen.look_around"],
2129            &[],
2130            &[],
2131            &[],
2132            &[],
2133            vec![uref("kitchen.look_around", RefKind::Divert, None, None)],
2134        );
2135        let files = vec![(FileId(0), &manifest)];
2136        let (index, _) = merge_manifests(&files);
2137        let (resolutions, diags) = resolve_refs(&index, &files);
2138
2139        assert!(diags.is_empty());
2140        assert_eq!(resolutions.len(), 1);
2141    }
2142
2143    #[test]
2144    fn stitch_local_divert_prefers_local_stitch() {
2145        let manifest = make_manifest(
2146            &["bedroom", "kitchen"],
2147            &["bedroom.look", "kitchen.look"],
2148            &[],
2149            &[],
2150            &[],
2151            &[],
2152            vec![uref("look", RefKind::Divert, Some("bedroom"), None)],
2153        );
2154        let files = vec![(FileId(0), &manifest)];
2155        let (index, _) = merge_manifests(&files);
2156        let (resolutions, diags) = resolve_refs(&index, &files);
2157
2158        assert!(diags.is_empty());
2159        assert_eq!(resolutions.len(), 1);
2160        // The resolved ID should be for bedroom.look
2161        let info = index.symbols.get(&resolutions[0].target).unwrap();
2162        assert_eq!(info.name, "bedroom.look");
2163    }
2164
2165    #[test]
2166    fn unresolved_divert_emits_diagnostic() {
2167        let manifest = make_manifest(
2168            &["start"],
2169            &[],
2170            &[],
2171            &[],
2172            &[],
2173            &[],
2174            vec![uref("nonexistent", RefKind::Divert, None, None)],
2175        );
2176        let files = vec![(FileId(0), &manifest)];
2177        let (index, _) = merge_manifests(&files);
2178        let (resolutions, diags) = resolve_refs(&index, &files);
2179
2180        assert!(resolutions.is_empty());
2181        assert_eq!(diags.len(), 1);
2182        assert_eq!(diags[0].code, DiagnosticCode::E024);
2183    }
2184
2185    #[test]
2186    fn duplicate_knot_emits_warning() {
2187        let mut m1 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
2188        let m2 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
2189
2190        // Give m1 different range so they don't collide
2191        m1.knots[0].range = range(0, 5);
2192
2193        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
2194        let (_index, diags) = merge_manifests(&files);
2195
2196        // Inklecate permits duplicate definitions — we warn but don't error.
2197        assert_eq!(diags.len(), 1);
2198        assert_eq!(diags[0].code, DiagnosticCode::E022);
2199    }
2200
2201    #[test]
2202    fn cross_file_duplicate_knot_local_does_not_leak_across_files() {
2203        // #517 (finding-4 fix): file B has a duplicate knot name (`dup`,
2204        // warned E022) but does *not* declare its own `t`. Before the
2205        // locals split, `lookup_local_in_scope` searched the merged index
2206        // and would (wrongly) resolve B's reference against A's
2207        // same-scoped `t`, since the lookup never checked which file a
2208        // candidate came from. After the split, resolution reads only the
2209        // referencing file's own `manifest.locals`, so B's reference to an
2210        // undeclared `t` must fail to resolve instead of silently aliasing
2211        // A's declaration.
2212        let mut a = SymbolManifest::default();
2213        a.knots.push(brink_ir::DeclaredSymbol {
2214            name: "dup".to_string(),
2215            range: range(0, 3),
2216            params: Vec::new(),
2217            detail: None,
2218            visibility: None,
2219            was: None,
2220        });
2221        a.locals.push(LocalSymbol {
2222            name: "t".to_string(),
2223            range: range(10, 1),
2224            scope: Scope {
2225                knot: Some("dup".to_string()),
2226                stitch: None,
2227            },
2228            kind: SymbolKind::Temp,
2229            param_detail: None,
2230            annotation: None,
2231        });
2232
2233        let mut b = SymbolManifest::default();
2234        b.knots.push(brink_ir::DeclaredSymbol {
2235            name: "dup".to_string(),
2236            range: range(100, 3),
2237            params: Vec::new(),
2238            detail: None,
2239            visibility: None,
2240            was: None,
2241        }); // duplicate name -> E022, not indexed
2242        b.unresolved
2243            .push(uref("t", RefKind::Variable, Some("dup"), None));
2244
2245        let files = vec![(FileId(0), &a), (FileId(1), &b)];
2246        let (index, merge_diags) = merge_manifests(&files);
2247        assert_eq!(merge_diags.len(), 1, "duplicate knot should warn once");
2248        assert_eq!(merge_diags[0].code, DiagnosticCode::E022);
2249
2250        let (resolutions, diags) = resolve_refs(&index, &files);
2251        assert!(
2252            resolutions.is_empty(),
2253            "B's reference to an undeclared local must not resolve, got {resolutions:?}"
2254        );
2255        assert_eq!(diags.len(), 1);
2256        assert_eq!(diags[0].code, DiagnosticCode::E025);
2257    }
2258
2259    #[test]
2260    fn list_item_bare_name_resolves() {
2261        let manifest = make_manifest(
2262            &[],
2263            &[],
2264            &[],
2265            &[("Colors", &["red", "green", "blue"])],
2266            &[],
2267            &[],
2268            vec![uref("red", RefKind::Variable, None, None)],
2269        );
2270        let files = vec![(FileId(0), &manifest)];
2271        let (index, _) = merge_manifests(&files);
2272        let (resolutions, diags) = resolve_refs(&index, &files);
2273
2274        assert!(diags.is_empty());
2275        assert_eq!(resolutions.len(), 1);
2276        let info = index.symbols.get(&resolutions[0].target).unwrap();
2277        assert_eq!(info.name, "Colors.red");
2278    }
2279
2280    #[test]
2281    fn end_done_not_in_unresolved() {
2282        // END/DONE are handled as DivertPath::End/Done at the HIR level,
2283        // so they never appear as UnresolvedRef entries. This test verifies
2284        // that the resolution pass doesn't get confused by them.
2285        let manifest = make_manifest(
2286            &["start"],
2287            &[],
2288            &[],
2289            &[],
2290            &[],
2291            &[],
2292            vec![], // No unresolved refs for END/DONE
2293        );
2294        let files = vec![(FileId(0), &manifest)];
2295        let (index, _) = merge_manifests(&files);
2296        let (resolutions, diags) = resolve_refs(&index, &files);
2297
2298        assert!(diags.is_empty());
2299        assert!(resolutions.is_empty());
2300    }
2301
2302    #[test]
2303    fn label_in_knot_resolves() {
2304        let manifest = make_manifest(
2305            &["meeting"],
2306            &[],
2307            &[],
2308            &[],
2309            &[],
2310            &["meeting.greet"],
2311            vec![uref("greet", RefKind::Divert, Some("meeting"), None)],
2312        );
2313        let files = vec![(FileId(0), &manifest)];
2314        let (index, _) = merge_manifests(&files);
2315        let (resolutions, diags) = resolve_refs(&index, &files);
2316
2317        assert!(diags.is_empty());
2318        assert_eq!(resolutions.len(), 1);
2319        let info = index.symbols.get(&resolutions[0].target).unwrap();
2320        assert_eq!(info.name, "meeting.greet");
2321    }
2322
2323    #[test]
2324    fn external_function_resolves() {
2325        let manifest = make_manifest(
2326            &[],
2327            &[],
2328            &[],
2329            &[],
2330            &["print_debug"],
2331            &[],
2332            vec![uref("print_debug", RefKind::Function, None, None)],
2333        );
2334        let files = vec![(FileId(0), &manifest)];
2335        let (index, _) = merge_manifests(&files);
2336        let (resolutions, diags) = resolve_refs(&index, &files);
2337
2338        assert!(diags.is_empty());
2339        assert_eq!(resolutions.len(), 1);
2340    }
2341
2342    #[test]
2343    fn global_variable_resolves() {
2344        let manifest = make_manifest(
2345            &[],
2346            &[],
2347            &["player_name"],
2348            &[],
2349            &[],
2350            &[],
2351            vec![uref("player_name", RefKind::Variable, None, None)],
2352        );
2353        let files = vec![(FileId(0), &manifest)];
2354        let (index, _) = merge_manifests(&files);
2355        let (resolutions, diags) = resolve_refs(&index, &files);
2356
2357        assert!(diags.is_empty());
2358        assert_eq!(resolutions.len(), 1);
2359    }
2360
2361    #[test]
2362    fn ambiguous_bare_list_item_emits_diagnostic() {
2363        let manifest = make_manifest(
2364            &[],
2365            &[],
2366            &[],
2367            &[("Fruit", &["red"]), ("Color", &["red"])],
2368            &[],
2369            &[],
2370            vec![uref("red", RefKind::Variable, None, None)],
2371        );
2372        let files = vec![(FileId(0), &manifest)];
2373        let (index, _) = merge_manifests(&files);
2374        let (resolutions, diags) = resolve_refs(&index, &files);
2375
2376        assert!(resolutions.is_empty());
2377        assert_eq!(diags.len(), 1);
2378        assert_eq!(diags[0].code, DiagnosticCode::E027);
2379    }
2380
2381    #[test]
2382    fn qualified_list_item_resolves_despite_ambiguity() {
2383        let manifest = make_manifest(
2384            &[],
2385            &[],
2386            &[],
2387            &[("Fruit", &["red"]), ("Color", &["red"])],
2388            &[],
2389            &[],
2390            vec![uref("Color.red", RefKind::Variable, None, None)],
2391        );
2392        let files = vec![(FileId(0), &manifest)];
2393        let (index, _) = merge_manifests(&files);
2394        let (resolutions, diags) = resolve_refs(&index, &files);
2395
2396        assert!(diags.is_empty());
2397        assert_eq!(resolutions.len(), 1);
2398        let info = index.symbols.get(&resolutions[0].target).unwrap();
2399        assert_eq!(info.name, "Color.red");
2400    }
2401
2402    // ── Arity checking ──────────────────────────────────────────────
2403
2404    /// Make a manifest with a knot that has a specific number of params.
2405    fn make_manifest_with_params(
2406        knot_name: &str,
2407        param_count: usize,
2408        unresolved: Vec<UnresolvedRef>,
2409    ) -> SymbolManifest {
2410        let mut manifest = SymbolManifest::default();
2411        let r = range(0, knot_name.len() as u32);
2412        let params: Vec<brink_ir::ParamInfo> = (0..param_count)
2413            .map(|i| brink_ir::ParamInfo {
2414                name: format!("p{i}"),
2415                is_ref: false,
2416                is_divert: false,
2417            })
2418            .collect();
2419        manifest.knots.push(DeclaredSymbol {
2420            name: knot_name.to_string(),
2421            range: r,
2422            params,
2423            detail: Some("function".to_string()),
2424            visibility: None,
2425            was: None,
2426        });
2427        manifest.unresolved = unresolved;
2428        manifest
2429    }
2430
2431    #[test]
2432    fn arity_match_no_warning() {
2433        // Call `greet(x)` where greet takes 1 param — no warning.
2434        let manifest = make_manifest_with_params(
2435            "greet",
2436            1,
2437            vec![uref_with_args(
2438                "greet",
2439                RefKind::Function,
2440                None,
2441                None,
2442                Some(1),
2443            )],
2444        );
2445        let files = vec![(FileId(0), &manifest)];
2446        let (index, _) = merge_manifests(&files);
2447        let (resolutions, diags) = resolve_refs(&index, &files);
2448
2449        assert_eq!(resolutions.len(), 1);
2450        assert!(
2451            diags.is_empty(),
2452            "expected no diagnostics for matching arity, got: {diags:?}"
2453        );
2454    }
2455
2456    #[test]
2457    fn arity_mismatch_emits_e031() {
2458        // Call `greet(x, y)` where greet takes 1 param — E031 warning.
2459        let manifest = make_manifest_with_params(
2460            "greet",
2461            1,
2462            vec![uref_with_args(
2463                "greet",
2464                RefKind::Function,
2465                None,
2466                None,
2467                Some(2),
2468            )],
2469        );
2470        let files = vec![(FileId(0), &manifest)];
2471        let (index, _) = merge_manifests(&files);
2472        let (resolutions, diags) = resolve_refs(&index, &files);
2473
2474        assert_eq!(
2475            resolutions.len(),
2476            1,
2477            "should still resolve despite arity mismatch"
2478        );
2479        assert_eq!(diags.len(), 1);
2480        assert_eq!(diags[0].code, DiagnosticCode::E031);
2481        assert!(diags[0].message.contains("expects 1"));
2482        assert!(diags[0].message.contains("got 2"));
2483    }
2484
2485    #[test]
2486    fn arity_check_no_arg_count_no_warning() {
2487        // A ref with `arg_count: None` (not a call site at all) should
2488        // never trigger arity checking, regardless of kind. This uses
2489        // `uref`'s hardcoded `None` directly rather than a real divert
2490        // pipeline — since issue #2156 a *real* `RefKind::Divert` ref
2491        // always carries `Some(target.args.len())`
2492        // (`brink_ir::symbols::project`'s `walk_divert_target`); see
2493        // `divert_arity_mismatch_emits_e176` below for that path.
2494        let manifest =
2495            make_manifest_with_params("greet", 1, vec![uref("greet", RefKind::Divert, None, None)]);
2496        let files = vec![(FileId(0), &manifest)];
2497        let (index, _) = merge_manifests(&files);
2498        let (_resolutions, diags) = resolve_refs(&index, &files);
2499
2500        assert!(
2501            diags.is_empty(),
2502            "a ref with no arg_count should not trigger arity check: {diags:?}"
2503        );
2504    }
2505
2506    /// Make a manifest with a top-level knot that has a specific number of
2507    /// params, alongside a top-level `Variable` — for the divert-arity
2508    /// (`E176`, issue #2156) test family below.
2509    fn make_manifest_with_knot_and_variable(
2510        knot_name: &str,
2511        param_count: usize,
2512        variable_name: &str,
2513        unresolved: Vec<UnresolvedRef>,
2514    ) -> SymbolManifest {
2515        let mut manifest = make_manifest_with_params(knot_name, param_count, Vec::new());
2516        let r = range(9000, variable_name.len() as u32);
2517        manifest.variables.push(DeclaredSymbol {
2518            name: variable_name.to_string(),
2519            range: r,
2520            params: Vec::new(),
2521            detail: None,
2522            visibility: None,
2523            was: None,
2524        });
2525        manifest.unresolved = unresolved;
2526        manifest
2527    }
2528
2529    #[test]
2530    fn divert_arity_match_emits_no_e176() {
2531        // `-> greet(x)` where `greet` takes 1 param — no warning.
2532        let manifest = make_manifest_with_params(
2533            "greet",
2534            1,
2535            vec![uref_with_args(
2536                "greet",
2537                RefKind::Divert,
2538                None,
2539                None,
2540                Some(1),
2541            )],
2542        );
2543        let files = vec![(FileId(0), &manifest)];
2544        let (index, _) = merge_manifests(&files);
2545        let (resolutions, diags) = resolve_refs(&index, &files);
2546
2547        assert_eq!(resolutions.len(), 1);
2548        assert!(
2549            diags.is_empty(),
2550            "expected no diagnostics for matching divert arity, got: {diags:?}"
2551        );
2552    }
2553
2554    #[test]
2555    fn divert_arity_mismatch_emits_e176() {
2556        // `-> greet(x, y)` where `greet` takes 1 param — E176 warning, not
2557        // E031 (that code stays scoped to ordinary calls; this is its
2558        // sibling for the divert shape).
2559        let manifest = make_manifest_with_params(
2560            "greet",
2561            1,
2562            vec![uref_with_args(
2563                "greet",
2564                RefKind::Divert,
2565                None,
2566                None,
2567                Some(2),
2568            )],
2569        );
2570        let files = vec![(FileId(0), &manifest)];
2571        let (index, _) = merge_manifests(&files);
2572        let (resolutions, diags) = resolve_refs(&index, &files);
2573
2574        assert_eq!(
2575            resolutions.len(),
2576            1,
2577            "should still resolve despite arity mismatch"
2578        );
2579        assert_eq!(diags.len(), 1);
2580        assert_eq!(diags[0].code, DiagnosticCode::E176);
2581        assert!(diags[0].message.contains("expects 1"));
2582        assert!(diags[0].message.contains("got 2"));
2583    }
2584
2585    #[test]
2586    fn divert_through_variable_is_not_arity_checked() {
2587        // `-> holder` where `holder` is a `Variable` (holding a stored
2588        // divert-target value, e.g. ink's "Advanced: sending divert
2589        // targets as parameters") must never be arity-checked: a
2590        // `Variable` symbol carries no declared parameter row of its own,
2591        // so checking `arg_count` against it would misfire on legitimate
2592        // code. `lookup_divert`'s case 6 (bare-name fallback to a
2593        // `Variable`) is exactly the resolution this exercises — the knot
2594        // `greet` (1 param) is a decoy that must NOT be what `holder`
2595        // resolves to.
2596        let manifest = make_manifest_with_knot_and_variable(
2597            "greet",
2598            1,
2599            "holder",
2600            vec![uref_with_args(
2601                "holder",
2602                RefKind::Divert,
2603                None,
2604                None,
2605                Some(3),
2606            )],
2607        );
2608        let files = vec![(FileId(0), &manifest)];
2609        let (index, _) = merge_manifests(&files);
2610        let (resolutions, diags) = resolve_refs(&index, &files);
2611
2612        assert_eq!(resolutions.len(), 1, "the Variable must still resolve");
2613        assert!(
2614            diags.is_empty(),
2615            "a divert through a Variable resolution must never be arity-checked: {diags:?}"
2616        );
2617    }
2618
2619    #[test]
2620    fn arity_mismatch_external() {
2621        // Call external with wrong arity.
2622        let mut manifest = SymbolManifest::default();
2623        let r = range(0, 5);
2624        manifest.externals.push(DeclaredSymbol {
2625            name: "print".to_string(),
2626            range: r,
2627            params: vec![brink_ir::ParamInfo {
2628                name: "msg".into(),
2629                is_ref: false,
2630                is_divert: false,
2631            }],
2632            detail: None,
2633            visibility: None,
2634            was: None,
2635        });
2636        manifest.unresolved.push(uref_with_args(
2637            "print",
2638            RefKind::Function,
2639            None,
2640            None,
2641            Some(3),
2642        ));
2643        let files = vec![(FileId(0), &manifest)];
2644        let (index, _) = merge_manifests(&files);
2645        let (resolutions, diags) = resolve_refs(&index, &files);
2646
2647        assert_eq!(resolutions.len(), 1);
2648        assert_eq!(diags.len(), 1);
2649        assert_eq!(diags[0].code, DiagnosticCode::E031);
2650    }
2651
2652    // ── TM-4b resolution fallback (docs/typed-mode-spec.md §6) ─────────
2653    //
2654    // End-to-end (parse -> HIR lower -> merge_manifests -> resolve_file)
2655    // rather than the hand-built `make_manifest` fixtures above: the
2656    // precedence claim is about how a *real* dotted `Path` — produced by
2657    // the actual parser/lowering pipeline, not a synthesized
2658    // `UnresolvedRef` — resolves, so the fixture needs the real pipeline to
2659    // be a faithful test of the claim.
2660
2661    /// Parse -> HIR lower -> `merge_manifests` -> `resolve_file` (the real
2662    /// production pipeline). Each fixture below is written to produce
2663    /// exactly one resolvable reference (the LHS of `~ y = …` is an
2664    /// undeclared `y`, so it stays unresolved — diagnosed, not resolved —
2665    /// and `-> DONE` is handled specially at the HIR level, never a
2666    /// resolvable reference), so `resolutions` always has exactly one entry:
2667    /// the dotted/bare reference under test.
2668    fn build_real(src: &str) -> (SymbolIndex, ResolutionMap) {
2669        let parsed = brink_syntax::parse(src);
2670        let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
2671        let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
2672        let (resolutions, _diag) =
2673            resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
2674        (index, resolutions)
2675    }
2676
2677    /// The `SymbolKind` a reference spanning exactly `needle` (the first
2678    /// occurrence of that substring in `src`) resolved to. Ink's own
2679    /// lowering can register additional bookkeeping references beyond the
2680    /// one under test (e.g. an implicit fallthrough divert when a knot's
2681    /// only content is its first stitch) — matching by the reference's
2682    /// exact source span, rather than assuming `resolutions` has exactly
2683    /// one entry, is robust to that noise.
2684    fn resolved_kind_at(
2685        src: &str,
2686        index: &SymbolIndex,
2687        resolutions: &ResolutionMap,
2688        needle: &str,
2689    ) -> SymbolKind {
2690        // The *last* occurrence — every fixture below places the reference
2691        // under test after any same-named declaration.
2692        let start = src
2693            .rfind(needle)
2694            .expect("needle not found in fixture source");
2695        #[expect(
2696            clippy::cast_possible_truncation,
2697            reason = "test fixture offsets fit in u32"
2698        )]
2699        let range = rowan::TextRange::new(
2700            rowan::TextSize::from(start as u32),
2701            rowan::TextSize::from((start + needle.len()) as u32),
2702        );
2703        let target = resolutions
2704            .iter()
2705            .find(|r| r.range == range)
2706            .expect("no resolution spanning the needle's exact range")
2707            .target;
2708        index
2709            .symbols
2710            .get(&target)
2711            .expect("resolved target missing from index")
2712            .kind
2713    }
2714
2715    #[test]
2716    fn resolution_fallback_static_dotted_path_wins_over_a_colliding_variable_name() {
2717        // §6's own precedence claim: "ink's static dotted paths (knot.stitch,
2718        // List.Item) are resolved first and win". `knot` here is BOTH the
2719        // head of a real stitch path (`knot.stitch`) AND the name of a
2720        // declared variable — the static path must win, resolving `knot.x`
2721        // to the stitch, not falling back to field access on the variable.
2722        let src = "VAR knot = 0\n=== knot ===\n= x\nHello.\n-> DONE\n\
2723                   === main ===\n~ y = knot.x\n-> DONE\n";
2724        let (index, resolutions) = build_real(src);
2725        assert_eq!(
2726            resolved_kind_at(src, &index, &resolutions, "knot.x"),
2727            SymbolKind::Stitch,
2728            "the static `knot.x` stitch path must win over the colliding `knot` variable"
2729        );
2730    }
2731
2732    #[test]
2733    fn resolution_fallback_resolves_to_head_variable_when_no_static_path_matches() {
2734        // No knot/stitch/list/label named `p` or `p.x` exists — the fallback
2735        // resolves `p.x` to the variable `p` itself (field access on it),
2736        // not an unresolved reference.
2737        let src = "VAR p = 0\n=== main ===\n~ y = p.x\n-> DONE\n";
2738        let (index, resolutions) = build_real(src);
2739        assert_eq!(
2740            resolved_kind_at(src, &index, &resolutions, "p.x"),
2741            SymbolKind::Variable
2742        );
2743    }
2744
2745    #[test]
2746    fn resolution_fallback_resolves_to_head_param() {
2747        // Same fallback, but the head is a knot parameter rather than a
2748        // global — the fallback checks locals (params/temps) first, per
2749        // `lookup_variable`'s existing local-shadows-global ordering.
2750        let src = "=== main(p) ===\n~ y = p.x\n-> DONE\n";
2751        let (index, resolutions) = build_real(src);
2752        assert_eq!(
2753            resolved_kind_at(src, &index, &resolutions, "p.x"),
2754            SymbolKind::Param
2755        );
2756    }
2757
2758    #[test]
2759    fn resolution_fallback_does_not_apply_to_a_single_segment_path() {
2760        // A bare `p` (no dot at all) must resolve exactly as it always has
2761        // — the fallback is gated on `path.contains('.')` and must never
2762        // fire for an ordinary single-segment variable reference.
2763        let src = "VAR p = 0\n=== main ===\n~ y = p\n-> DONE\n";
2764        let (index, resolutions) = build_real(src);
2765        assert_eq!(
2766            resolved_kind_at(src, &index, &resolutions, "p"),
2767            SymbolKind::Variable
2768        );
2769    }
2770
2771    #[test]
2772    fn struct_literal_resolves_shape_name_to_the_declared_struct() {
2773        let src = "STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n";
2774        let (index, resolutions) = build_real(src);
2775        assert_eq!(
2776            resolved_kind_at(src, &index, &resolutions, "Point"),
2777            SymbolKind::Struct
2778        );
2779    }
2780
2781    #[test]
2782    fn struct_literal_unresolved_shape_name_is_e068() {
2783        let src = "=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n";
2784        let parsed = brink_syntax::parse(src);
2785        let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
2786        let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
2787        let (resolutions, diags) =
2788            resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
2789        assert!(
2790            resolutions.is_empty(),
2791            "no resolution for an undeclared shape: {resolutions:?}"
2792        );
2793        assert!(
2794            diags.iter().any(|d| d.code == DiagnosticCode::E068),
2795            "{diags:?}"
2796        );
2797    }
2798
2799    // ── M-2d import-scoped lookup (issue #790) ────────────────────────
2800
2801    /// A hand-built index with two `Knot`s named `ambush` in *different*
2802    /// declared modules (the coexistence unlocked by relaxing the #784/#793
2803    /// stopgap). Insertion order is `quest_a`, then `quest_b`, so the flat
2804    /// first-winner is always `quest_a` — the import scope is what makes
2805    /// `quest_b` reachable.
2806    fn two_module_ambush_index() -> (SymbolIndex, DefinitionId, DefinitionId) {
2807        use brink_format::DefinitionTag;
2808        let mut index = SymbolIndex::default();
2809        let mk = |index: &mut SymbolIndex, module: &str, hash: u64| {
2810            let id = DefinitionId::new(DefinitionTag::Address, hash);
2811            index.symbols.insert(
2812                id,
2813                SymbolInfo {
2814                    kind: SymbolKind::Knot,
2815                    file: FileId(0),
2816                    range: TextRange::default(),
2817                    id,
2818                    name: "ambush".to_string(),
2819                    params: Vec::new(),
2820                    detail: None,
2821                    scope: None,
2822                    param_detail: None,
2823                    module: Some(module.to_string()),
2824                    visibility: Visibility::Public,
2825                },
2826            );
2827            index
2828                .by_name
2829                .entry("ambush".to_string())
2830                .or_default()
2831                .push(id);
2832            id
2833        };
2834        let a = mk(&mut index, "quest_a", 0xA);
2835        let b = mk(&mut index, "quest_b", 0xB);
2836        (index, a, b)
2837    }
2838
2839    #[test]
2840    fn import_scope_binds_each_importer_to_its_own_module() {
2841        let (index, a, b) = two_module_ambush_index();
2842
2843        let scope_a = ImportScope {
2844            file_module: None,
2845            qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2846            bare_imports: BTreeSet::new(),
2847            aliases: BTreeMap::new(),
2848        };
2849        assert_eq!(
2850            lookup_by_name(&index, &scope_a, "ambush", &[SymbolKind::Knot]),
2851            Some(a),
2852            "a file importing quest_a binds quest_a's ambush"
2853        );
2854
2855        let scope_b = ImportScope {
2856            file_module: None,
2857            qualified_modules: ["quest_b".to_string()].into_iter().collect(),
2858            bare_imports: BTreeSet::new(),
2859            aliases: BTreeMap::new(),
2860        };
2861        assert_eq!(
2862            lookup_by_name(&index, &scope_b, "ambush", &[SymbolKind::Knot]),
2863            Some(b),
2864            "a file importing quest_b binds quest_b's ambush — not the flat first-winner"
2865        );
2866    }
2867
2868    /// Issue #1909's fence: [`lookup_unique_by_name`] must only ever answer
2869    /// where [`lookup_by_name`] would answer identically **for every scope
2870    /// whose `file_module` equals the `referrer_module` hint this function
2871    /// was handed** (issue #2233 narrowed the guarantee from "every scope
2872    /// unconditionally" to this — a std-mounted candidate's visibility now
2873    /// depends on that hint agreeing with the scope's own `file_module`; see
2874    /// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
2875    /// below for the case that distinguishes them). Both halves are
2876    /// asserted — the sole-candidate name agrees with two deliberately
2877    /// opposed scopes, and the ambiguous name declines.
2878    ///
2879    /// This fixture has no std-mounted candidate; the std-mounted case
2880    /// (issue #2216, the #2197 follow-up this doc used to flag as an
2881    /// un-pinned gap) is covered separately by
2882    /// `unique_lookup_excludes_std_mounted_sole_candidate`,
2883    /// `unique_lookup_skips_std_candidate_and_returns_the_ordinary_one`, and
2884    /// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
2885    /// below, now that [`lookup_unique_by_name`] applies the same
2886    /// std-invisibility gate as [`lookup_by_name_direct`], narrowed by a
2887    /// `referrer_module` hint (issue #2233).
2888    #[test]
2889    fn unique_lookup_agrees_with_scoped_lookup() {
2890        let (index, a, b) = two_module_ambush_index();
2891        assert_eq!(
2892            lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
2893            None,
2894            "two same-named candidates: only the scoped lookup can decide, so decline"
2895        );
2896        assert_ne!(a, b, "the fixture must really hold two distinct candidates");
2897
2898        // The same index, filtered to a kind exactly one candidate has:
2899        // now `lookup_by_name`'s own byte-identity fast path returns it
2900        // regardless of scope, so the scope-free answer must match both.
2901        let mut single = SymbolIndex::default();
2902        let (&only_id, only_info) = index
2903            .symbols
2904            .iter()
2905            .find(|(id, _)| **id == a)
2906            .expect("fixture id present");
2907        single.symbols.insert(only_id, only_info.clone());
2908        single.by_name.insert("ambush".to_string(), vec![only_id]);
2909        let scope_a = ImportScope {
2910            file_module: None,
2911            qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2912            bare_imports: BTreeSet::new(),
2913            aliases: BTreeMap::new(),
2914        };
2915        let scope_none = ImportScope {
2916            file_module: None,
2917            qualified_modules: BTreeSet::new(),
2918            bare_imports: BTreeSet::new(),
2919            aliases: BTreeMap::new(),
2920        };
2921        let unique = lookup_unique_by_name(&single, "ambush", &[SymbolKind::Knot], None);
2922        assert_eq!(unique, Some(a));
2923        assert_eq!(
2924            unique,
2925            lookup_by_name(&single, &scope_a, "ambush", &[SymbolKind::Knot])
2926        );
2927        assert_eq!(
2928            unique,
2929            lookup_by_name(&single, &scope_none, "ambush", &[SymbolKind::Knot]),
2930            "the sole-candidate answer must not depend on the scope at all"
2931        );
2932        assert_eq!(
2933            lookup_unique_by_name(&single, "ambush", &[SymbolKind::External], None),
2934            None,
2935            "the kind filter still gates the match"
2936        );
2937    }
2938
2939    #[test]
2940    fn same_module_candidate_wins_over_imported_one() {
2941        let (index, a, b) = two_module_ambush_index();
2942        // A file *inside* quest_b that also imports quest_a: its own module's
2943        // `ambush` is bare-visible and wins over the imported homonym.
2944        let scope = ImportScope {
2945            file_module: Some("quest_b".to_string()),
2946            qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2947            bare_imports: BTreeSet::new(),
2948            aliases: BTreeMap::new(),
2949        };
2950        assert_eq!(
2951            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
2952            Some(b),
2953            "own-module definition beats an imported homonym"
2954        );
2955        let _ = a;
2956    }
2957
2958    /// Build a `SymbolIndex` with one `Knot` named `ambush` per module in
2959    /// `modules`, all `Public` — the shape [`two_module_ambush_index`] hands
2960    /// M-2d, generalized so a std-shaped module string can sit alongside an
2961    /// ordinary one.
2962    fn ambush_index_with_modules(modules: &[&str]) -> (SymbolIndex, Vec<DefinitionId>) {
2963        use brink_format::DefinitionTag;
2964        let mut index = SymbolIndex::default();
2965        let mut ids = Vec::new();
2966        for (i, module) in modules.iter().enumerate() {
2967            let id = DefinitionId::new(DefinitionTag::Address, 0xA + i as u64);
2968            index.symbols.insert(
2969                id,
2970                SymbolInfo {
2971                    kind: SymbolKind::Knot,
2972                    file: FileId(0),
2973                    range: TextRange::default(),
2974                    id,
2975                    name: "ambush".to_string(),
2976                    params: Vec::new(),
2977                    detail: None,
2978                    scope: None,
2979                    param_detail: None,
2980                    module: Some((*module).to_string()),
2981                    visibility: Visibility::Public,
2982                },
2983            );
2984            index
2985                .by_name
2986                .entry("ambush".to_string())
2987                .or_default()
2988                .push(id);
2989            ids.push(id);
2990        }
2991        (index, ids)
2992    }
2993
2994    /// Issue #2197, per #2080's SCOPE FENCE (`docs/decision-log.md`,
2995    /// "Stdlib mounts into `Environment`'s manifest at the producer, as
2996    /// plain source"): a std-mounted candidate must be invisible to
2997    /// bare-name resolution — not merely deprioritized — even when it is
2998    /// the *sole* candidate. Before this fix, `lookup_by_name_direct`'s
2999    /// `!multiple` fast path returned any sole candidate unconditionally
3000    /// regardless of scope, which would have let a project silently reach
3001    /// into `std::…` with zero imports.
3002    #[test]
3003    fn std_mounted_sole_candidate_is_invisible_with_no_import() {
3004        let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3005        assert_eq!(
3006            lookup_by_name(
3007                &index,
3008                &ImportScope::default(),
3009                "ambush",
3010                &[SymbolKind::Knot]
3011            ),
3012            None,
3013            "a std-mounted definition must not resolve by bare name with no `use std::…` \
3014             import — reaching it requires an explicit import, which does not exist yet \
3015             (#1582/#2167), so today it must resolve to nothing rather than silently reach std"
3016        );
3017    }
3018
3019    /// The E060 collision shape, at the resolution layer rather than the
3020    /// LIR-lowering self-identity layer `stdlib_mount_no_longer_collides_
3021    /// with_a_projects_own_scene_entered` (brink-test-harness) proves
3022    /// end to end: a project's own declared module and the std mount both
3023    /// declare `ambush`. A file *inside* the project's own module resolves
3024    /// its own `ambush` via the pre-existing `Candidacy::InScope` tier,
3025    /// which already wins the tie-break with or without this issue's std
3026    /// gate — `project_referencing_a_third_module_still_skips_a_coexisting_
3027    /// std_candidate` below is the case that actually distinguishes pre-fix
3028    /// from post-fix behavior for the `Other`/`Other` shape this gate
3029    /// targets. Kept as its own test because the `InScope` tier is a real,
3030    /// separate guarantee worth pinning on its own.
3031    #[test]
3032    fn project_own_module_wins_over_a_coexisting_std_mount_candidate() {
3033        let (index, ids) =
3034            ambush_index_with_modules(&["story::story", "std::conventions::screenplay"]);
3035        let project_id = ids[0];
3036        let scope = ImportScope {
3037            file_module: Some("story::story".to_string()),
3038            qualified_modules: BTreeSet::new(),
3039            bare_imports: BTreeSet::new(),
3040            aliases: BTreeMap::new(),
3041        };
3042        assert_eq!(
3043            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3044            Some(project_id),
3045            "a file inside `story::story` must resolve its OWN `ambush`, never the coexisting \
3046             std mount's same-named one"
3047        );
3048    }
3049
3050    /// Review finding on #2197: the test above does **not** actually
3051    /// exercise the std-`Other` exclusion — with `file_module ==
3052    /// "story::story"`, the project candidate classifies `Candidacy::
3053    /// InScope` and wins via `first_in_scope` regardless of whether the std
3054    /// gate exists at all (reverting it changes nothing about that test's
3055    /// outcome). This test instead puts the referring file in a **third**
3056    /// declared module, so *neither* candidate is `InScope`: the project's
3057    /// `ambush` classifies `Other` (a real cross-module reference this file
3058    /// has no import for) and the std mount's `ambush` also classifies
3059    /// `Other`. Without the gate, the flat fallback picks whichever `Other`
3060    /// candidate was inserted first in `by_name` — here, the std one,
3061    /// listed first — so this test fails with the gate removed and passes
3062    /// only because the std candidate is skipped before it can ever become
3063    /// `first_match`.
3064    #[test]
3065    fn project_referencing_a_third_module_still_skips_a_coexisting_std_candidate() {
3066        let (index, ids) =
3067            ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3068        let project_id = ids[1];
3069        let scope = ImportScope {
3070            file_module: Some("story::another_module".to_string()),
3071            qualified_modules: BTreeSet::new(),
3072            bare_imports: BTreeSet::new(),
3073            aliases: BTreeMap::new(),
3074        };
3075        assert_eq!(
3076            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3077            Some(project_id),
3078            "with neither candidate `InScope`, the std `Other` candidate must still be \
3079             skipped rather than winning the flat first-inserted tie-break"
3080        );
3081    }
3082
3083    /// Issue #2216 (the #2197 follow-up this doc's own "known gap" pointed
3084    /// at), narrowed by #2233: with no `referrer_module` hint at all (`None`
3085    /// — the shape every pre-#2233 caller effectively had), a std-mounted
3086    /// candidate must still be unconditionally invisible here, exactly as it
3087    /// is to [`lookup_by_name`] with the default (no-import) scope, even
3088    /// when it is the function's *sole* candidate — the case the old
3089    /// `!multiple` style fast path would otherwise return unconditionally.
3090    #[test]
3091    fn unique_lookup_excludes_std_mounted_sole_candidate() {
3092        let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3093        assert_eq!(
3094            lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
3095            None,
3096            "a std-mounted sole candidate must not resolve through the scope-free path when \
3097             the caller has no referrer-module hint — lookup_by_name returns None for it under \
3098             the default scope, so lookup_unique_by_name must agree rather than silently \
3099             reaching into std with no import"
3100        );
3101    }
3102
3103    /// The unification half of #2216: with a std-mounted candidate
3104    /// coexisting alongside one ordinary candidate, [`lookup_unique_by_name`]
3105    /// must still resolve the ordinary one (not decline as ambiguous, and
3106    /// not pick the std one) — agreeing with [`lookup_by_name`] for a scope
3107    /// where neither candidate is `InScope` (asserted below with
3108    /// `file_module = "story::another_module"`, passed through as
3109    /// `referrer_module`). That is not the *only* scope where the two
3110    /// agree — a scope with `file_module = "story::story"` (the ordinary
3111    /// candidate's own module) also agrees, since the ordinary candidate
3112    /// then classifies `InScope` and wins [`lookup_by_name`]'s own
3113    /// tie-break. The std-referrer case — once the one deliberate
3114    /// disagreement this doc used to call out — is now covered separately
3115    /// by `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
3116    /// below, now that issue #2233 threads a `referrer_module` hint through.
3117    #[test]
3118    fn unique_lookup_skips_std_candidate_and_returns_the_ordinary_one() {
3119        let (index, ids) =
3120            ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3121        let project_id = ids[1];
3122        assert_eq!(
3123            lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
3124            Some(project_id),
3125            "the std candidate must be excluded from the sole-match count entirely, leaving \
3126             the one ordinary candidate as the unique match"
3127        );
3128        let other_scope = ImportScope {
3129            file_module: Some("story::another_module".to_string()),
3130            qualified_modules: BTreeSet::new(),
3131            bare_imports: BTreeSet::new(),
3132            aliases: BTreeMap::new(),
3133        };
3134        assert_eq!(
3135            lookup_unique_by_name(
3136                &index,
3137                "ambush",
3138                &[SymbolKind::Knot],
3139                Some("story::another_module")
3140            ),
3141            lookup_by_name(&index, &other_scope, "ambush", &[SymbolKind::Knot]),
3142            "the scope-free answer must agree with the scoped one for a scope where neither \
3143             candidate is InScope"
3144        );
3145        let project_scope = ImportScope {
3146            file_module: Some("story::story".to_string()),
3147            qualified_modules: BTreeSet::new(),
3148            bare_imports: BTreeSet::new(),
3149            aliases: BTreeMap::new(),
3150        };
3151        assert_eq!(
3152            lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], Some("story::story")),
3153            lookup_by_name(&index, &project_scope, "ambush", &[SymbolKind::Knot]),
3154            "the scope-free answer must also agree with the scoped one for a scope where the \
3155             ordinary candidate (not the std one) is InScope"
3156        );
3157    }
3158
3159    /// Issue #2233: the fix for the one deliberate disagreement
3160    /// `unique_lookup_excludes_std_mounted_sole_candidate`'s old sibling doc
3161    /// used to describe (the pre-fix version of this file documented it on
3162    /// `unique_lookup_skips_std_candidate_and_returns_the_ordinary_one`). A
3163    /// referrer whose own `file_module` IS the std module keeps resolving
3164    /// the std candidate via [`lookup_by_name_direct`]'s `InScope` tier
3165    /// (std's own internal references are untouched by the #2197/#2216
3166    /// gates) — and now that `lookup_unique_by_name` is handed that same
3167    /// module string as `referrer_module`, it reproduces the identical
3168    /// answer instead of excluding the std candidate unconditionally, for
3169    /// the case that matters: the std candidate is the *sole* match once
3170    /// visible (no coexisting ordinary candidate of the same name — see
3171    /// `unique_lookup_still_declines_when_a_visible_std_sibling_is_ambiguous`
3172    /// for what happens when one does coexist).
3173    #[test]
3174    fn unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module() {
3175        let (index, ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3176        let std_id = ids[0];
3177        let std_scope = ImportScope {
3178            file_module: Some("std::conventions::screenplay".to_string()),
3179            qualified_modules: BTreeSet::new(),
3180            bare_imports: BTreeSet::new(),
3181            aliases: BTreeMap::new(),
3182        };
3183        assert_eq!(
3184            lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
3185            Some(std_id),
3186            "a referrer whose own file_module IS the std module keeps resolving the std \
3187             candidate via lookup_by_name_direct's InScope tier — std's own internal \
3188             references are untouched by the #2197/#2216 gates"
3189        );
3190        assert_eq!(
3191            lookup_unique_by_name(
3192                &index,
3193                "ambush",
3194                &[SymbolKind::Knot],
3195                Some("std::conventions::screenplay")
3196            ),
3197            lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
3198            "with the referrer's own module threaded through, lookup_unique_by_name now agrees \
3199             with lookup_by_name for a referrer inside the std tree looking up a std sibling — \
3200             the #2233 fix"
3201        );
3202    }
3203
3204    /// Issue #2249: `resolve_type_ref` (the new `RefKind::Type` arm) routes
3205    /// a TM-2 annotation through this file's own `lookup_by_name` — full
3206    /// `ImportScope`/`Candidacy` semantics — rather than
3207    /// `lir::lower::decls::lookup_global`'s narrower fallback, which
3208    /// `ShapeTable::resolve` used before this issue (deleted; see
3209    /// `brink-ir::lir::lower::structs`'s module doc). The two primitives
3210    /// disagree on exactly this shape: a referrer *inside* a std module
3211    /// referencing a **sibling** struct in the *same* std module, declared
3212    /// in a different file, with no explicit import — `lookup_global`
3213    /// excludes every std-declared candidate unconditionally in its
3214    /// fallback arm (no referrer-is-std carve-out, `decls.rs`'s own doc),
3215    /// so this would have resolved to `None` under the old
3216    /// `ShapeTable::resolve`; `lookup_by_name_direct`'s `InScope` tier
3217    /// (std's own internal references are untouched by the #2197/#2216
3218    /// gates, same as the `Knot` case `unique_lookup_reproduces_in_scope_
3219    /// std_sibling_with_referrer_module` above proves) resolves it. This is
3220    /// the one behavioral delta issue #2249's PR body must call out: a
3221    /// std convention file's own `~ temp c: Cue`-shaped annotation
3222    /// referencing a *sibling* std file's struct now gets the static-offset
3223    /// chase (`known_shape`) it silently lost before.
3224    #[test]
3225    fn resolve_type_ref_reproduces_in_scope_std_sibling_with_referrer_module() {
3226        let mut index = SymbolIndex::default();
3227        let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0E);
3228        index.symbols.insert(
3229            cue_id,
3230            SymbolInfo {
3231                kind: SymbolKind::Struct,
3232                file: FileId(9),
3233                range: TextRange::default(),
3234                id: cue_id,
3235                name: "Cue".to_string(),
3236                params: Vec::new(),
3237                detail: None,
3238                scope: None,
3239                param_detail: None,
3240                module: Some("std::conventions::screenplay".to_string()),
3241                visibility: Visibility::Public,
3242            },
3243        );
3244        index
3245            .by_name
3246            .entry("Cue".to_string())
3247            .or_default()
3248            .push(cue_id);
3249
3250        let std_scope = ImportScope {
3251            file_module: Some("std::conventions::screenplay".to_string()),
3252            qualified_modules: BTreeSet::new(),
3253            bare_imports: BTreeSet::new(),
3254            aliases: BTreeMap::new(),
3255        };
3256        let referrer_file = FileId(1); // a *different* std file, same module
3257        let uref = UnresolvedRef {
3258            path: "Cue".to_string(),
3259            range: range(0, 3),
3260            kind: RefKind::Type,
3261            scope: Scope::default(),
3262            arg_count: None,
3263            module_qualified: false,
3264        };
3265        let mut map: ResolutionMap = Vec::new();
3266        resolve_type_ref(&index, &std_scope, referrer_file, &uref, &mut map);
3267
3268        assert_eq!(
3269            map,
3270            vec![ResolvedRef {
3271                file: referrer_file,
3272                range: uref.range,
3273                target: cue_id,
3274            }],
3275            "a referrer inside std referencing a sibling std file's struct with no import \
3276             resolves via lookup_by_name_direct's InScope tier — the exact case \
3277             ShapeTable::resolve's old lookup_global-based fallback could never reach \
3278             (it excluded every std-declared candidate unconditionally, referrer or not)"
3279        );
3280    }
3281
3282    /// Issue #2249: the flip side of the test above — a scalar/tower/
3283    /// generic-head keyword (never a declared struct) must resolve to
3284    /// nothing and raise no diagnostic, because `resolve_type_ref`'s own
3285    /// doc is explicit that "no declared struct named this" is the
3286    /// legal, common case for a `RefKind::Type` reference, not an error.
3287    #[test]
3288    fn resolve_type_ref_silently_misses_a_scalar_keyword_name() {
3289        let index = SymbolIndex::default();
3290        let uref = UnresolvedRef {
3291            path: "int".to_string(),
3292            range: range(0, 3),
3293            kind: RefKind::Type,
3294            scope: Scope::default(),
3295            arg_count: None,
3296            module_qualified: false,
3297        };
3298        let mut map: ResolutionMap = Vec::new();
3299        resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
3300        assert!(
3301            map.is_empty(),
3302            "`int` never names a declared STRUCT — this must not resolve, and (unlike \
3303             RefKind::Struct's E068) resolve_type_ref never diagnoses a miss either, since a \
3304             miss here is not necessarily wrong"
3305        );
3306    }
3307
3308    /// PR #2271 review finding: `lir::lower::structs`'s own tests
3309    /// (`lookup_global_excludes_a_sole_std_declared_struct_with_no_project_
3310    /// homonym`, `lookup_global_picks_the_referrers_own_shape_when_names_
3311    /// collide`) were retargeted at `decls::lookup_global` after issue
3312    /// #2249 deleted `ShapeTable::resolve` — but annotations no longer call
3313    /// `lookup_global` at all; they go through this file's own
3314    /// `resolve_type_ref`/`lookup_by_name`/`ImportScope` machinery (this
3315    /// module's own doc on `resolve_type_ref`). Neither retargeted test, nor
3316    /// `resolve_type_ref_silently_misses_a_scalar_keyword_name` above (an
3317    /// empty index, no std/project homonym in play at all), exercises the
3318    /// std-exclusion property through the real annotation path. This test
3319    /// closes that gap's negative half: a struct only a mounted std module
3320    /// declares, referenced from an ordinary (non-std, non-importing) project
3321    /// file, must not resolve — mirroring
3322    /// `std_mounted_sole_candidate_is_invisible_with_no_import` above, but
3323    /// through `resolve_type_ref` (the real TM-2 annotation path) instead of
3324    /// `lookup_by_name` directly.
3325    #[test]
3326    fn resolve_type_ref_excludes_a_std_only_struct_with_no_project_homonym_or_import() {
3327        let mut index = SymbolIndex::default();
3328        let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0F);
3329        index.symbols.insert(
3330            cue_id,
3331            SymbolInfo {
3332                kind: SymbolKind::Struct,
3333                file: FileId(9),
3334                range: TextRange::default(),
3335                id: cue_id,
3336                name: "Cue".to_string(),
3337                params: Vec::new(),
3338                detail: None,
3339                scope: None,
3340                param_detail: None,
3341                module: Some("std::conventions::screenplay".to_string()),
3342                visibility: Visibility::Public,
3343            },
3344        );
3345        index
3346            .by_name
3347            .entry("Cue".to_string())
3348            .or_default()
3349            .push(cue_id);
3350
3351        // An ordinary project file, no `#@module`/`use` in play at all —
3352        // the default scope every non-modules file carries.
3353        let uref = UnresolvedRef {
3354            path: "Cue".to_string(),
3355            range: range(0, 3),
3356            kind: RefKind::Type,
3357            scope: Scope::default(),
3358            arg_count: None,
3359            module_qualified: false,
3360        };
3361        let mut map: ResolutionMap = Vec::new();
3362        resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
3363        assert!(
3364            map.is_empty(),
3365            "a struct only a mounted std module declares must not resolve for a `~ temp c: Cue`- \
3366             shaped annotation with no project-side homonym and no import — the sole-candidate \
3367             std-exclusion property `resolve_type_ref`'s own doc claims, unproven by any test \
3368             through this path before: {map:?}"
3369        );
3370    }
3371
3372    /// PR #2271 review finding, positive half: a project's own struct and a
3373    /// coexisting mounted std struct sharing a bare name (M-2d, issue
3374    /// #2238) — a referrer *inside* the project's own declared module must
3375    /// resolve its own struct through `resolve_type_ref`, never the std
3376    /// mount's same-named one. Mirrors `project_own_module_wins_over_a_
3377    /// coexisting_std_mount_candidate` above, but through the real
3378    /// annotation path.
3379    #[test]
3380    fn resolve_type_ref_picks_the_referrers_own_project_struct_over_a_coexisting_std_homonym() {
3381        let mut index = SymbolIndex::default();
3382        let std_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC10);
3383        index.symbols.insert(
3384            std_cue_id,
3385            SymbolInfo {
3386                kind: SymbolKind::Struct,
3387                file: FileId(9),
3388                range: TextRange::default(),
3389                id: std_cue_id,
3390                name: "Cue".to_string(),
3391                params: Vec::new(),
3392                detail: None,
3393                scope: None,
3394                param_detail: None,
3395                module: Some("std::conventions::screenplay".to_string()),
3396                visibility: Visibility::Public,
3397            },
3398        );
3399        let project_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC11);
3400        index.symbols.insert(
3401            project_cue_id,
3402            SymbolInfo {
3403                kind: SymbolKind::Struct,
3404                file: FileId(1),
3405                range: TextRange::default(),
3406                id: project_cue_id,
3407                name: "Cue".to_string(),
3408                params: Vec::new(),
3409                detail: None,
3410                scope: None,
3411                param_detail: None,
3412                module: Some("story::market".to_string()),
3413                visibility: Visibility::Public,
3414            },
3415        );
3416        for id in [std_cue_id, project_cue_id] {
3417            index.by_name.entry("Cue".to_string()).or_default().push(id);
3418        }
3419
3420        let scope = ImportScope {
3421            file_module: Some("story::market".to_string()),
3422            qualified_modules: BTreeSet::new(),
3423            bare_imports: BTreeSet::new(),
3424            aliases: BTreeMap::new(),
3425        };
3426        let uref = UnresolvedRef {
3427            path: "Cue".to_string(),
3428            range: range(0, 3),
3429            kind: RefKind::Type,
3430            scope: Scope::default(),
3431            arg_count: None,
3432            module_qualified: false,
3433        };
3434        let mut map: ResolutionMap = Vec::new();
3435        resolve_type_ref(&index, &scope, FileId(1), &uref, &mut map);
3436        assert_eq!(
3437            map,
3438            vec![ResolvedRef {
3439                file: FileId(1),
3440                range: uref.range,
3441                target: project_cue_id,
3442            }],
3443            "a `story::market` file's own `~ temp c: Cue` must resolve to `story::market`'s own \
3444             Cue, never the coexisting std mount's same-named one: {map:?}"
3445        );
3446    }
3447
3448    /// Issue #2233: threading `referrer_module` through fixes the *sole
3449    /// candidate* disagreement above, but does not (and cannot, without
3450    /// replicating `lookup_by_name_direct`'s full `InScope`-beats-`Other`
3451    /// tie-break, a strictly larger change than a referrer hint) make
3452    /// `lookup_unique_by_name` reproduce [`lookup_by_name`]'s tie-break when
3453    /// an ordinary same-name candidate coexists with the now-visible std
3454    /// sibling. There, un-excluding the std candidate makes it a *second*
3455    /// candidate rather than the resolved one, so this function declines
3456    /// (`None`) exactly per its own documented contract ("when it returns
3457    /// `None` on an ambiguous name, the caller must fall back... rather than
3458    /// guess") — a safe decline, not the silently-wrong `Some(project_id)`
3459    /// answer this exact fixture produced before #2233 (the referrer-module
3460    /// hint being `None` in every pre-#2233 call site is what caused that:
3461    /// the std candidate was excluded unconditionally, leaving the ordinary
3462    /// one as a false "sole" match).
3463    #[test]
3464    fn unique_lookup_still_declines_when_a_visible_std_sibling_is_ambiguous() {
3465        let (index, _ids) =
3466            ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3467        assert_eq!(
3468            lookup_unique_by_name(
3469                &index,
3470                "ambush",
3471                &[SymbolKind::Knot],
3472                Some("std::conventions::screenplay")
3473            ),
3474            None,
3475            "with the std candidate now visible (referrer inside its own module) alongside a \
3476             coexisting ordinary candidate, the name is genuinely ambiguous to this scope-free \
3477             function — it must decline rather than silently pick either one"
3478        );
3479    }
3480
3481    /// Issue #2233: a referrer *inside* std but in a *different* std module
3482    /// than the candidate must not gain visibility either — only an exact
3483    /// module match reproduces `InScope`; a cross-std-submodule reference is
3484    /// `Other` under `classify` (no import machinery to promote it to
3485    /// `Imported` here), so it stays excluded exactly like any other
3486    /// cross-module std reference.
3487    #[test]
3488    fn unique_lookup_still_excludes_a_different_std_sibling_module() {
3489        let (index, ids) =
3490            ambush_index_with_modules(&["std::conventions::screenplay", "std::conventions::other"]);
3491        let other_id = ids[1];
3492        assert_eq!(
3493            lookup_unique_by_name(
3494                &index,
3495                "ambush",
3496                &[SymbolKind::Knot],
3497                Some("std::conventions::other")
3498            ),
3499            Some(other_id),
3500            "the referrer's own std module's candidate still resolves (InScope, exact match)"
3501        );
3502        let screenplay_scope = ImportScope {
3503            file_module: Some("std::conventions::other".to_string()),
3504            qualified_modules: BTreeSet::new(),
3505            bare_imports: BTreeSet::new(),
3506            aliases: BTreeMap::new(),
3507        };
3508        assert_eq!(
3509            lookup_by_name(&index, &screenplay_scope, "ambush", &[SymbolKind::Knot]),
3510            Some(other_id),
3511            "sanity: lookup_by_name agrees — the referrer's own module wins, not the sibling"
3512        );
3513    }
3514
3515    /// Issue #2217: `is_std_module`/the std-invisibility gate excludes a
3516    /// name from bare-name resolution identically whether the candidate is
3517    /// the *embedded* stdlib mount or a project's own file that legitimately
3518    /// lives at a `std/…` path (`mount_stdlib`'s "project source at the same
3519    /// key wins" carve-out — both mint the same `std::…` module identity,
3520    /// by design, per #2245's "peer roots" ruling). Before this fix, the
3521    /// resulting diagnostic was a bare "unresolved name" with nothing
3522    /// distinguishing "no such symbol anywhere" from "this symbol exists,
3523    /// but only under `std::`, invisible by rule" — an author who names
3524    /// their own directory `std/` had no way to learn that from the
3525    /// diagnostic alone.
3526    #[test]
3527    fn is_std_shadowed_name_true_when_only_a_std_candidate_exists() {
3528        let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3529        assert!(
3530            is_std_shadowed_name(&index, "ambush"),
3531            "a name whose sole declaration lives under the std peer root must be reported as \
3532             std-shadowed"
3533        );
3534    }
3535
3536    #[test]
3537    fn is_std_shadowed_name_false_for_an_ordinary_project_name() {
3538        let (index, _ids) = ambush_index_with_modules(&["story::story"]);
3539        assert!(
3540            !is_std_shadowed_name(&index, "ambush"),
3541            "a name declared only in an ordinary project module must not be reported as \
3542             std-shadowed"
3543        );
3544        assert!(
3545            !is_std_shadowed_name(&index, "no_such_name"),
3546            "a name with no declaration at all must not be reported as std-shadowed either"
3547        );
3548    }
3549
3550    #[test]
3551    fn unresolved_diag_hints_at_std_shadowing_when_a_std_candidate_exists() {
3552        let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3553        let diag = unresolved_diag(
3554            &index,
3555            &ImportScope::default(),
3556            FileId(0),
3557            range(0, 6),
3558            "ambush",
3559            DiagnosticCode::E025,
3560            &[],
3561        );
3562        assert_eq!(diag.code, DiagnosticCode::E025);
3563        assert!(
3564            diag.message.contains("std::") && diag.message.contains("peer root"),
3565            "the diagnostic for a name that IS declared, but only under std, must say so \
3566             rather than reading as an ordinary unresolved-name error: {}",
3567            diag.message
3568        );
3569    }
3570
3571    #[test]
3572    fn unresolved_diag_stays_plain_when_no_std_candidate_exists() {
3573        let index = SymbolIndex::default();
3574        let diag = unresolved_diag(
3575            &index,
3576            &ImportScope::default(),
3577            FileId(0),
3578            range(0, 6),
3579            "nope",
3580            DiagnosticCode::E025,
3581            &[],
3582        );
3583        assert_eq!(
3584            diag.message,
3585            format!("{}: `nope`", DiagnosticCode::E025.title()),
3586            "a genuinely unresolved name (no std candidate anywhere) must keep the plain \
3587             message — the hint must not fire spuriously"
3588        );
3589    }
3590
3591    /// End-to-end through the real resolution entry point, not just the
3592    /// diagnostic-formatting helper directly: a project file that places its
3593    /// own `Variable` declaration under a `std/…` path (the exact landmine
3594    /// #2217 describes — nothing marks this as the embedded stdlib) is
3595    /// invisible to a bare reference from the rest of the project, and the
3596    /// `E025` this produces must carry the std-shadowing hint.
3597    #[test]
3598    fn resolve_variable_hints_std_shadowing_for_a_projects_own_std_path_file() {
3599        let mut index = SymbolIndex::default();
3600        let id = DefinitionId::new(brink_format::DefinitionTag::Address, 0xF00D);
3601        index.symbols.insert(
3602            id,
3603            SymbolInfo {
3604                kind: SymbolKind::Variable,
3605                file: FileId(0),
3606                range: TextRange::default(),
3607                id,
3608                name: "screenplay_intro".to_string(),
3609                params: Vec::new(),
3610                detail: None,
3611                scope: None,
3612                param_detail: None,
3613                module: Some("std::conventions::screenplay".to_string()),
3614                visibility: Visibility::Public,
3615            },
3616        );
3617        index
3618            .by_name
3619            .entry("screenplay_intro".to_string())
3620            .or_default()
3621            .push(id);
3622
3623        let scope = ImportScope::default();
3624        let locals: Vec<LocalSymbol> = Vec::new();
3625        let mut map: ResolutionMap = Vec::new();
3626        let mut diagnostics = Vec::new();
3627        let uref = uref("screenplay_intro", RefKind::Variable, None, None);
3628
3629        resolve_variable(
3630            &index,
3631            &scope,
3632            &locals,
3633            FileId(1),
3634            &uref,
3635            &mut map,
3636            &mut diagnostics,
3637        );
3638
3639        assert!(map.is_empty(), "a std-shadowed candidate must not resolve");
3640        assert_eq!(diagnostics.len(), 1);
3641        assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
3642        assert!(
3643            diagnostics[0].message.contains("std::"),
3644            "the real resolution path's E025 must carry the std-shadowing hint too, not just \
3645             the diagnostic-formatting helper in isolation: {}",
3646            diagnostics[0].message
3647        );
3648    }
3649
3650    #[test]
3651    fn default_scope_falls_back_to_flat_first_winner() {
3652        // Byte-identity guard: with no import context (the pre-M-2d world),
3653        // a multi-candidate lookup returns the flat first-inserted winner,
3654        // exactly as the old flat resolver did.
3655        let (index, a, _b) = two_module_ambush_index();
3656        assert_eq!(
3657            lookup_by_name(
3658                &index,
3659                &ImportScope::default(),
3660                "ambush",
3661                &[SymbolKind::Knot]
3662            ),
3663            Some(a),
3664            "no imports → flat first-winner, unchanged from pre-M-2d"
3665        );
3666    }
3667
3668    /// `ImportScope` granularity regression (issue #790 review): a bare
3669    /// import must be name-precise, matching `modules::import_covers`
3670    /// exactly. `ImportScope::new` used to collapse every import to just its
3671    /// module name (`imports.iter().map(|i| i.module.clone())`), so a bare
3672    /// `IMPORT { other } FROM quest_a` wrongly counted as importing *all* of
3673    /// `quest_a` — including its unrelated public `ambush`.
3674    #[test]
3675    fn bare_import_grants_candidacy_only_for_its_own_named_item() {
3676        let (index, _a, b) = two_module_ambush_index();
3677        let scope = ImportScope::new(
3678            None,
3679            &[
3680                Import {
3681                    module: "quest_a".to_string(),
3682                    module_range: TextRange::default(),
3683                    items: vec![ImportItem {
3684                        name: "other".to_string(),
3685                        alias: None,
3686                        range: TextRange::default(),
3687                    }],
3688                    bare: true,
3689                    range: TextRange::default(),
3690                },
3691                Import {
3692                    module: "quest_b".to_string(),
3693                    module_range: TextRange::default(),
3694                    items: vec![ImportItem {
3695                        name: "ambush".to_string(),
3696                        alias: None,
3697                        range: TextRange::default(),
3698                    }],
3699                    bare: true,
3700                    range: TextRange::default(),
3701                },
3702            ],
3703        );
3704        assert_eq!(
3705            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3706            Some(b),
3707            "bare-importing `other` from quest_a must not license quest_a's `ambush` — \
3708             only quest_b's `ambush` (actually bare-imported) is a candidate"
3709        );
3710    }
3711
3712    /// A qualified `IMPORT mod` still licenses every public export of that
3713    /// module (unlike a bare import, which is name-precise) — the
3714    /// granularity fix must not regress this path.
3715    #[test]
3716    fn qualified_import_still_grants_candidacy_for_any_export() {
3717        let (index, a, _b) = two_module_ambush_index();
3718        let scope = ImportScope::new(
3719            None,
3720            &[Import {
3721                module: "quest_a".to_string(),
3722                module_range: TextRange::default(),
3723                items: Vec::new(),
3724                bare: false,
3725                range: TextRange::default(),
3726            }],
3727        );
3728        assert_eq!(
3729            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3730            Some(a),
3731            "a qualified `IMPORT quest_a` still licenses quest_a's `ambush`"
3732        );
3733    }
3734
3735    // ── Import aliasing (issue #1590) ───────────────────────────────
3736
3737    /// The headline bug: `IMPORT { ambush AS b } FROM quest_a` must make `b`
3738    /// resolve — before the fix, `ImportItem.alias` was read only by the
3739    /// E089 duplicate check, so a reference to the alias found nothing in
3740    /// `index.by_name` (keyed by definitions' own spellings only) and
3741    /// resolution silently failed.
3742    #[test]
3743    fn aliased_bare_import_resolves_via_its_local_alias() {
3744        let (index, a, _b) = two_module_ambush_index();
3745        let scope = ImportScope::new(
3746            None,
3747            &[Import {
3748                module: "quest_a".to_string(),
3749                module_range: TextRange::default(),
3750                items: vec![ImportItem {
3751                    name: "ambush".to_string(),
3752                    alias: Some("b".to_string()),
3753                    range: TextRange::default(),
3754                }],
3755                bare: true,
3756                range: TextRange::default(),
3757            }],
3758        );
3759        assert_eq!(
3760            lookup_by_name(&index, &scope, "b", &[SymbolKind::Knot]),
3761            Some(a),
3762            "`ambush AS b` must make `b` resolve to quest_a's `ambush`"
3763        );
3764    }
3765
3766    /// Additive ruling (issue #1590 — "is the original name still
3767    /// licensed?"): brink's alias is additive, not Rust's shadow-and-revoke.
3768    /// The source spelling stays resolvable through the very same import —
3769    /// see the doc comment on [`lookup_by_name`] for the full justification
3770    /// (the fast path's byte-identity guarantee already ignores `ImportScope`
3771    /// for a globally-unique name, so a strict revoke would only sometimes
3772    /// hold).
3773    #[test]
3774    fn aliased_bare_import_also_still_resolves_via_its_original_name() {
3775        let (index, a, _b) = two_module_ambush_index();
3776        let scope = ImportScope::new(
3777            None,
3778            &[Import {
3779                module: "quest_a".to_string(),
3780                module_range: TextRange::default(),
3781                items: vec![ImportItem {
3782                    name: "ambush".to_string(),
3783                    alias: Some("b".to_string()),
3784                    range: TextRange::default(),
3785                }],
3786                bare: true,
3787                range: TextRange::default(),
3788            }],
3789        );
3790        assert_eq!(
3791            lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3792            Some(a),
3793            "the source name `ambush` must still resolve alongside its alias `b`"
3794        );
3795    }
3796
3797    /// Negative case: an alias is scoped to the exact `(module, kind)` its
3798    /// import named — it must never resolve against a same-named symbol of a
3799    /// different kind, nor leak into a file that never declared it.
3800    #[test]
3801    fn alias_does_not_resolve_the_wrong_kind() {
3802        let (index, _a, _b) = two_module_ambush_index();
3803        let scope = ImportScope::new(
3804            None,
3805            &[Import {
3806                module: "quest_a".to_string(),
3807                module_range: TextRange::default(),
3808                items: vec![ImportItem {
3809                    name: "ambush".to_string(),
3810                    alias: Some("b".to_string()),
3811                    range: TextRange::default(),
3812                }],
3813                bare: true,
3814                range: TextRange::default(),
3815            }],
3816        );
3817        assert_eq!(
3818            lookup_by_name(&index, &scope, "b", &[SymbolKind::Variable]),
3819            None,
3820            "`b` aliases a Knot; it must not resolve when a Variable is requested"
3821        );
3822    }
3823
3824    /// Negative case: a name that is neither imported nor aliased in this
3825    /// file's scope must not resolve just because *some* file's `ImportScope`
3826    /// carries an alias for it — `lookup_by_name` is per-file.
3827    #[test]
3828    fn unrelated_scope_has_no_alias_and_does_not_resolve() {
3829        let (index, _a, _b) = two_module_ambush_index();
3830        assert_eq!(
3831            lookup_by_name(&index, &ImportScope::default(), "b", &[SymbolKind::Knot]),
3832            None,
3833            "a file with no import scope must never resolve an alias it never declared"
3834        );
3835    }
3836
3837    /// Precedence when an alias collides with an in-scope direct name (see
3838    /// the doc comment on [`lookup_by_name`]): `lookup_by_name_direct` always
3839    /// runs first, so a local knot named `start` wins over an alias `start`
3840    /// that a bare import bound to a *different* knot — the alias fallback
3841    /// only ever fires once the direct lookup comes up empty. `IMPORT {
3842    /// haggle AS start } FROM quest_a` in a file that also defines `start`
3843    /// silently loses the alias to the local definition.
3844    #[test]
3845    fn alias_colliding_with_an_in_scope_direct_name_resolves_to_the_direct_name() {
3846        use brink_format::DefinitionTag;
3847        let mut index = SymbolIndex::default();
3848        let local_start = DefinitionId::new(DefinitionTag::Address, 0x51A47);
3849        index.symbols.insert(
3850            local_start,
3851            SymbolInfo {
3852                kind: SymbolKind::Knot,
3853                file: FileId(0),
3854                range: TextRange::default(),
3855                id: local_start,
3856                name: "start".to_string(),
3857                params: Vec::new(),
3858                detail: None,
3859                scope: None,
3860                param_detail: None,
3861                module: None,
3862                visibility: Visibility::Public,
3863            },
3864        );
3865        index
3866            .by_name
3867            .entry("start".to_string())
3868            .or_default()
3869            .push(local_start);
3870
3871        let (ambush_index, aliased_target, _b) = two_module_ambush_index();
3872        for (name, ids) in ambush_index.by_name {
3873            index.by_name.entry(name).or_default().extend(ids);
3874        }
3875        for (id, info) in ambush_index.symbols {
3876            index.symbols.insert(id, info);
3877        }
3878
3879        let scope = ImportScope::new(
3880            None,
3881            &[Import {
3882                module: "quest_a".to_string(),
3883                module_range: TextRange::default(),
3884                items: vec![ImportItem {
3885                    name: "ambush".to_string(),
3886                    alias: Some("start".to_string()),
3887                    range: TextRange::default(),
3888                }],
3889                bare: true,
3890                range: TextRange::default(),
3891            }],
3892        );
3893
3894        assert_eq!(
3895            lookup_by_name(&index, &scope, "start", &[SymbolKind::Knot]),
3896            Some(local_start),
3897            "a direct in-scope `start` must win over the colliding alias — \
3898             `ambush AS start` never reaches quest_a's ambush ({aliased_target:?}) \
3899             under that name"
3900        );
3901    }
3902
3903    // ── Issue #2287: module-qualified divert resolution ──────────────
3904    //
3905    // `market/barter.brink` declaring `pub flow haggle()` (module
3906    // `story::market::barter`), referenced from `story.brink`. Four rows,
3907    // the maintainer's corrected model (issue #2287's comment):
3908    //
3909    //   | import                             | `-> barter::haggle` | `-> haggle` |
3910    //   |-------------------------------------|----------------------|-------------|
3911    //   | *(none)*                            | rejected             | rejected    |
3912    //   | `use story::market::barter;`        | accepted             | rejected    |
3913    //   | `use story::market::barter::haggle;`| —                    | accepted    |
3914    //
3915    // The fourth row (`use story::market::barter::*;`, a glob import) is
3916    // not exercised here — the native grammar has no glob-`use` production
3917    // at all (`brink_syntax_native::parser::decl::use_tree` accepts only an
3918    // `IDENT` segment, a `{ … }` group, or a trailing `as` alias; no `*`
3919    // arm), so that import spelling cannot be constructed as HIR in the
3920    // first place. See this PR's body for the full finding.
3921
3922    fn haggle_index(module: &str) -> (SymbolIndex, DefinitionId) {
3923        use brink_format::DefinitionTag;
3924        let mut index = SymbolIndex::default();
3925        let id = DefinitionId::new(DefinitionTag::Address, 0x748);
3926        index.symbols.insert(
3927            id,
3928            SymbolInfo {
3929                kind: SymbolKind::Knot,
3930                file: FileId(1),
3931                range: TextRange::default(),
3932                id,
3933                name: "haggle".to_string(),
3934                params: Vec::new(),
3935                detail: None,
3936                scope: None,
3937                param_detail: None,
3938                module: Some(module.to_string()),
3939                visibility: Visibility::Public,
3940            },
3941        );
3942        index
3943            .by_name
3944            .entry("haggle".to_string())
3945            .or_default()
3946            .push(id);
3947        (index, id)
3948    }
3949
3950    fn divert_uref(path: &str, module_qualified: bool) -> UnresolvedRef {
3951        UnresolvedRef {
3952            path: path.to_string(),
3953            range: range(0, path.len() as u32),
3954            kind: RefKind::Divert,
3955            scope: Scope::default(),
3956            arg_count: None,
3957            module_qualified,
3958        }
3959    }
3960
3961    /// Bug (a): `use story::market::barter;` (a module-qualified import —
3962    /// lowered as a bare import of item `barter` from `story::market`, whose
3963    /// #1592 dual-reading also licenses `story::market::barter` as a
3964    /// qualified module candidate) must accept `-> barter::haggle`.
3965    ///
3966    /// Reverting `lookup_divert`'s `uref.module_qualified` branch makes this
3967    /// fail: the old code only ever tried `path.contains('.')`, and a
3968    /// `::`-joined path with no dot at all falls through every branch to
3969    /// `None` — reproducing the exact reported defect (`unresolved divert
3970    /// target: barter.haggle`, before this fix normalized `::` to `.` and
3971    /// lost the distinction entirely).
3972    #[test]
3973    fn qualified_divert_resolves_via_module_qualified_import() {
3974        let (index, haggle_id) = haggle_index("story::market::barter");
3975        let scope = ImportScope::new(
3976            None,
3977            &[Import {
3978                module: "story::market".to_string(),
3979                module_range: TextRange::default(),
3980                items: vec![ImportItem {
3981                    name: "barter".to_string(),
3982                    alias: None,
3983                    range: TextRange::default(),
3984                }],
3985                bare: true,
3986                range: TextRange::default(),
3987            }],
3988        );
3989        let uref = divert_uref("barter::haggle", true);
3990        assert_eq!(
3991            lookup_divert(&index, &scope, &[], &uref),
3992            Some(haggle_id),
3993            "`use story::market::barter;` must license the module-qualified \
3994             `-> barter::haggle` divert"
3995        );
3996    }
3997
3998    /// The other half of bug (a): with no import at all, `-> barter::haggle`
3999    /// must stay rejected — and the diagnostic must spell the qualifier with
4000    /// `::`, not the confusing `.` the pre-fix path-joining produced.
4001    #[test]
4002    fn qualified_divert_rejected_with_no_import_and_message_uses_double_colon() {
4003        let (index, _haggle_id) = haggle_index("story::market::barter");
4004        let scope = ImportScope::default();
4005        let uref = divert_uref("barter::haggle", true);
4006        assert_eq!(
4007            lookup_divert(&index, &scope, &[], &uref),
4008            None,
4009            "no import at all must not license the qualified divert"
4010        );
4011
4012        let mut diagnostics = Vec::new();
4013        let mut map = ResolutionMap::new();
4014        resolve_divert(
4015            &index,
4016            &scope,
4017            &[],
4018            FileId(0),
4019            &uref,
4020            &mut map,
4021            &mut diagnostics,
4022        );
4023        assert!(map.is_empty());
4024        assert_eq!(diagnostics.len(), 1);
4025        assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
4026        assert!(
4027            diagnostics[0].message.contains("barter::haggle"),
4028            "the E024 message must spell the qualified path with `::` (the \
4029             native separator actually written), not `.`: {}",
4030            diagnostics[0].message
4031        );
4032    }
4033
4034    /// Bug (b), the dangerous half: `use story::market::barter;` licenses
4035    /// the module-qualified spelling (test above) but must NOT also license
4036    /// the bare `-> haggle` — that was the over-permissive defect issue
4037    /// #2287 reported as its more dangerous half. Reverting
4038    /// `lookup_divert`'s step 2 back to `lookup_by_name` makes this fail:
4039    /// the dual-reading phantom `story::market::barter` entry in
4040    /// `qualified_modules` would classify `haggle` `Candidacy::Imported`,
4041    /// and being the sole candidate, the old fast path returned it
4042    /// unconditionally.
4043    #[test]
4044    fn bare_divert_rejected_after_qualified_module_import_only() {
4045        let (index, _haggle_id) = haggle_index("story::market::barter");
4046        let scope = ImportScope::new(
4047            None,
4048            &[Import {
4049                module: "story::market".to_string(),
4050                module_range: TextRange::default(),
4051                items: vec![ImportItem {
4052                    name: "barter".to_string(),
4053                    alias: None,
4054                    range: TextRange::default(),
4055                }],
4056                bare: true,
4057                range: TextRange::default(),
4058            }],
4059        );
4060        let uref = divert_uref("haggle", false);
4061        assert_eq!(
4062            lookup_divert(&index, &scope, &[], &uref),
4063            None,
4064            "a qualified-module-only import must not license the bare \
4065             `-> haggle` spelling"
4066        );
4067    }
4068
4069    /// Row 3 of the corrected table: `use story::market::barter::haggle;`
4070    /// (a genuine symbol-level bare import — no dual-reading ambiguity,
4071    /// since the whole prefix `story::market::barter` is unambiguously the
4072    /// module and `haggle` is unambiguously the item) must license the bare
4073    /// `-> haggle` spelling.
4074    #[test]
4075    fn bare_divert_resolves_via_symbol_level_import() {
4076        let (index, haggle_id) = haggle_index("story::market::barter");
4077        let scope = ImportScope::new(
4078            None,
4079            &[Import {
4080                module: "story::market::barter".to_string(),
4081                module_range: TextRange::default(),
4082                items: vec![ImportItem {
4083                    name: "haggle".to_string(),
4084                    alias: None,
4085                    range: TextRange::default(),
4086                }],
4087                bare: true,
4088                range: TextRange::default(),
4089            }],
4090        );
4091        let uref = divert_uref("haggle", false);
4092        assert_eq!(
4093            lookup_divert(&index, &scope, &[], &uref),
4094            Some(haggle_id),
4095            "`use story::market::barter::haggle;` must license the bare \
4096             `-> haggle` divert"
4097        );
4098    }
4099
4100    /// With no import at all, `lookup_divert` must still resolve the bare
4101    /// `-> haggle` — end-to-end it is correctly rejected (issue #2287
4102    /// confirms this row already worked), but that rejection is
4103    /// `modules::check`'s separate whole-project `E025`/`E087` gate's job,
4104    /// which only fires on a *resolved* reference (`ResolutionMap` walk,
4105    /// `check_cross_module_refs`'s own doc). This is the pre-existing,
4106    /// deliberate byte-identity guarantee `lookup_by_name_direct`'s own doc
4107    /// describes (`!multiple` sole match wins regardless of candidacy) —
4108    /// `lookup_knot_bare` must reproduce it exactly, not just for bug (b)'s
4109    /// qualified-import-only case. `crates/internal/brink-db/src/db.rs`'s
4110    /// `private_cross_module_reference_is_e087_through_db` and
4111    /// `public_cross_module_reference_without_import_is_e025` are the real
4112    /// end-to-end regression pins for this — this unit test is the
4113    /// resolve-layer half, added after those two briefly regressed to
4114    /// `E024` during this fix's own development (an over-broad first draft
4115    /// of `lookup_knot_bare` rejected *any* non-`InScope`/non-bare-imported
4116    /// candidate, not only a qualified-import-only one).
4117    #[test]
4118    fn bare_divert_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
4119        let (index, haggle_id) = haggle_index("story::market::barter");
4120        let uref = divert_uref("haggle", false);
4121        assert_eq!(
4122            lookup_divert(&index, &ImportScope::default(), &[], &uref),
4123            Some(haggle_id),
4124            "a totally unimported cross-module Knot must still resolve here — \
4125             `modules::check`'s E025/E087 gate is what rejects it, with a far \
4126             more precise diagnostic than a bare E024 would give"
4127        );
4128    }
4129
4130    // ── Issue #2298: the remainder #2287/#2296 deliberately left ─────
4131    //
4132    // Item 1 (the live gap): `resolve_function`'s "try knots" step is bug
4133    // (b)'s call-site twin — a bare `haggle()` after only a module-qualified
4134    // import must be rejected exactly like bare `-> haggle` already is.
4135    // Item 2 (latent): `lookup_divert`'s Stitch/Label/Variable+Constant
4136    // steps share the same exclusion now, plus the `Constant` omission
4137    // recorded on issue #2083's thread. Item 3: the rejection message
4138    // names the qualified-import-only candidate it skipped, mirroring
4139    // `modules::check`'s own E025 "import it from" framing.
4140
4141    fn function_uref(path: &str, arg_count: usize) -> UnresolvedRef {
4142        UnresolvedRef {
4143            path: path.to_string(),
4144            range: range(0, path.len() as u32),
4145            kind: RefKind::Function,
4146            scope: Scope::default(),
4147            arg_count: Some(arg_count),
4148            module_qualified: false,
4149        }
4150    }
4151
4152    fn qualified_module_only_scope() -> ImportScope {
4153        ImportScope::new(
4154            None,
4155            &[Import {
4156                module: "story::market".to_string(),
4157                module_range: TextRange::default(),
4158                items: vec![ImportItem {
4159                    name: "barter".to_string(),
4160                    alias: None,
4161                    range: TextRange::default(),
4162                }],
4163                bare: true,
4164                range: TextRange::default(),
4165            }],
4166        )
4167    }
4168
4169    fn symbol_level_import_scope() -> ImportScope {
4170        ImportScope::new(
4171            None,
4172            &[Import {
4173                module: "story::market::barter".to_string(),
4174                module_range: TextRange::default(),
4175                items: vec![ImportItem {
4176                    name: "haggle".to_string(),
4177                    alias: None,
4178                    range: TextRange::default(),
4179                }],
4180                bare: true,
4181                range: TextRange::default(),
4182            }],
4183        )
4184    }
4185
4186    /// Item 1's RED case: before this fix, `resolve_function`'s "try knots"
4187    /// step used the flat `lookup_by_name`/`classify`, under which the
4188    /// dual-reading phantom `story::market::barter` qualified-module entry
4189    /// classified `haggle` `Candidacy::Imported` and — being the sole
4190    /// candidate — the old fast path returned it unconditionally, exactly
4191    /// reproducing #2287 bug (b) for a call instead of a divert.
4192    #[test]
4193    fn bare_call_rejected_after_qualified_module_import_only() {
4194        let (index, _haggle_id) = haggle_index("story::market::barter");
4195        let scope = qualified_module_only_scope();
4196        let uref = function_uref("haggle", 0);
4197        let mut map = ResolutionMap::new();
4198        let mut diagnostics = Vec::new();
4199        resolve_function(
4200            &index,
4201            &scope,
4202            &[],
4203            FileId(0),
4204            &uref,
4205            &mut map,
4206            &mut diagnostics,
4207        );
4208        assert!(
4209            map.is_empty(),
4210            "a qualified-module-only import must not license the bare call \
4211             `haggle()` — issue #2298's live gap, the call-site twin of \
4212             #2287 bug (b): {map:?}"
4213        );
4214        assert_eq!(diagnostics.len(), 1);
4215        assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
4216    }
4217
4218    /// Row 3's call-site twin: a genuine symbol-level bare import
4219    /// (`use story::market::barter::haggle;`) must still license the bare
4220    /// call `haggle()` — the exclusion must not overcorrect into rejecting
4221    /// a legitimately bare-imported knot-as-tunnel-function.
4222    #[test]
4223    fn bare_call_resolves_via_symbol_level_import() {
4224        let (index, haggle_id) = haggle_index("story::market::barter");
4225        let scope = symbol_level_import_scope();
4226        let uref = function_uref("haggle", 0);
4227        let mut map = ResolutionMap::new();
4228        let mut diagnostics = Vec::new();
4229        resolve_function(
4230            &index,
4231            &scope,
4232            &[],
4233            FileId(0),
4234            &uref,
4235            &mut map,
4236            &mut diagnostics,
4237        );
4238        assert!(
4239            diagnostics.is_empty(),
4240            "unexpected diagnostics: {diagnostics:?}"
4241        );
4242        assert_eq!(
4243            map.iter().map(|r| r.target).collect::<Vec<_>>(),
4244            vec![haggle_id],
4245            "`use story::market::barter::haggle;` must license the bare call \
4246             `haggle()`"
4247        );
4248    }
4249
4250    /// Row 4's call-site twin: with no import at all, the call still
4251    /// resolves here — `modules::check`'s separate E025/E087 gate is the
4252    /// one that rejects a genuinely unimported cross-module reference, not
4253    /// this lookup (same deferral `bare_divert_still_resolves_with_no_import_deferring_to_the_e025_e087_gate`
4254    /// pins for diverts).
4255    #[test]
4256    fn bare_call_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
4257        let (index, haggle_id) = haggle_index("story::market::barter");
4258        let uref = function_uref("haggle", 0);
4259        let mut map = ResolutionMap::new();
4260        let mut diagnostics = Vec::new();
4261        resolve_function(
4262            &index,
4263            &ImportScope::default(),
4264            &[],
4265            FileId(0),
4266            &uref,
4267            &mut map,
4268            &mut diagnostics,
4269        );
4270        assert_eq!(
4271            map.iter().map(|r| r.target).collect::<Vec<_>>(),
4272            vec![haggle_id],
4273            "a totally unimported cross-module Knot must still resolve at a \
4274             call site, exactly as it does at a divert site"
4275        );
4276    }
4277
4278    /// Item 3: the module-imported-but-bare divert row gets the same
4279    /// "import it from `module`" framing `modules::check`'s own E025
4280    /// already gives the "no import at all" row, instead of a bare,
4281    /// unexplained `E024` naming just `haggle`.
4282    #[test]
4283    fn unresolved_diag_hints_at_qualified_import_only_candidate_for_divert() {
4284        let (index, _haggle_id) = haggle_index("story::market::barter");
4285        let scope = qualified_module_only_scope();
4286        let uref = divert_uref("haggle", false);
4287        let mut map = ResolutionMap::new();
4288        let mut diagnostics = Vec::new();
4289        resolve_divert(
4290            &index,
4291            &scope,
4292            &[],
4293            FileId(0),
4294            &uref,
4295            &mut map,
4296            &mut diagnostics,
4297        );
4298        assert_eq!(diagnostics.len(), 1);
4299        assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
4300        assert!(
4301            diagnostics[0]
4302                .message
4303                .contains("import it from `story::market::barter`"),
4304            "the module-imported-but-bare row must name the qualified-\
4305             import-only candidate it skipped: {}",
4306            diagnostics[0].message
4307        );
4308    }
4309
4310    /// Item 3's call-site twin.
4311    #[test]
4312    fn unresolved_diag_hints_at_qualified_import_only_candidate_for_call() {
4313        let (index, _haggle_id) = haggle_index("story::market::barter");
4314        let scope = qualified_module_only_scope();
4315        let uref = function_uref("haggle", 0);
4316        let mut map = ResolutionMap::new();
4317        let mut diagnostics = Vec::new();
4318        resolve_function(
4319            &index,
4320            &scope,
4321            &[],
4322            FileId(0),
4323            &uref,
4324            &mut map,
4325            &mut diagnostics,
4326        );
4327        assert_eq!(diagnostics.len(), 1);
4328        assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
4329        assert!(
4330            diagnostics[0]
4331                .message
4332                .contains("import it from `story::market::barter`"),
4333            "the module-imported-but-bare call row must name the qualified-\
4334             import-only candidate it skipped too: {}",
4335            diagnostics[0].message
4336        );
4337    }
4338
4339    fn constant_index(name: &str) -> (SymbolIndex, DefinitionId) {
4340        use brink_format::DefinitionTag;
4341        let mut index = SymbolIndex::default();
4342        let id = DefinitionId::new(DefinitionTag::Address, 0x749);
4343        index.symbols.insert(
4344            id,
4345            SymbolInfo {
4346                kind: SymbolKind::Constant,
4347                file: FileId(0),
4348                range: TextRange::default(),
4349                id,
4350                name: name.to_string(),
4351                params: Vec::new(),
4352                detail: None,
4353                scope: None,
4354                param_detail: None,
4355                module: None,
4356                visibility: Visibility::Public,
4357            },
4358        );
4359        index.by_name.entry(name.to_string()).or_default().push(id);
4360        (index, id)
4361    }
4362
4363    /// Item 2's `Constant` omission, credited to issue #2083's thread:
4364    /// `resolve_function`'s own "try variables and constants" step was
4365    /// widened to `[Variable, Constant]` by #2947 for the call-site gap
4366    /// #2083 reported, but `lookup_divert`'s step 6 — the divert-target
4367    /// twin of that same lookup — was left `Variable`-only. A `CONST
4368    /// target = -> knot` could never be diverted to via `-> target` even
4369    /// though the call-site twin already accepts a const-bound value.
4370    ///
4371    /// Probed directly against a synthetic index (not a real native
4372    /// compile): today's native surface has no way to construct a
4373    /// `Constant`-kind divert-target candidate through real source, since
4374    /// a `flow` always classifies `SymbolKind::Knot` — this is the only
4375    /// way to exercise step 6's kind list at all, matching the "probe it;
4376    /// if it turns out unreachable, record WHY" instruction this issue's
4377    /// build carried.
4378    #[test]
4379    fn divert_target_resolves_a_constant_symbol() {
4380        let (index, const_id) = constant_index("target");
4381        let uref = divert_uref("target", false);
4382        assert_eq!(
4383            lookup_divert(&index, &ImportScope::default(), &[], &uref),
4384            Some(const_id),
4385            "a top-level Constant-kind divert target must resolve via step \
4386             6, matching resolve_function's own [Variable, Constant] \
4387             call-site lookup (issue #2083's thread)"
4388        );
4389    }
4390
4391    /// The shadow order the `Constant` widening implies, pinned per the
4392    /// #2298 review round (finding 4): when BOTH a local (param) `target`
4393    /// AND a global `CONST target` (divert-target-holding) exist, the
4394    /// global wins — step 6 runs before the locals step 7, exactly the
4395    /// established order the `Variable` species has always had at this
4396    /// step. Before the widening the param won for the `Constant` species
4397    /// only (step 6 missed `Constant`, step 7 found the local). Same
4398    /// synthetic-index reachability caveat as
4399    /// `divert_target_resolves_a_constant_symbol` above.
4400    #[test]
4401    fn global_constant_divert_target_shadows_a_same_named_local() {
4402        let (index, const_id) = constant_index("target");
4403        let locals = vec![LocalSymbol {
4404            name: "target".to_string(),
4405            range: range(10, 6),
4406            scope: Scope::default(),
4407            kind: SymbolKind::Param,
4408            param_detail: None,
4409            annotation: None,
4410        }];
4411        let uref = divert_uref("target", false);
4412        assert_eq!(
4413            lookup_divert(&index, &ImportScope::default(), &locals, &uref),
4414            Some(const_id),
4415            "a global Constant divert target must shadow a same-named local \
4416             at a divert site, matching the Variable species' established \
4417             step-6-before-step-7 order"
4418        );
4419    }
4420
4421    /// Item 2's plumbing proof: the shared [`lookup_bare_excluding_qualified_only`]
4422    /// honors the exclusion for a non-`Knot` kind too — not just asserted
4423    /// by code inspection. Unreachable via a real native compile today for
4424    /// the same reason as the test above (no `Stitch` candidate can carry
4425    /// a real cross-module `module` yet), so this is a synthetic-index
4426    /// probe of the shared helper directly.
4427    #[test]
4428    fn lookup_bare_excluding_qualified_only_respects_the_exclusion_for_non_knot_kinds() {
4429        use brink_format::DefinitionTag;
4430        let mut index = SymbolIndex::default();
4431        let id = DefinitionId::new(DefinitionTag::Address, 0x750);
4432        index.symbols.insert(
4433            id,
4434            SymbolInfo {
4435                kind: SymbolKind::Stitch,
4436                file: FileId(1),
4437                range: TextRange::default(),
4438                id,
4439                name: "haggle".to_string(),
4440                params: Vec::new(),
4441                detail: None,
4442                scope: None,
4443                param_detail: None,
4444                module: Some("story::market::barter".to_string()),
4445                visibility: Visibility::Public,
4446            },
4447        );
4448        index
4449            .by_name
4450            .entry("haggle".to_string())
4451            .or_default()
4452            .push(id);
4453
4454        let scope = qualified_module_only_scope();
4455        assert_eq!(
4456            lookup_bare_excluding_qualified_only(&index, &scope, "haggle", &[SymbolKind::Stitch]),
4457            None,
4458            "a qualified-module-only import must not license a bare Stitch \
4459             lookup any more than it licenses a bare Knot lookup"
4460        );
4461
4462        // Should-not-fire control: no import at all still defers to the
4463        // resolved-but-unimported gate, exactly like the Knot case.
4464        assert_eq!(
4465            lookup_bare_excluding_qualified_only(
4466                &index,
4467                &ImportScope::default(),
4468                "haggle",
4469                &[SymbolKind::Stitch]
4470            ),
4471            Some(id)
4472        );
4473    }
4474}