Skip to main content

brink_analyzer/
manifest.rs

1use std::collections::BTreeMap;
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4
5use brink_format::{DefinitionId, DefinitionTag};
6use brink_ir::{
7    Diagnostic, DiagnosticCode, FileId, LocalSymbol, Scope, SymbolIndex, SymbolInfo, SymbolKind,
8    SymbolManifest, Visibility, VisibilityMark,
9};
10
11/// Apply declaration-flips-default (modules-spec §4) to an explicit
12/// `#@private`/`#@public` override, returning the effective [`Visibility`]
13/// and whether the override was *redundant* (restated the module default).
14///
15/// A declared module (`declared == true`) defaults private; an undeclared
16/// stem-module defaults public.
17fn effective_visibility(declared: bool, mark: Option<VisibilityMark>) -> (Visibility, bool) {
18    let default = if declared {
19        Visibility::Private
20    } else {
21        Visibility::Public
22    };
23    match mark {
24        None => (default, false),
25        Some(VisibilityMark::Private) => (Visibility::Private, default == Visibility::Private),
26        Some(VisibilityMark::Public) => (Visibility::Public, default == Visibility::Public),
27    }
28}
29
30/// A file's resolved module (M-1, docs/modules-spec.md §1/§5).
31///
32/// Computed upstream (in `brink-db`, where file stems, `#@module`
33/// declarations, and the INCLUDE graph are all known) and threaded into
34/// the symbol index so `DefinitionId` hashing can qualify names by
35/// module. Only a **declared** module (`#@module(name)` present) qualifies
36/// identity; an undeclared stem-module contributes nothing to the hash, so
37/// the entire pre-modules corpus keeps byte-identical `DefinitionId`s.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResolvedModule {
40    /// The module's name (stem for undeclared, `#@module` argument for
41    /// declared).
42    pub name: String,
43    /// `true` when the file (or its INCLUDE head) carries `#@module`.
44    pub declared: bool,
45    /// The module's old name, from a `#@was(old_name)` on **any** file
46    /// sharing this resolved module (M-3, docs/modules-spec.md §5) —
47    /// aggregated by `brink-db::modules::resolve_modules` so every file in
48    /// a multi-file module sees the same rename regardless of which file
49    /// declared it. `None` for a module with no recorded rename, and
50    /// always `None` for an undeclared stem-module (scoped to declared
51    /// modules only — see `ModuleDecl::was`'s doc).
52    pub was: Option<String>,
53}
54
55/// Map from file to its resolved module. Absent entries (and undeclared
56/// modules) hash by bare name — byte-identical to the pre-modules scheme.
57pub type ModuleMap = BTreeMap<FileId, ResolvedModule>;
58
59/// Merge per-file symbol manifests into a unified symbol index.
60///
61/// Returns the index and any diagnostics (e.g. duplicate definitions).
62/// Names hash by bare name (no module qualification) — the pre-modules
63/// behavior. Use [`merge_manifests_with_modules`] to qualify identity by
64/// declared module.
65pub fn merge_manifests(files: &[(FileId, &SymbolManifest)]) -> (SymbolIndex, Vec<Diagnostic>) {
66    merge_manifests_with_modules(files, &ModuleMap::new(), crate::Dialect::default(), false)
67}
68
69/// Merge per-file symbol manifests, qualifying `DefinitionId`s by each
70/// file's **declared** module (M-1, docs/modules-spec.md §5).
71///
72/// A file whose [`ResolvedModule::declared`] is `true` hashes its symbol
73/// names as `(module, name)`; every other file (undeclared stem-modules,
74/// or files absent from `modules`) hashes by bare name, byte-identically
75/// to [`merge_manifests`]. This is the byte-identity guarantee the whole
76/// pre-modules corpus relies on.
77///
78/// `dialect` gates M-2d cross-module coexistence (issue #790, decision-log
79/// "Cross-module name collisions" 2026-07-14, endgame clause): under
80/// `Dialect::Brink`, a same-name/same-kind pair whose two owning files
81/// declared *different* modules is no longer a duplicate — both are inserted
82/// and coexist, and import-scoped resolution ([`crate::ImportScope`]) binds
83/// each reference to the module its file imported. This relaxes the M-2c/#793
84/// `E096` stopgap. A duplicate within one declared module, or involving any
85/// undeclared/legacy file, still warns (`E022`/`E023`/`E026`) and drops the
86/// later definition. `merge_manifests`'s empty `ModuleMap` can never produce
87/// a declared-module pair, so its `Dialect::default()` is inert.
88///
89/// `is_native` (issue #1562 review finding) is the same widening
90/// [`is_cross_declared_module_collision`]'s own doc describes: `true` lets
91/// M-2d coexistence apply regardless of `dialect`, for callers with a real
92/// `Language` classification of the project (`brink-db`'s `symbol_index_query`,
93/// via `project_is_native`). `merge_manifests`'s `false` is byte-identical to
94/// before this parameter existed.
95pub fn merge_manifests_with_modules(
96    files: &[(FileId, &SymbolManifest)],
97    modules: &ModuleMap,
98    dialect: crate::Dialect,
99    is_native: bool,
100) -> (SymbolIndex, Vec<Diagnostic>) {
101    let mut index = SymbolIndex::default();
102    let mut diagnostics = Vec::new();
103
104    for &(file_id, manifest) in files {
105        // Only a *declared* module qualifies identity; undeclared
106        // stem-modules (and files absent from `modules`) hash bare.
107        let resolved = modules.get(&file_id).filter(|m| m.declared);
108        let module = ModuleCtx {
109            name: resolved.map(|m| m.name.as_str()),
110            // M-3 (docs/modules-spec.md §5): the module's old name, if any
111            // file sharing it declared `#@was` —
112            // `brink-db::modules::resolve_modules` aggregates this onto
113            // every file's `ResolvedModule` already, so a single per-file
114            // lookup here sees it regardless of which file in a multi-file
115            // module carried the directive.
116            was: resolved.and_then(|m| m.was.as_deref()),
117        };
118        insert_file_symbols(
119            &mut index,
120            &mut diagnostics,
121            file_id,
122            module,
123            manifest,
124            dialect,
125            is_native,
126        );
127    }
128
129    (index, diagnostics)
130}
131
132/// A file's resolved module, bundled for the `insert_*` helpers below
133/// (keeps their argument count under clippy's limit — `module` and
134/// `module_was` are always passed together).
135#[derive(Clone, Copy)]
136struct ModuleCtx<'a> {
137    /// The **declared** module's current name; `None` for an undeclared
138    /// stem-module (identity hashes bare).
139    name: Option<&'a str>,
140    /// The module's old name from a `#@was` (M-3, docs/modules-spec.md
141    /// §5), if any file sharing this module recorded one.
142    was: Option<&'a str>,
143}
144
145/// Insert every symbol declared in one file's manifest, qualifying named
146/// declarations by `module` (M-1). Locals stay unqualified (`LocalVar`,
147/// container-scoped, never serialized).
148fn insert_file_symbols(
149    index: &mut SymbolIndex,
150    diagnostics: &mut Vec<Diagnostic>,
151    file_id: FileId,
152    module: ModuleCtx<'_>,
153    manifest: &SymbolManifest,
154    dialect: crate::Dialect,
155    is_native: bool,
156) {
157    use DiagnosticCode::{E022, E023, E026};
158    use SymbolKind::{Constant, External, Knot, Label, List, ListItem, Stitch, Struct, Variable};
159
160    let groups: [(&[brink_ir::DeclaredSymbol], SymbolKind, DiagnosticCode); 9] = [
161        (&manifest.knots, Knot, E022),
162        (&manifest.stitches, Stitch, E022),
163        (&manifest.variables, Variable, E023),
164        (&manifest.constants, Constant, E023),
165        (&manifest.lists, List, E023),
166        (&manifest.structs, Struct, E023),
167        (&manifest.externals, External, E023),
168        (&manifest.labels, Label, E022),
169        (&manifest.list_items, ListItem, E026),
170    ];
171    for (syms, kind, dup_code) in groups {
172        for sym in syms {
173            insert_symbol(
174                index,
175                diagnostics,
176                file_id,
177                module,
178                sym,
179                kind,
180                dup_code,
181                dialect,
182                is_native,
183            );
184        }
185    }
186    for local in &manifest.locals {
187        insert_local(index, file_id, local);
188    }
189
190    push_transitive_aliases(index, module, manifest);
191}
192
193/// M-3 transitive alias entries (issue #1671, docs/modules-spec.md §5): a
194/// `#@was` on a knot or stitch re-keys **every** stitch/label beneath it,
195/// because their qualified names embed the container's name — but
196/// [`insert_symbol`] mints only the container's *own* alias entry, so a
197/// declared rename still lost every descendant's durable state. The loader
198/// cannot recover this at load time (a `DefinitionId` is a hash; no path can
199/// be derived from one), so it must be materialized here, where the
200/// compiler still knows every descendant's qualified path.
201///
202/// Mirrors [`insert_symbol`]'s own-alias construction, additive and
203/// independent per level — a knot renamed *simultaneously* with one of its
204/// own stitches produces one edge per level (`old_knot.new_stitch...` and
205/// `new_knot.old_stitch...`), not the doubly-old `old_knot.old_stitch...`
206/// form, the same documented limitation [`insert_symbol`]'s module+name
207/// case already carries. Table growth is bounded by the renamed container's
208/// subtree size (docs/format-spec.md's alias-table section).
209fn push_transitive_aliases(
210    index: &mut SymbolIndex,
211    module: ModuleCtx<'_>,
212    manifest: &SymbolManifest,
213) {
214    // A knot rename re-keys every stitch and label qualified under its
215    // (bare) name.
216    for knot in &manifest.knots {
217        let Some((old_name, _range)) = &knot.was else {
218            continue;
219        };
220        if old_name == &knot.name {
221            continue; // already diagnosed E095 at lowering; no bridge to mint
222        }
223        let new_prefix = format!("{}.", knot.name);
224        let old_prefix = format!("{old_name}.");
225        for (descendants, kind) in [
226            (&manifest.stitches, SymbolKind::Stitch),
227            (&manifest.labels, SymbolKind::Label),
228        ] {
229            push_prefix_bridged_aliases(index, module, descendants, kind, &new_prefix, &old_prefix);
230        }
231    }
232
233    // A stitch rename re-keys every label qualified under it. `stitch.was`
234    // is already fully qualified as `{knot}.{old_stitch}` (`lower_stitch`
235    // qualifies it with the *current* knot name before storing — see
236    // `Stitch::was`'s doc), matching `stitch.name`'s `{knot}.{new_stitch}`
237    // shape, so only labels (never stitches — stitches don't nest) need a
238    // bridge here.
239    for stitch in &manifest.stitches {
240        let Some((old_qualified, _range)) = &stitch.was else {
241            continue;
242        };
243        if old_qualified == &stitch.name {
244            continue;
245        }
246        let new_prefix = format!("{}.", stitch.name);
247        let old_prefix = format!("{old_qualified}.");
248        push_prefix_bridged_aliases(
249            index,
250            module,
251            &manifest.labels,
252            SymbolKind::Label,
253            &new_prefix,
254            &old_prefix,
255        );
256    }
257}
258
259/// For every `descendant` whose qualified name starts with `new_prefix`,
260/// mint an [`brink_format::AliasEntry`] bridging its pre-rename identity
261/// (`old_prefix` substituted for `new_prefix`) to its real, already-current
262/// id. Shared by both [`push_transitive_aliases`] levels (knot→{stitch,
263/// label}, stitch→label) — same substitution, different prefix pair.
264fn push_prefix_bridged_aliases(
265    index: &mut SymbolIndex,
266    module: ModuleCtx<'_>,
267    descendants: &[brink_ir::DeclaredSymbol],
268    kind: SymbolKind,
269    new_prefix: &str,
270    old_prefix: &str,
271) {
272    let tag = kind.definition_tag();
273    for sym in descendants {
274        let Some(rest) = sym.name.strip_prefix(new_prefix) else {
275            continue;
276        };
277        let old_qualified = format!("{old_prefix}{rest}");
278        let old_hash = hash_qualified_name(module.name, &old_qualified, tag);
279        let new_hash = hash_qualified_name(module.name, &sym.name, tag);
280        index.aliases.push(brink_format::AliasEntry {
281            old: DefinitionId::new(tag, old_hash),
282            new: DefinitionId::new(tag, new_hash),
283        });
284    }
285}
286
287/// M-2d import-scoped coexistence (issue #790, decision-log "Cross-module
288/// name collisions" 2026-07-14, endgame clause): two same-name/same-kind
289/// definitions in *different* **declared** modules are no longer a collision
290/// at all — they coexist in the index and import-scoped resolution
291/// (`resolve.rs`, [`crate::ImportScope`]) binds each reference to the module
292/// its file imported. This *relaxes* the M-2c/#793 `E096` stopgap, which had
293/// hard-errored exactly this case as a temporary guard against silent
294/// misbinding while the flat resolver was still authoritative.
295///
296/// Returns `true` when `existing`/`new_module` are such a cross-declared-
297/// module pair (so the caller must let both coexist — no diagnostic, no
298/// skip). A duplicate *within* one declared module, or involving any
299/// undeclared/legacy file, returns `false` — the ordinary inklecate-compat
300/// redefinition warning (`E022`/`E023`/`E026`) still applies and the later
301/// definition is still dropped. The `Dialect::Brink` gate preserves
302/// `strict-ink` byte-for-byte: `#@module` is brink-only syntax, so a
303/// strict-ink story never has a declared module, and this always returns
304/// `false` there — the whole compat corpus keeps its existing behavior.
305///
306/// `is_native` (issue #1562 review finding) widens the gate exactly as
307/// [`crate::strict_diagnostics`]'s own `is_native` widens `E064`'s dialect
308/// check: `dialect` is an ink-only axis — a native `.brink` project has no
309/// dialect to be wrong about (every `.brink` module is always *declared*,
310/// see `brink_db::modules::native_module_path`), so M-2d coexistence must
311/// not depend on a client having declared `dialect: "brink"` in
312/// `initializationOptions`. `true` lets cross-declared-module homonyms
313/// coexist regardless of `dialect`'s value; `false` (every pure-API caller
314/// with no `Language` classification of its own) is byte-identical to
315/// before this parameter existed.
316fn is_cross_declared_module_collision(
317    dialect: crate::Dialect,
318    is_native: bool,
319    existing_module: Option<&str>,
320    new_module: Option<&str>,
321) -> bool {
322    if dialect != crate::Dialect::Brink && !is_native {
323        return false;
324    }
325    matches!(
326        (existing_module, new_module),
327        (Some(a), Some(b)) if a != b
328    )
329}
330
331#[expect(
332    clippy::too_many_arguments,
333    reason = "internal helper threading file/module context + the per-kind duplicate code + dialect through one insertion; splitting further would just re-bundle these into an ad-hoc struct for no clarity gain"
334)]
335fn insert_symbol(
336    index: &mut SymbolIndex,
337    diagnostics: &mut Vec<Diagnostic>,
338    file: FileId,
339    module: ModuleCtx<'_>,
340    sym: &brink_ir::DeclaredSymbol,
341    kind: SymbolKind,
342    dup_code: DiagnosticCode,
343    dialect: crate::Dialect,
344    is_native: bool,
345) {
346    // Duplicate handling. A same-name/same-kind definition already in the
347    // index is normally an inklecate-compat redefinition: we warn and drop
348    // the later one. The M-2d exception (issue #790): if *every* existing
349    // same-kind definition sits in a **different declared module** from this
350    // one, they are not duplicates at all — they coexist and import-scoped
351    // resolution disambiguates them per referring file. Only a *true*
352    // duplicate (same declared module, or the undeclared/legacy world, or
353    // strict-ink where declared modules cannot exist) still warns-and-skips.
354    if let Some(existing_ids) = index.by_name.get(&sym.name) {
355        let true_duplicate = existing_ids
356            .iter()
357            .filter_map(|id| index.symbols.get(id))
358            .filter(|info| info.kind == kind)
359            .find(|existing| {
360                !is_cross_declared_module_collision(
361                    dialect,
362                    is_native,
363                    existing.module.as_deref(),
364                    module.name,
365                )
366            });
367        if let Some(_existing) = true_duplicate {
368            diagnostics.push(Diagnostic {
369                file,
370                range: sym.range,
371                message: format!("{}: `{}`", dup_code.title(), sym.name),
372                code: dup_code,
373            });
374            return;
375        }
376        // Otherwise: only cross-declared-module homonyms exist — fall through
377        // and insert this definition alongside them.
378    }
379
380    let tag = kind.definition_tag();
381    let hash = hash_qualified_name(module.name, &sym.name, tag);
382    let id = DefinitionId::new(tag, hash);
383
384    // Effective visibility (declaration-flips-default, modules-spec §4): a
385    // *declared* module (`module.name.is_some()`) defaults private; an
386    // undeclared stem-module defaults public. A `#@private`/`#@public`
387    // override flips that; restating the default is a redundant-override
388    // warning (`E092`).
389    let declared = module.name.is_some();
390    let (visibility, redundant) = effective_visibility(declared, sym.visibility);
391    if redundant {
392        diagnostics.push(Diagnostic {
393            file,
394            range: sym.range,
395            message: format!("{}: `{}`", DiagnosticCode::E092.title(), sym.name),
396            code: DiagnosticCode::E092,
397        });
398    }
399
400    index.symbols.insert(
401        id,
402        SymbolInfo {
403            kind,
404            file,
405            range: sym.range,
406            id,
407            name: sym.name.clone(),
408            params: sym.params.clone(),
409            detail: sym.detail.clone(),
410            scope: None,
411            param_detail: None,
412            module: module.name.map(str::to_string),
413            visibility,
414        },
415    );
416    index.by_name.entry(sym.name.clone()).or_default().push(id);
417
418    // M-3 (docs/modules-spec.md §5): compiled alias-table entries. Additive
419    // and independent — a definition-level `#@was` and a module-level
420    // `#@was` on the same symbol both produce an entry (each aliasing a
421    // *different* stale identity to the same current `id`); the rarer case
422    // of both renamed **simultaneously** (old module + old name together)
423    // is a known gap, not covered by either entry alone.
424    if let Some((old_name, _range)) = &sym.was {
425        let old_hash = hash_qualified_name(module.name, old_name, tag);
426        index.aliases.push(brink_format::AliasEntry {
427            old: DefinitionId::new(tag, old_hash),
428            new: id,
429        });
430    }
431    if let Some(old_module) = module.was {
432        let old_hash = hash_qualified_name(Some(old_module), &sym.name, tag);
433        index.aliases.push(brink_format::AliasEntry {
434            old: DefinitionId::new(tag, old_hash),
435            new: id,
436        });
437    }
438
439    // Warn if the symbol name shadows a built-in function — the classic
440    // uppercase ink intrinsics (`is_builtin_function`) or a T1b stdlib
441    // slice 1 / TM-3-completion lowercase free function
442    // (`is_t1b_stdlib_name`, docs/t1b-surface-spec.md §5 +
443    // docs/typed-mode-spec.md §4 — "an author-defined function with the
444    // same name shadows the builtin, with a warning diagnostic"). Fired
445    // dialect-agnostically here, same as the existing uppercase check: under
446    // `strict-ink` the T1b names aren't reserved at all, so the warning is
447    // merely informational (harmless, matches existing precedent for the
448    // uppercase set, which also doesn't consult dialect).
449    if matches!(
450        kind,
451        SymbolKind::Knot | SymbolKind::Variable | SymbolKind::Constant | SymbolKind::External
452    ) && (crate::resolve::is_builtin_function(&sym.name)
453        || crate::resolve::is_t1b_stdlib_name(&sym.name)
454        // NS-A1: `none` is the Option absence literal (variable-position,
455        // not a call name, so it lives outside `is_t1b_stdlib_name`) —
456        // shadowing it warns exactly like the call-form builtins.
457        || sym.name == "none")
458    {
459        diagnostics.push(Diagnostic {
460            file,
461            range: sym.range,
462            message: format!("{}: `{}`", DiagnosticCode::E035.title(), sym.name),
463            code: DiagnosticCode::E035,
464        });
465    }
466}
467
468fn insert_local(index: &mut SymbolIndex, file: FileId, local: &LocalSymbol) {
469    let id = local_definition_id(&local.scope, &local.name, local.kind);
470
471    index.symbols.insert(
472        id,
473        SymbolInfo {
474            kind: local.kind,
475            file,
476            range: local.range,
477            id,
478            name: local.name.clone(),
479            params: Vec::new(),
480            detail: None,
481            scope: Some(local.scope.clone()),
482            param_detail: local.param_detail.clone(),
483            // Locals are never module-qualified and always module-internal.
484            module: None,
485            visibility: Visibility::Private,
486        },
487    );
488    index
489        .by_name
490        .entry(local.name.clone())
491        .or_default()
492        .push(id);
493}
494
495/// Hash a bare (unqualified) symbol name. Equivalent to
496/// `hash_qualified_name(None, name, tag)` — retained for the local-var
497/// path, whose scope-qualified names are never module-qualified in M-1.
498fn hash_name(name: &str, tag: DefinitionTag) -> u64 {
499    hash_qualified_name(None, name, tag)
500}
501
502/// Hash a symbol name, optionally qualified by its **declared** module
503/// (M-1, docs/modules-spec.md §5).
504///
505/// The identity gate: `hash_qualified_name(None, name, tag)` writes the
506/// hasher in the exact order the pre-modules `hash_name` did — `tag` then
507/// `name` — so every undeclared stem-module symbol hashes to a
508/// byte-identical `DefinitionId`. A declared module folds its name in
509/// *before* the symbol name; `str`'s `Hash` impl self-delimits (a `0xff`
510/// sentinel after the bytes), so `(module="ab", name="c")` can never
511/// collide with `(module="a", name="bc")`.
512fn hash_qualified_name(module: Option<&str>, name: &str, tag: DefinitionTag) -> u64 {
513    let mut hasher = DefaultHasher::new();
514    tag.hash(&mut hasher);
515    if let Some(module) = module {
516        module.hash(&mut hasher);
517    }
518    name.hash(&mut hasher);
519    hasher.finish()
520}
521
522/// Compute the `DefinitionId` for a scoped local (param/temp).
523///
524/// Scope-qualifies the hash so identically-named locals in different
525/// containers get distinct ids: `knot.stitch.name`, `knot.name`, or bare
526/// `name` for an unscoped local. Shared between [`insert_local`] (project-wide
527/// merge, still used by `symbol_index`/hover/completion) and the per-file
528/// local lookup in `resolve.rs` (issue #517) so both derive the identical id
529/// for the same declaration.
530///
531/// Not file-qualified: two files that (pathologically) declare the same
532/// scope-qualified local name still collide on one `DefinitionId` in the
533/// *merged* index (slice-A finding 4, unchanged by this — a merged-index
534/// consumer, e.g. hover, can only show one). Per-file resolution
535/// (`resolve_file`) no longer goes through the merged index for locals, so
536/// the collision cannot leak into resolution correctness.
537pub(crate) fn local_definition_id(scope: &Scope, name: &str, kind: SymbolKind) -> DefinitionId {
538    let tag = kind.definition_tag();
539    let scope_prefix = match (&scope.knot, &scope.stitch) {
540        (Some(k), Some(s)) => format!("{k}.{s}."),
541        (Some(k), None) => format!("{k}."),
542        _ => String::new(),
543    };
544    let qualified = format!("{scope_prefix}{name}");
545    let hash = hash_name(&qualified, tag);
546    DefinitionId::new(tag, hash)
547}
548
549#[cfg(test)]
550#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
551mod tests {
552    use brink_format::{DefinitionId, DefinitionTag};
553    use brink_ir::{DeclaredSymbol, DiagnosticCode, FileId, SymbolManifest};
554    use rowan::{TextRange, TextSize};
555
556    use super::{
557        ModuleMap, ResolvedModule, hash_qualified_name, merge_manifests,
558        merge_manifests_with_modules,
559    };
560
561    fn range(offset: u32, len: u32) -> TextRange {
562        TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
563    }
564
565    fn sym(name: &str, offset: u32) -> DeclaredSymbol {
566        DeclaredSymbol {
567            name: name.to_string(),
568            range: range(offset, name.len() as u32),
569            params: Vec::new(),
570            detail: None,
571            visibility: None,
572            was: None,
573        }
574    }
575
576    /// A declared symbol carrying a `#@was(old_name)` record — `old_name` is
577    /// exactly what `DeclaredSymbol::was` stores (bare for a knot, already
578    /// knot-qualified for a stitch — see `Stitch::was`'s doc).
579    fn sym_with_was(name: &str, offset: u32, old_name: &str) -> DeclaredSymbol {
580        DeclaredSymbol {
581            was: Some((old_name.to_string(), range(offset, old_name.len() as u32))),
582            ..sym(name, offset)
583        }
584    }
585
586    fn bridge(old_name: &str, new_name: &str) -> (DefinitionId, DefinitionId) {
587        let tag = DefinitionTag::Address;
588        (
589            DefinitionId::new(tag, hash_qualified_name(None, old_name, tag)),
590            DefinitionId::new(tag, hash_qualified_name(None, new_name, tag)),
591        )
592    }
593
594    #[test]
595    fn duplicate_knot_emits_e022() {
596        let mut m1 = SymbolManifest::default();
597        m1.knots.push(sym("start", 0));
598
599        let mut m2 = SymbolManifest::default();
600        m2.knots.push(sym("start", 100));
601
602        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
603        let (_index, diags) = merge_manifests(&files);
604
605        assert_eq!(diags.len(), 1);
606        assert_eq!(diags[0].code, DiagnosticCode::E022);
607    }
608
609    #[test]
610    fn duplicate_variable_emits_e023() {
611        let mut m1 = SymbolManifest::default();
612        m1.variables.push(sym("score", 0));
613
614        let mut m2 = SymbolManifest::default();
615        m2.variables.push(sym("score", 100));
616
617        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
618        let (_index, diags) = merge_manifests(&files);
619
620        assert_eq!(diags.len(), 1);
621        assert_eq!(diags[0].code, DiagnosticCode::E023);
622    }
623
624    #[test]
625    fn different_kind_same_name_no_warning() {
626        let mut manifest = SymbolManifest::default();
627        manifest.knots.push(sym("thing", 0));
628        manifest.variables.push(sym("thing", 100));
629
630        let files = vec![(FileId(0), &manifest)];
631        let (_index, diags) = merge_manifests(&files);
632
633        // A knot and a variable with the same name are different kinds — no duplicate.
634        assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
635    }
636
637    #[test]
638    fn builtin_name_shadow_emits_e035() {
639        let mut manifest = SymbolManifest::default();
640        manifest.knots.push(sym("RANDOM", 0));
641
642        let files = vec![(FileId(0), &manifest)];
643        let (_index, diags) = merge_manifests(&files);
644
645        assert_eq!(diags.len(), 1);
646        assert_eq!(diags[0].code, DiagnosticCode::E035);
647    }
648
649    #[test]
650    fn non_builtin_name_no_shadow_warning() {
651        let mut manifest = SymbolManifest::default();
652        manifest.knots.push(sym("my_function", 0));
653
654        let files = vec![(FileId(0), &manifest)];
655        let (_index, diags) = merge_manifests(&files);
656
657        assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
658    }
659
660    // ── M-2d cross-module coexistence (issue #790, decision-log
661    // "Cross-module name collisions" 2026-07-14, endgame clause —
662    // relaxes the #784/#793 E096 stopgap) ─────────────────────────────
663
664    /// Two *declared* modules (different names) each defining `start` now
665    /// **coexist** under `Dialect::Brink`: no diagnostic, and *both*
666    /// definitions land in the index under the shared bare name so
667    /// import-scoped resolution can bind each referring file to the module
668    /// it imported. This is the E096 relaxation — the case #793 hard-errored
669    /// now compiles.
670    #[test]
671    fn cross_declared_module_duplicate_coexists_under_brink() {
672        let mut m1 = SymbolManifest::default();
673        m1.knots.push(sym("start", 0));
674        let mut m2 = SymbolManifest::default();
675        m2.knots.push(sym("start", 100));
676
677        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
678        let mut modules = ModuleMap::new();
679        modules.insert(
680            FileId(0),
681            ResolvedModule {
682                name: "quest".to_string(),
683                declared: true,
684                was: None,
685            },
686        );
687        modules.insert(
688            FileId(1),
689            ResolvedModule {
690                name: "town".to_string(),
691                declared: true,
692                was: None,
693            },
694        );
695
696        let (index, diags) =
697            merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
698
699        assert!(
700            diags.is_empty(),
701            "cross-declared-module homonyms coexist with no diagnostic (E096 relaxed), got {diags:?}"
702        );
703
704        // Both `start` definitions survive in the index under the shared bare
705        // name — the raw material import-scoped resolution disambiguates.
706        assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
707        // …and they are genuinely distinct ids (module-qualified identity).
708        let ids = index.by_name.get("start").expect("both starts present");
709        assert_ne!(
710            ids[0], ids[1],
711            "the two modules' `start`s have distinct ids"
712        );
713        let modules_of: std::collections::BTreeSet<Option<&str>> = ids
714            .iter()
715            .filter_map(|id| index.symbols.get(id))
716            .map(|info| info.module.as_deref())
717            .collect();
718        assert_eq!(
719            modules_of,
720            [Some("quest"), Some("town")].into_iter().collect(),
721            "one `start` per declared module"
722        );
723    }
724
725    /// Two files sharing the **same** declared module keep the ordinary
726    /// within-module `E022` warning — never `E096` — even under
727    /// `Dialect::Brink`.
728    #[test]
729    fn same_declared_module_duplicate_stays_e022_under_brink() {
730        let mut m1 = SymbolManifest::default();
731        m1.knots.push(sym("start", 0));
732        let mut m2 = SymbolManifest::default();
733        m2.knots.push(sym("start", 100));
734
735        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
736        let mut modules = ModuleMap::new();
737        for file in [FileId(0), FileId(1)] {
738            modules.insert(
739                file,
740                ResolvedModule {
741                    name: "quest".to_string(),
742                    declared: true,
743                    was: None,
744                },
745            );
746        }
747
748        let (_index, diags) =
749            merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
750
751        assert_eq!(diags.len(), 1);
752        assert_eq!(diags[0].code, DiagnosticCode::E022);
753    }
754
755    /// A declared-module/undeclared-file collision (one side has no
756    /// declared module) stays `E022` — the escalation requires *both*
757    /// sides to be declared modules.
758    #[test]
759    fn declared_vs_undeclared_duplicate_stays_e022_under_brink() {
760        let mut m1 = SymbolManifest::default();
761        m1.knots.push(sym("start", 0));
762        let mut m2 = SymbolManifest::default();
763        m2.knots.push(sym("start", 100));
764
765        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
766        let mut modules = ModuleMap::new();
767        modules.insert(
768            FileId(0),
769            ResolvedModule {
770                name: "quest".to_string(),
771                declared: true,
772                was: None,
773            },
774        );
775        // FileId(1) absent from `modules` -> undeclared, hashes bare.
776
777        let (_index, diags) =
778            merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
779
780        assert_eq!(diags.len(), 1);
781        assert_eq!(diags[0].code, DiagnosticCode::E022);
782    }
783
784    /// Under `Dialect::StrictInk` (the default) with `is_native = false`, a
785    /// cross-declared-module duplicate never escalates — `merge_manifests`'s
786    /// inert default keeps the compat corpus untouched. See
787    /// `cross_declared_module_duplicate_coexists_under_strict_ink_when_native`
788    /// below for the native counterpart (issue #1562 review finding), where
789    /// the same dialect instead coexists.
790    #[test]
791    fn cross_declared_module_duplicate_stays_e022_under_strict_ink() {
792        let mut m1 = SymbolManifest::default();
793        m1.knots.push(sym("start", 0));
794        let mut m2 = SymbolManifest::default();
795        m2.knots.push(sym("start", 100));
796
797        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
798        let mut modules = ModuleMap::new();
799        modules.insert(
800            FileId(0),
801            ResolvedModule {
802                name: "quest".to_string(),
803                declared: true,
804                was: None,
805            },
806        );
807        modules.insert(
808            FileId(1),
809            ResolvedModule {
810                name: "town".to_string(),
811                declared: true,
812                was: None,
813            },
814        );
815
816        let (_index, diags) =
817            merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, false);
818
819        assert_eq!(diags.len(), 1);
820        assert_eq!(diags[0].code, DiagnosticCode::E022);
821    }
822
823    /// Native counterpart of `cross_declared_module_duplicate_stays_e022_under_strict_ink`
824    /// above (issue #1562 review finding): under the exact same
825    /// `Dialect::StrictInk` (the default a client that never declares
826    /// `initializationOptions.dialect` gets), `is_native = true` still lets
827    /// the two declared-module `start`s coexist — a native `.brink` project
828    /// has no dialect to be wrong about, so M-2d coexistence must not depend
829    /// on a client having declared `dialect: "brink"`.
830    #[test]
831    fn cross_declared_module_duplicate_coexists_under_strict_ink_when_native() {
832        let mut m1 = SymbolManifest::default();
833        m1.knots.push(sym("start", 0));
834        let mut m2 = SymbolManifest::default();
835        m2.knots.push(sym("start", 100));
836
837        let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
838        let mut modules = ModuleMap::new();
839        modules.insert(
840            FileId(0),
841            ResolvedModule {
842                name: "quest".to_string(),
843                declared: true,
844                was: None,
845            },
846        );
847        modules.insert(
848            FileId(1),
849            ResolvedModule {
850                name: "town".to_string(),
851                declared: true,
852                was: None,
853            },
854        );
855
856        let (index, diags) =
857            merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, true);
858
859        assert!(
860            diags.is_empty(),
861            "native cross-declared-module homonyms coexist regardless of dialect, got {diags:?}"
862        );
863        assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
864    }
865
866    // ── M-1 identity gate (docs/modules-spec.md §5) ──────────────────
867
868    fn sample_manifest() -> SymbolManifest {
869        let mut m = SymbolManifest::default();
870        m.knots.push(sym("start", 0));
871        m.stitches.push(sym("start.middle", 20));
872        m.variables.push(sym("score", 60));
873        m.lists.push(sym("colors", 90));
874        m.list_items.push(sym("colors.red", 110));
875        m
876    }
877
878    /// THE critical gate: an undeclared stem-module (the entire pre-modules
879    /// corpus) must hash to byte-identical `DefinitionId`s. Proven two ways:
880    /// (1) qualifying with an *undeclared* module is equivalent to no
881    /// qualification, and (2) the merge is bit-for-bit equal to the
882    /// pre-modules `merge_manifests`.
883    #[test]
884    fn undeclared_module_definition_ids_are_byte_identical() {
885        let manifest = sample_manifest();
886        let files = vec![(FileId(0), &manifest)];
887
888        // (1) Baseline — the pre-modules derivation (no module map).
889        let (baseline, _) = merge_manifests(&files);
890
891        // (2) An *undeclared* stem-module entry must not qualify.
892        let mut modules = ModuleMap::new();
893        modules.insert(
894            FileId(0),
895            ResolvedModule {
896                name: "story".to_string(),
897                declared: false,
898                was: None,
899            },
900        );
901        let (with_undeclared, _) =
902            merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
903
904        // (3) A file absent from the map must also stay bare.
905        let (with_empty, _) = merge_manifests_with_modules(
906            &files,
907            &ModuleMap::new(),
908            crate::Dialect::default(),
909            false,
910        );
911
912        let mut base_ids: Vec<_> = baseline.symbols.keys().map(|id| id.to_raw()).collect();
913        base_ids.sort_unstable();
914        for other in [&with_undeclared, &with_empty] {
915            let mut ids: Vec<_> = other.symbols.keys().map(|id| id.to_raw()).collect();
916            ids.sort_unstable();
917            assert_eq!(
918                ids, base_ids,
919                "undeclared / absent module must produce byte-identical DefinitionIds"
920            );
921        }
922    }
923
924    /// Known-good pinned ids: hardcoded raw `DefinitionId`s for the sample
925    /// symbols under the bare (pre-modules) scheme. If any hashing input or
926    /// order ever changes, these literals break — the tripwire that would
927    /// have caught a silent regeneration of every checked-in `.inkb`.
928    #[test]
929    fn known_good_bare_definition_ids() {
930        let manifest = sample_manifest();
931        let files = vec![(FileId(0), &manifest)];
932        let (index, _) = merge_manifests(&files);
933
934        let id_of = |name: &str| -> u64 {
935            index
936                .by_name
937                .get(name)
938                .and_then(|ids| ids.first())
939                .map(|id| id.to_raw())
940                .expect("symbol present")
941        };
942
943        // These are the raw ids produced by `hash(tag, name)` — the exact
944        // derivation checked-in artifacts and saved games depend on. If any
945        // hashing input or write order regresses, these literals break.
946        assert_eq!(id_of("start"), 0x01e2_25d7_2013_19eb);
947        assert_eq!(id_of("start.middle"), 0x015d_6dc8_e16e_aef6);
948        assert_eq!(id_of("score"), 0x0293_4ea0_c935_6d8d);
949        assert_eq!(id_of("colors"), 0x03ab_176e_5431_d3b4);
950        assert_eq!(id_of("colors.red"), 0x04c5_e205_8cad_67b3);
951    }
952
953    /// A *declared* module qualifies identity — its ids differ from bare.
954    #[test]
955    fn declared_module_changes_definition_ids() {
956        let manifest = sample_manifest();
957        let files = vec![(FileId(0), &manifest)];
958        let (bare, _) = merge_manifests(&files);
959
960        let mut modules = ModuleMap::new();
961        modules.insert(
962            FileId(0),
963            ResolvedModule {
964                name: "quest".to_string(),
965                declared: true,
966                was: None,
967            },
968        );
969        let (qualified, _) =
970            merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
971
972        let bare_start = bare.by_name.get("start").and_then(|v| v.first()).copied();
973        let q_start = qualified
974            .by_name
975            .get("start")
976            .and_then(|v| v.first())
977            .copied();
978        assert!(bare_start.is_some() && q_start.is_some());
979        assert_ne!(
980            bare_start, q_start,
981            "a declared module must qualify (change) the DefinitionId"
982        );
983        // Same tag byte though — only the hash portion moves.
984        assert_eq!(
985            bare_start.unwrap().tag(),
986            q_start.unwrap().tag(),
987            "qualification changes the hash, not the tag"
988        );
989    }
990
991    // ── M-3 transitive aliases (issue #1671, docs/modules-spec.md §5)
992    // ─────────────────────────────────────────────────────────────────
993
994    /// `#@was` on a knot mints the knot's own alias entry *and* one per
995    /// descendant re-keyed only because the knot's name changed — a stitch
996    /// and a label nested directly under the knot, in this case.
997    #[test]
998    fn knot_rename_transitively_aliases_stitch_and_label() {
999        let mut manifest = SymbolManifest::default();
1000        manifest.knots.push(sym_with_was("plaza", 0, "hub"));
1001        manifest.stitches.push(sym("plaza.market", 10));
1002        manifest.labels.push(sym("plaza.done", 30));
1003
1004        let files = vec![(FileId(0), &manifest)];
1005        let (index, _diags) = merge_manifests(&files);
1006
1007        let expected = [
1008            bridge("hub", "plaza"),
1009            bridge("hub.market", "plaza.market"),
1010            bridge("hub.done", "plaza.done"),
1011        ];
1012        for (old, new) in expected {
1013            assert!(
1014                index.aliases.iter().any(|a| a.old == old && a.new == new),
1015                "missing bridge {old:?} -> {new:?}; aliases={:?}",
1016                index.aliases
1017            );
1018        }
1019        assert_eq!(
1020            index.aliases.len(),
1021            3,
1022            "one entry for the knot plus one per descendant; aliases={:?}",
1023            index.aliases
1024        );
1025    }
1026
1027    /// `#@was` on a stitch (unrelated to any knot rename) mints the stitch's
1028    /// own entry plus one for a label nested under it — `Stitch::was` is
1029    /// already qualified with the enclosing (current) knot name, so the
1030    /// bridge lands on `{knot}.{old_stitch}.{label}` ->
1031    /// `{knot}.{new_stitch}.{label}`.
1032    #[test]
1033    fn stitch_rename_transitively_aliases_its_label() {
1034        let mut manifest = SymbolManifest::default();
1035        manifest.knots.push(sym("hub", 0));
1036        manifest
1037            .stitches
1038            .push(sym_with_was("hub.bazaar", 10, "hub.market"));
1039        manifest.labels.push(sym("hub.bazaar.done", 30));
1040
1041        let files = vec![(FileId(0), &manifest)];
1042        let (index, _diags) = merge_manifests(&files);
1043
1044        let expected = [
1045            bridge("hub.market", "hub.bazaar"),
1046            bridge("hub.market.done", "hub.bazaar.done"),
1047        ];
1048        for (old, new) in expected {
1049            assert!(
1050                index.aliases.iter().any(|a| a.old == old && a.new == new),
1051                "missing bridge {old:?} -> {new:?}; aliases={:?}",
1052                index.aliases
1053            );
1054        }
1055        assert_eq!(index.aliases.len(), 2, "aliases={:?}", index.aliases);
1056    }
1057
1058    /// No `#@was` anywhere — no aliases minted at all, transitive or
1059    /// otherwise. (Guards against `push_transitive_aliases` firing on an
1060    /// empty-prefix false positive.)
1061    #[test]
1062    fn no_rename_no_aliases() {
1063        let mut manifest = SymbolManifest::default();
1064        manifest.knots.push(sym("hub", 0));
1065        manifest.stitches.push(sym("hub.market", 10));
1066        manifest.labels.push(sym("hub.market.done", 30));
1067
1068        let files = vec![(FileId(0), &manifest)];
1069        let (index, _diags) = merge_manifests(&files);
1070
1071        assert!(index.aliases.is_empty(), "aliases={:?}", index.aliases);
1072    }
1073}