Skip to main content

brink_db/
db.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use brink_analyzer::{
5    AnalysisOptions, AnalysisResult, BodyTypes, EffectRow, HarvestIndex, HarvestNames,
6    InferenceResult, InferredSig, Sig, SymbolMeta,
7};
8use brink_format::DefinitionId;
9use brink_ir::suppressions::Suppressions;
10use brink_ir::{Diagnostic, FileId, HirFile, ResolutionMap, SymbolIndex, SymbolManifest};
11use brink_syntax::Parse;
12use brink_syntax_native::Parse as NativeParse;
13use salsa::Setter as _;
14use tracing::debug;
15
16use crate::determinism::LookupMap;
17use crate::queries::{
18    BrinkDatabase, CompileProduct, DefKey, KnotChunkKey, LirProduct, ProjectInput, ResolvedProject,
19    SourceFile, analysis_query, call_site_diagnostics_query, call_site_metas_query,
20    conventions_projection_query, diagnostics_query, effects_query, harvest_completion_index_query,
21    harvest_index_query, has_errors_query, include_graph_query, infer_body_query,
22    inferred_signature_query, is_source_file, lir_knot_chunk_query, lir_prelude_decls_query,
23    lir_query, local_signature_query, lowered_query, module_map_query, parse_native_query,
24    parse_query, per_file_diagnostics_query, resolutions_index_query, resolve_query,
25    signature_query, story_data_query, suppressions_query, symbol_index_query,
26    type_diagnostics_query, type_inference_query, ufcs_resolution_query, value_meta_query,
27};
28
29/// Stateful incremental project database.
30///
31/// A thin, path-keyed shell around a [salsa](https://github.com/salsa-rs/salsa)
32/// database: file texts are salsa inputs, and every derived artifact (parse
33/// tree, HIR, symbol index, resolutions, LIR, `StoryData`) is a memoized
34/// tracked query with real dependency tracking and early cutoff. Both the
35/// compiler (one-shot) and LSP/IDE (long-lived) use this as their project
36/// model; editor overlays are plain input writes.
37pub struct ProjectDb {
38    salsa: BrinkDatabase,
39    project: ProjectInput,
40    /// Live files only — every public accessor reads through this map, so
41    /// tombstoned inputs (see `retired`) are invisible to consumers.
42    files: LookupMap<FileId, SourceFile>,
43    path_to_id: LookupMap<String, FileId>,
44    id_to_path: LookupMap<FileId, String>,
45    /// Tombstoned salsa inputs from removed files, keyed by path — the
46    /// durable path→`FileId` identity store (#536). Salsa never forgets an
47    /// input, so [`remove_file`](Self::remove_file) parks the `SourceFile`
48    /// here (text cleared) instead of dropping the handle; re-adding the
49    /// same path reinstates it, reusing its original `FileId` so the old
50    /// per-file memos are overwritten in place rather than leaking as
51    /// permanently unreachable dead entries (rust-analyzer precedent).
52    retired: LookupMap<String, SourceFile>,
53    next_id: u32,
54}
55
56impl ProjectDb {
57    /// Create an empty project database.
58    pub fn new() -> Self {
59        Self::with_id_base(0)
60    }
61
62    /// Create an empty project database whose `FileId`s start counting from
63    /// `id_base` instead of `0` (issue #1580).
64    ///
65    /// A long-lived host that keeps *multiple* independent `ProjectDb`
66    /// instances alive at once — `brink-lsp`'s per-native-project extent
67    /// partitioning, one db per governing `brink.toml` — needs every
68    /// instance's `FileId`s to be mutually disjoint: each db mints its own
69    /// `FileId`s starting at `0` internally, so two dbs each holding a
70    /// first-registered file would otherwise both mint `FileId(0)`, and a
71    /// caller merging per-project data into one `FileId`-keyed map (as
72    /// `brink-lsp`'s cross-project `ProjectAnalyses` does) would silently
73    /// conflate two unrelated files. Callers are responsible for choosing
74    /// non-overlapping `id_base` ranges (e.g. a fixed stride per project
75    /// index) — this constructor only seeds the counter, it does not police
76    /// collisions across instances it knows nothing about.
77    pub fn with_id_base(id_base: u32) -> Self {
78        let salsa = BrinkDatabase::default();
79        let project = ProjectInput::new(
80            &salsa,
81            Vec::new(),
82            None,
83            AnalysisOptions::default(),
84            None,
85            None,
86        );
87        Self {
88            salsa,
89            project,
90            files: LookupMap::new(),
91            path_to_id: LookupMap::new(),
92            id_to_path: LookupMap::new(),
93            retired: LookupMap::new(),
94            next_id: id_base,
95        }
96    }
97
98    /// Add or replace a file. An existing file's text is overwritten in
99    /// place (an input write); derived queries recompute lazily on next read.
100    ///
101    /// Path→`FileId` identity is durable (#536): re-adding a path that was
102    /// previously [`remove_file`](Self::remove_file)d reinstates its original
103    /// `FileId` and salsa input, so per-file memos are overwritten in place
104    /// instead of accumulating under freshly-minted dead ids.
105    pub fn set_file(&mut self, path: &str, source: String) -> FileId {
106        if let Some(&id) = self.path_to_id.get(path) {
107            if let Some(&file) = self.files.get(&id) {
108                file.set_text(&mut self.salsa).to(source);
109            }
110            debug!(path, id = id.0, "set_file complete");
111            return id;
112        }
113
114        // Reinstate a tombstoned input if this path existed before,
115        // otherwise mint a fresh id + input.
116        let file = if let Some(file) = self.retired.remove(path) {
117            file.set_text(&mut self.salsa).to(source);
118            file
119        } else {
120            let id = FileId(self.next_id);
121            self.next_id += 1;
122            SourceFile::new(&self.salsa, id, path.to_string(), source)
123        };
124        let id = file.file_id(&self.salsa);
125        self.path_to_id.insert(path.to_string(), id);
126        self.id_to_path.insert(id, path.to_string());
127        self.files.insert(id, file);
128
129        // A reinstated `FileId` can be smaller than later-minted ids, so a
130        // plain push would break the list's `FileId` ordering — insert at
131        // the sorted position instead.
132        let mut list = self.project.files(&self.salsa).clone();
133        let pos = list.partition_point(|f| f.file_id(&self.salsa).0 < id.0);
134        list.insert(pos, file);
135        self.project.set_files(&mut self.salsa).to(list);
136
137        debug!(path, id = id.0, "set_file complete");
138        id
139    }
140
141    /// Incrementally update a file. Identical to [`set_file`](Self::set_file):
142    /// salsa's dependency tracking decides what recomputes.
143    pub fn update_file(&mut self, path: &str, source: String) -> FileId {
144        self.set_file(path, source)
145    }
146
147    /// Remove a file from the database.
148    ///
149    /// The salsa input is tombstoned, not forgotten (#536): salsa can never
150    /// reclaim an input or the memos keyed on it, so the `SourceFile` is
151    /// parked in `retired` with its text cleared (releasing the source and
152    /// invalidating stale derived memos) while dropping out of the project
153    /// file list and every path/id map. From a consumer's view the file is
154    /// gone — enumeration, lookups, and INCLUDE resolution behave exactly as
155    /// if it never existed; re-adding the path reuses its original `FileId`.
156    pub fn remove_file(&mut self, path: &str) {
157        if let Some(id) = self.path_to_id.remove(path) {
158            self.id_to_path.remove(&id);
159            if let Some(file) = self.files.remove(&id) {
160                file.set_text(&mut self.salsa).to(String::new());
161                self.retired.insert(path.to_string(), file);
162                let list: Vec<SourceFile> = self
163                    .project
164                    .files(&self.salsa)
165                    .iter()
166                    .copied()
167                    .filter(|f| f.file_id(&self.salsa) != id)
168                    .collect();
169                self.project.set_files(&mut self.salsa).to(list);
170            }
171            if self.project.entry(&self.salsa) == Some(id) {
172                self.project.set_entry(&mut self.salsa).to(None);
173            }
174        }
175    }
176
177    /// Set the compile entry point (for the [`lir_product`](Self::lir_product)
178    /// and [`story_data`](Self::story_data) queries). The file must already
179    /// be in the database.
180    pub fn set_entry(&mut self, path: &str) -> Option<FileId> {
181        let id = self.file_id(path)?;
182        if self.project.entry(&self.salsa) != Some(id) {
183            self.project.set_entry(&mut self.salsa).to(Some(id));
184        }
185        Some(id)
186    }
187
188    /// The current compile entry point, if any.
189    pub fn entry(&self) -> Option<FileId> {
190        self.project.entry(&self.salsa)
191    }
192
193    /// Set the analysis options (host manifest + external-check severity)
194    /// used by the [`analysis`](Self::analysis) and downstream queries.
195    pub fn set_analysis_options(&mut self, options: AnalysisOptions) {
196        self.project
197            .set_analysis_options(&mut self.salsa)
198            .to(options);
199    }
200
201    /// The analysis options currently registered with the database.
202    pub fn analysis_options(&self) -> &AnalysisOptions {
203        self.project.analysis_options(&self.salsa)
204    }
205
206    /// Register the directory native `.brink` file keys are root-relative
207    /// *to* (issue #1572).
208    ///
209    /// A native file's module — and therefore every `DefinitionId` it
210    /// qualifies — is a pure function of its **root-relative** key
211    /// (decision-log 2026-07-22 "Native module identity"). `brink-driver`'s
212    /// `discover_native` already registers such keys, so a compile leaves
213    /// this `None` and nothing changes. A consumer that must key by some
214    /// other prefix — the LSP keys by absolute OS path, because every path it
215    /// holds round-trips through a `file://` URI — declares that prefix here,
216    /// and the identity it mints then matches a real compile of the same
217    /// tree byte for byte instead of embedding the machine's directory
218    /// layout. Paths not under `root` are unaffected.
219    ///
220    /// Ink (`.ink`) files never consult this: their module is their file
221    /// *stem*, which no path prefix can change.
222    pub fn set_native_root(&mut self, root: Option<String>) {
223        if self.project.native_root(&self.salsa) != &root {
224            self.project.set_native_root(&mut self.salsa).to(root);
225        }
226    }
227
228    /// The registered native source root, if any — see
229    /// [`set_native_root`](Self::set_native_root).
230    pub fn native_root(&self) -> Option<&str> {
231        self.project.native_root(&self.salsa).as_deref()
232    }
233
234    /// Register the directory `.ink` file keys are root-relative *to*
235    /// (issue #1696) — ink's sibling of [`set_native_root`](Self::set_native_root),
236    /// consulted by [`hir::root_content_scope_path`](brink_ir::hir::root_content_scope_path)'s
237    /// qualifier rather than by module identity.
238    ///
239    /// `brink-compiler/src/driver.rs`'s `prepare_driver` registers this for
240    /// every ink compile, using `brink_driver::native_source_root` (the same
241    /// root-discovery rule native compiles already use) fed the entry path.
242    /// `None` — no caller has registered a root — is byte-identical to the
243    /// pre-#1696 world: the qualifier stays the file's raw registered path.
244    pub fn set_ink_root(&mut self, root: Option<String>) {
245        if self.project.ink_root(&self.salsa) != &root {
246            self.project.set_ink_root(&mut self.salsa).to(root);
247        }
248    }
249
250    /// The registered ink source root, if any — see
251    /// [`set_ink_root`](Self::set_ink_root).
252    pub fn ink_root(&self) -> Option<&str> {
253        self.project.ink_root(&self.salsa).as_deref()
254    }
255
256    /// Look up a file's ID by path.
257    pub fn file_id(&self, path: &str) -> Option<FileId> {
258        self.path_to_id.get(path).copied()
259    }
260
261    /// Look up a file's path by ID.
262    pub fn file_path(&self, id: FileId) -> Option<&str> {
263        self.id_to_path.get(&id).map(String::as_str)
264    }
265
266    /// Iterate over all registered file IDs.
267    pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
268        let mut ids: Vec<_> = self.files.keys().copied().collect();
269        ids.sort_by_key(|id| id.0);
270        ids.into_iter()
271    }
272
273    /// Return file IDs in topological include order (included files before
274    /// the files that include them), matching ink's `INCLUDE` paste
275    /// semantics. Only `entry` and files it transitively `INCLUDE`s are
276    /// returned — see [`IncludeGraph::topological_order`] (issue #815).
277    pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
278        self.include_graph().topological_order(entry)
279    }
280
281    /// Get the parse tree for a file.
282    pub fn parse(&self, id: FileId) -> Option<&Parse> {
283        let file = self.files.get(&id)?;
284        Some(parse_query(&self.salsa, *file))
285    }
286
287    /// Get the native (`.brink`) parse tree for a file (B0.10a, the native
288    /// compile seam, issue #1106). The native-frontend sibling of
289    /// [`parse`](Self::parse) — a distinct nominal `Parse` type. This runs the
290    /// native parser regardless of the file's extension; the extension-based
291    /// frontend dispatch that decides which parser *lowering* uses lives in
292    /// `lowered_query`, so `parse()` stays ink-typed and untouched for the
293    /// LSP/IDE ink path.
294    pub fn parse_native(&self, id: FileId) -> Option<&NativeParse> {
295        let file = self.files.get(&id)?;
296        Some(parse_native_query(&self.salsa, *file))
297    }
298
299    /// Get the HIR for a file. `None` for an unknown file id, or for a
300    /// tracked file [`is_source_file`] excludes (issue #2329 review
301    /// finding): this per-file accessor reads `lowered_query` directly, so
302    /// without this gate a `brink.toml`/`.md`/`.json` document would still
303    /// return its bogus ink-lowered HIR.
304    pub fn hir(&self, id: FileId) -> Option<&HirFile> {
305        let file = self.files.get(&id)?;
306        if !is_source_file(file.path(&self.salsa)) {
307            return None;
308        }
309        Some(&lowered_query(&self.salsa, self.project, *file).hir)
310    }
311
312    /// Get the symbol manifest for a file. `None` for an unknown file id, or
313    /// for a tracked file [`is_source_file`] excludes — see [`Self::hir`]'s
314    /// doc (issue #2329 review finding).
315    pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
316        let file = self.files.get(&id)?;
317        if !is_source_file(file.path(&self.salsa)) {
318            return None;
319        }
320        Some(&lowered_query(&self.salsa, self.project, *file).manifest)
321    }
322
323    /// Get the source text for a file.
324    pub fn source(&self, id: FileId) -> Option<&str> {
325        let file = self.files.get(&id)?;
326        Some(file.text(&self.salsa).as_str())
327    }
328
329    /// Get per-file diagnostics (parse + lowering). `None` for an unknown
330    /// file id, or for a tracked file [`is_source_file`] excludes — see
331    /// [`Self::hir`]'s doc (issue #2329 review finding).
332    pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
333        let file = self.files.get(&id)?;
334        if !is_source_file(file.path(&self.salsa)) {
335            return None;
336        }
337        Some(
338            lowered_query(&self.salsa, self.project, *file)
339                .diagnostics
340                .as_slice(),
341        )
342    }
343
344    /// Get the B0.3 HIR admission validator's output for a file
345    /// (docs/hir-admission-contract.md §4.2, issue #1172) — kept separate
346    /// from [`Self::file_diagnostics`] because it is non-suppressible
347    /// (never routed through `apply_suppressions`). `None` for an unknown
348    /// file id, or for a tracked file [`is_source_file`] excludes — see
349    /// [`Self::hir`]'s doc (issue #2329 review finding).
350    pub fn admission_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
351        let file = self.files.get(&id)?;
352        if !is_source_file(file.path(&self.salsa)) {
353            return None;
354        }
355        Some(
356            lowered_query(&self.salsa, self.project, *file)
357                .admission
358                .as_slice(),
359        )
360    }
361
362    /// Get suppression directives for a file — the text-scanned
363    /// `brink-disable`/`brink-expect` comments merged with the file's
364    /// HIR-derived `@[allow(…)]` scopes (issue #1161), i.e. parsed ∪
365    /// HIR-derived, not parsed alone.
366    pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
367        let file = self.files.get(&id)?;
368        Some(suppressions_query(&self.salsa, *file))
369    }
370
371    /// Rebuild include graph edges for all files.
372    ///
373    /// No-op since the salsa migration: the include graph is a tracked query
374    /// over the full file set and is always complete. Kept so batch-loading
375    /// call sites need no change.
376    pub fn rebuild_include_graph(&mut self) {}
377
378    /// Detect cycles in the include graph.
379    ///
380    /// Returns the first cycle found as an ordered path of file IDs.
381    pub fn find_cycle(&self) -> Option<Vec<FileId>> {
382        self.include_graph().find_cycle()
383    }
384
385    /// Compute independent projects — the unit every editor surface scopes
386    /// itself to (the LSP analyzes one project at a time, and navigation only
387    /// ever sees the files of the project the cursor's file belongs to).
388    ///
389    /// Returns `(root, members)` pairs sorted by root `FileId`; each
390    /// project's members are sorted by `FileId`.
391    ///
392    /// One rule per frontend:
393    ///
394    /// - **Ink** groups by `INCLUDE` reachability — a root file plus its
395    ///   transitive `INCLUDE` closure (see
396    ///   [`IncludeGraph::compute_projects`](crate::include_graph::IncludeGraph::compute_projects)).
397    ///   Unchanged.
398    /// - **Native `.brink`** files are *one* project, all of them. Issue
399    ///   #1562: `.brink` has no `INCLUDE` (the module system replaced it), so
400    ///   running them through the ink rule made every native file its own
401    ///   single-file project and broke go-to-definition, find-references,
402    ///   completion, and diagnostics across every real native workspace. The
403    ///   rule here is the one
404    ///   [`compilation_closure_files`](crate::queries::compilation_closure_files)
405    ///   already applies to codegen (decision-log *"Native multi-file
406    ///   linking"*, 2026-07-23): the discovered module set **is** the
407    ///   compilation unit, so it is also the editor's scope. No second
408    ///   discovery mechanism is involved — this partitions the files the db
409    ///   already holds.
410    ///
411    /// The two sets are disjoint, so an `INCLUDE` in an ink file that names a
412    /// `.brink` target (not expressible in native, and meaningless as ink)
413    /// contributes no edge: the native file is in the native project only.
414    pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
415        let (native, ink): (Vec<FileId>, Vec<FileId>) =
416            self.file_ids().partition(|&id| self.is_native(id));
417
418        let mut projects = self.include_graph().compute_projects(&ink);
419        if let Some(root) = self.native_project_root(&native) {
420            projects.push((root, native));
421        }
422        projects.sort_by_key(|(root, _)| root.0);
423        projects
424    }
425
426    /// Whether `id` is a native (`.brink`) module rather than an ink file.
427    ///
428    /// `pub` (issue #1562 review finding) so a caller running the off-db
429    /// `brink_analyzer::analyze_with_modules` pass per project root —
430    /// `brink-lsp`'s `analysis_loop`, which needs the same "does this
431    /// project's dialect axis even apply" answer
432    /// [`crate::queries::project_is_native`] gives the salsa-backed
433    /// `symbol_index_query` — can ask it of a project's root `FileId`
434    /// without rederiving [`crate::queries::file_language`] itself.
435    pub fn is_native(&self, id: FileId) -> bool {
436        self.file_path(id).is_some_and(|path| {
437            crate::queries::file_language(path) == crate::queries::Language::Native
438        })
439    }
440
441    /// Whether **every recognized source file** (`.ink` or `.brink`) this db
442    /// holds is a native (`.brink`) module — `false` for an empty db, one
443    /// holding even a single ink source file, or one whose tracked files are
444    /// all non-source documents. A tracked file with neither extension (a
445    /// project's own `brink.toml`, e.g. — issue #2318) does not count either
446    /// way; see [`crate::queries::project_is_all_native`]'s doc for the full
447    /// reasoning and the bug this exemption fixes.
448    ///
449    /// The whole-db view of [`crate::queries::project_is_all_native`], for a
450    /// caller that analyzes this db's entire file set as one unit off-db
451    /// (`IdeSession`, whose editor analysis runs
452    /// [`brink_analyzer::analyze_with_modules`] over
453    /// [`analysis_inputs`](Self::analysis_inputs)). That flag is
454    /// whole-project, so it is only correct when the set is *entirely*
455    /// native: a mixed set must analyze as ink, or an ink file would get the
456    /// native arm of passes that would then mis-judge it.
457    ///
458    /// Distinct from [`is_native`](Self::is_native), which answers for one
459    /// file and is what a per-project caller (`brink-lsp`'s `analysis_loop`,
460    /// which analyzes each project root separately) asks of its root.
461    pub fn is_all_native(&self) -> bool {
462        crate::queries::project_is_all_native(&self.salsa, self.project)
463    }
464
465    /// The root of the single native project: the file whose **path** sorts
466    /// first (`FileId` breaking a tie that paths cannot actually produce).
467    /// `None` when the db holds no native file.
468    ///
469    /// Keyed on the path rather than on the `FileId` — which is how
470    /// [`compilation_closure_files`](crate::queries::compilation_closure_files)
471    /// orders the same file set — because a project root is *identity*, not
472    /// just order: it keys the published per-project analysis and names the
473    /// project in multi-project diagnostics. `FileId`s are minted in
474    /// registration order, which for a long-lived LSP session is `didOpen`
475    /// order and varies run to run; the path does not.
476    fn native_project_root(&self, native: &[FileId]) -> Option<FileId> {
477        native.iter().copied().min_by(|&a, &b| {
478            self.file_path(a)
479                .unwrap_or_default()
480                .cmp(self.file_path(b).unwrap_or_default())
481                .then(a.0.cmp(&b.0))
482        })
483    }
484
485    /// All files reachable from `entry` via the forward `INCLUDE` graph,
486    /// `entry` included.
487    ///
488    /// A forward DFS over `INCLUDE` edges (transitive). The result is a
489    /// [`BTreeSet`], so iteration order is deterministic regardless of graph
490    /// internals — callers that compare or render the set get stable output.
491    pub fn reachable_from(&self, entry: FileId) -> BTreeSet<FileId> {
492        self.include_graph().reachable_from(entry)
493    }
494
495    /// Snapshot analysis inputs for a subset of files.
496    ///
497    /// Like `analysis_inputs()` but filtered to the given set.
498    pub fn analysis_inputs_for(
499        &self,
500        file_ids: &[FileId],
501    ) -> Vec<(FileId, HirFile, SymbolManifest)> {
502        let mut inputs: Vec<_> = file_ids
503            .iter()
504            .filter_map(|&id| {
505                let file = self.files.get(&id)?;
506                let lowered = lowered_query(&self.salsa, self.project, *file);
507                Some((id, lowered.hir.clone(), lowered.manifest.clone()))
508            })
509            .collect();
510        inputs.sort_by_key(|(id, _, _)| id.0);
511        inputs
512    }
513
514    /// Snapshot all analysis inputs for background analysis.
515    ///
516    /// Returns `(FileId, HirFile, SymbolManifest)` tuples cloned out of the db,
517    /// so the caller can run `brink_analyzer::analyze_with_modules` (with
518    /// [`module_map`](Self::module_map), also snapshotted) without holding
519    /// the lock. Issue #1526: a bare `brink_analyzer::analyze()` /
520    /// `analyze_with_options` over these inputs is module-*blind* and mints
521    /// different `DefinitionId`s than this db's own queries for native
522    /// `.brink` files — see [`module_map`](Self::module_map)'s doc.
523    pub fn analysis_inputs(&self) -> Vec<(FileId, HirFile, SymbolManifest)> {
524        let ids: Vec<_> = self.file_ids().collect();
525        self.analysis_inputs_for(&ids)
526    }
527
528    /// Snapshot file metadata for diagnostic publishing.
529    ///
530    /// Returns `(FileId, path, source)` tuples for all files in the db.
531    pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
532        let mut meta: Vec<_> = self
533            .files
534            .iter()
535            .filter_map(|(&id, file)| {
536                let path = self.id_to_path.get(&id)?.clone();
537                Some((id, path, file.text(&self.salsa).clone()))
538            })
539            .collect();
540        meta.sort_by_key(|(id, _, _)| id.0);
541        meta
542    }
543
544    // ── Query surface (scripting-substrate spec §4) ──────────────────
545
546    /// The merged project-wide symbol index (layer 2, `symbol_index()`).
547    pub fn symbol_index(&self) -> Arc<SymbolIndex> {
548        Arc::clone(&symbol_index_query(&self.salsa, self.project).0)
549    }
550
551    /// Indexing diagnostics (duplicate definitions, built-in shadowing)
552    /// produced alongside [`symbol_index`](Self::symbol_index).
553    pub fn symbol_index_diagnostics(&self) -> &[Diagnostic] {
554        &symbol_index_query(&self.salsa, self.project).1
555    }
556
557    /// The project-wide harvest index (layer 2, issue #2114,
558    /// `docs/prose-dialect-spec.md` §5): every `@NAME` cue payload and every
559    /// inline-markup span kind/attribute name written anywhere in the
560    /// project, upgraded by the registered host manifest's `markup`
561    /// vocabulary where one is declared. The compiler-side sibling of
562    /// [`symbol_index`](Self::symbol_index) — a completion consumer reads
563    /// this the same way it reads that index, and gets the same per-file
564    /// [`lowered_query`] early cutoff the symbol index has: an edit
565    /// backdates this memo when it backdates the symbol index's
566    /// `lowered_query` half, but this index also depends on the registered
567    /// host manifest, so a manifest-only edit backdates this memo without
568    /// touching the symbol index at all (see
569    /// [`harvest_index_query`](crate::queries::harvest_index_query)'s own
570    /// doc for the full dependency set).
571    pub fn harvest_index(&self) -> Arc<HarvestIndex> {
572        Arc::clone(harvest_index_query(&self.salsa, self.project))
573    }
574
575    /// The harvest index's range-free completion projection (issue #2134):
576    /// every harvested cue and span/attribute *name*, with every site's
577    /// `TextRange` dropped. This is the query a keystroke-driven completion
578    /// path should read instead of [`harvest_index`](Self::harvest_index)
579    /// itself — see [`harvest_completion_index_query`]'s own doc for why
580    /// the raw index can never `Eq`-cutoff.
581    pub fn harvest_completion_names(&self) -> Arc<HarvestNames> {
582        Arc::clone(harvest_completion_index_query(&self.salsa, self.project))
583    }
584
585    /// The conventions projection (issue #2111, NS-T seam 1/6): every
586    /// `@[convention]` handler declared in the project's one configured
587    /// conventions module, ascending by `order` — "THE SOLE EDITOR
588    /// INTERCHANGE" the design-backport comment on #2111 names
589    /// (`docs/decision-log.md` 2026-08-03). Reads the `[project] conventions`
590    /// pointer, the project module map, the resolved conventions module's
591    /// transitive `IMPORT` closure (`import_closure_query`, issue #2111
592    /// finding 3), and every file in that closure's own `lowered_query`
593    /// output — see `conventions_projection_query`'s doc for the exact
594    /// dependency set, and `brink_ir::ConventionsProjection`'s doc for the
595    /// one part of #2111 this still does not deliver: it is not yet
596    /// serialized into `.inkb`/`StoryData` (the attach schema IS now
597    /// resolved to its fields and types, not merely a struct name — that
598    /// gap closed in the 2026-08-04 continuation).
599    pub fn conventions_projection(&self) -> Arc<brink_ir::ConventionsProjection> {
600        Arc::clone(conventions_projection_query(&self.salsa, self.project))
601    }
602
603    /// Every file's resolved module (M-1, docs/modules-spec.md §1/§5) — the
604    /// map that qualifies `DefinitionId` identity, built here from file
605    /// stems, `#@module` declarations, the INCLUDE graph, and (for native
606    /// `.brink` files) the path-derived `story::…` module.
607    ///
608    /// Exposed (issue #1526) for callers that must run
609    /// [`brink_analyzer::analyze_with_modules`] *outside* the db — the LSP's
610    /// background analysis pass and [`analysis_inputs`](Self::analysis_inputs)
611    /// consumers generally — so their `DefinitionId`s match the ones this
612    /// db's per-def queries ([`effects`](Self::effects),
613    /// [`signature`](Self::signature), [`infer_body`](Self::infer_body)) are
614    /// keyed by. Identity is minted here and nowhere else.
615    ///
616    /// The map's *diagnostics* half is
617    /// [`module_map_diagnostics`](Self::module_map_diagnostics) — an
618    /// off-db `analyze_with_modules` pass has to fold it back in itself
619    /// (issue #1553).
620    pub fn module_map(&self) -> &brink_analyzer::ModuleMap {
621        &module_map_query(&self.salsa, self.project).0
622    }
623
624    /// Stem-collision diagnostics (`E085`) produced alongside
625    /// [`module_map`](Self::module_map): a file with no `#@module` whose
626    /// stem is some *other* file's declared module name.
627    ///
628    /// A db-driven compile picks these up through
629    /// [`symbol_index_diagnostics`](Self::symbol_index_diagnostics), which
630    /// folds them in. A caller that instead runs
631    /// [`brink_analyzer::analyze_with_modules`] outside the db (the LSP's
632    /// background pass, `IdeSession`'s editor analysis) gets only the
633    /// analyzer's own diagnostics, so before issue #1553 the collision was
634    /// silently dropped on every editor surface. Such callers must snapshot
635    /// this alongside [`module_map`](Self::module_map) and extend their
636    /// result with the entries belonging to their file set.
637    pub fn module_map_diagnostics(&self) -> &[Diagnostic] {
638        &module_map_query(&self.salsa, self.project).1
639    }
640
641    /// One file's resolved references + resolution diagnostics (layer 2,
642    /// `resolve(FileId)`).
643    pub fn resolve(&self, id: FileId) -> Option<(Arc<ResolutionMap>, &[Diagnostic])> {
644        let file = self.files.get(&id)?;
645        let (map, diags) = resolve_query(&self.salsa, self.project, *file);
646        Some((Arc::clone(map), diags.as_slice()))
647    }
648
649    /// Per-declaration signature stub (layer 2, `signature(def)`). `None`
650    /// for an unknown definition id.
651    pub fn signature(&self, def: DefinitionId) -> Option<Arc<Sig>> {
652        signature_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
653    }
654
655    /// Signature stub for a **local** (`Param`/`Temp`) `def`, declared in
656    /// `id` (issue #530): the per-file locals path [`signature`](Self::signature)
657    /// itself can't take — see `local_signature_query`'s doc for why a
658    /// local's `DefinitionId` needs a caller-supplied file. `None` for an
659    /// unknown file id or a `def` not declared as a local in that file
660    /// (including a declaration id — those stay [`signature`](Self::signature)'s
661    /// job).
662    pub fn local_signature(&self, id: FileId, def: DefinitionId) -> Option<Arc<Sig>> {
663        let file = *self.files.get(&id)?;
664        local_signature_query(
665            &self.salsa,
666            self.project,
667            file,
668            DefKey::new(&self.salsa, def),
669        )
670    }
671
672    /// Full cross-file analysis over all files, honoring the registered
673    /// [`AnalysisOptions`]. Memoized; module-aware — identical to
674    /// `brink_analyzer::analyze_with_modules` over
675    /// [`analysis_inputs`](Self::analysis_inputs) and
676    /// [`module_map`](Self::module_map) by construction. For native
677    /// `.brink` files this is *not* identical to `analyze_with_options`
678    /// (module-blind), which mints different `DefinitionId`s — see
679    /// [`module_map`](Self::module_map)'s doc (issue #1526).
680    pub fn analysis(&self) -> &AnalysisResult {
681        analysis_query(&self.salsa, self.project)
682    }
683
684    /// Index + resolutions, no diagnostics (issue #632 / FG-3 — the
685    /// RESOLUTIONS/INDEX half of [`analysis`](Self::analysis), split off
686    /// from the diagnostics half so a diagnostics-only `AnalysisOptions`
687    /// edit leaves this `Arc`'s pointer identity untouched).
688    pub fn resolutions_index(&self) -> Arc<ResolvedProject> {
689        resolutions_index_query(&self.salsa, self.project)
690    }
691
692    /// One file's per-file diagnostic contributors — structural validation,
693    /// the dialect gate, and (brink dialect only) annotation-content checks
694    /// (issue #632 / FG-3). `None` for an unknown file id. A body edit in a
695    /// *different* file leaves this `Arc`'s pointer identity untouched.
696    pub fn per_file_diagnostics(&self, id: FileId) -> Option<Arc<Vec<Diagnostic>>> {
697        let file = *self.files.get(&id)?;
698        Some(per_file_diagnostics_query(&self.salsa, self.project, file))
699    }
700
701    /// One file's VAR/CONST/LIST initializer/doc enrichment (issue #750 /
702    /// FG-3 completion) — purely presentational `symbol_meta` entries, no
703    /// diagnostics. `None` for an unknown file id. A body edit in a
704    /// *different* file leaves this `Arc`'s pointer identity untouched.
705    pub fn file_value_meta(&self, id: FileId) -> Option<Arc<BTreeMap<DefinitionId, SymbolMeta>>> {
706        let file = *self.files.get(&id)?;
707        Some(value_meta_query(&self.salsa, self.project, file))
708    }
709
710    /// One file's external call-site literal checks (`E041`/`E042`, issue
711    /// #750 / FG-3 completion). `None` for an unknown file id; empty when
712    /// the `external_check` severity is `Off`. A body edit in a *different*
713    /// file leaves this `Arc`'s pointer identity untouched.
714    pub fn file_call_site_diagnostics(&self, id: FileId) -> Option<Arc<Vec<Diagnostic>>> {
715        let file = *self.files.get(&id)?;
716        Some(call_site_diagnostics_query(&self.salsa, self.project, file))
717    }
718
719    /// The range-free, name-keyed external metas feeding the per-file
720    /// call-site checks (issue #750 / FG-3 completion) — the cutoff seam
721    /// between the (often re-executed, full-ranged-index-reading)
722    /// enrichment pass and every file's call-site memo. Exposed for the
723    /// dependency-edge tests; pointer identity across an edit proves the
724    /// seam backdated.
725    pub fn call_site_metas(&self) -> Arc<BTreeMap<String, SymbolMeta>> {
726        call_site_metas_query(&self.salsa, self.project)
727    }
728
729    /// Per-file diagnostics including this file's share of analysis
730    /// diagnostics (layer 3, `diagnostics(FileId)`). Raw — no suppression
731    /// filtering.
732    pub fn diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
733        let file = self.files.get(&id)?;
734        Some(diagnostics_query(&self.salsa, self.project, *file).as_slice())
735    }
736
737    /// Whole-project type inference (TM-1, typed-mode-spec §2/§9 step 1).
738    /// Advisory-only substrate: `infer_body`/`type_diagnostics` are thin
739    /// per-def/per-file views over this. Lazy — nothing in `story_data`,
740    /// `lir_product`, or `diagnostics` reads it, so calling this (directly
741    /// or via `infer_body`/`type_diagnostics`) is the only thing that
742    /// triggers the underlying computation.
743    pub fn type_inference(&self) -> &InferenceResult {
744        type_inference_query(&self.salsa, self.project)
745    }
746
747    /// Per-def inferred body types (`infer_body(def)`). `None` for a def
748    /// with no inferable body (not a knot/stitch, or an unknown id).
749    pub fn infer_body(&self, def: DefinitionId) -> Option<Arc<BodyTypes>> {
750        infer_body_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
751    }
752
753    /// Per-def inferred signature (`inferred_signature(def)`, FG-2 issue
754    /// #631) — the firewall-facing per-def view: params + return type only,
755    /// no locals, no ranges. This is the boundary TM-2's annotation-override
756    /// consumer reads. `None` for a def with no inferable body (not a
757    /// knot/stitch, or an unknown id) — same `None` contract as
758    /// [`signature`](Self::signature)/[`infer_body`](Self::infer_body).
759    pub fn inferred_signature(&self, def: DefinitionId) -> Option<Arc<InferredSig>> {
760        inferred_signature_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
761    }
762
763    /// Per-def effect row (`effects(def)`, T2-1, docs/effects-spec.md §2/§4,
764    /// issue #860) — the advisory `{reads, writes, calls}` summary of the
765    /// atomic effects `def` (and everything it transitively calls) may
766    /// perform, sited beside [`inferred_signature`](Self::inferred_signature).
767    /// Conservative-total (spec §3): the row over-reports, never under-reports;
768    /// a call through a function value or an unknown callee makes it pessimal
769    /// ([`EffectRow::opaque`]). `None` for a def with no inferable body (not a
770    /// knot/stitch, or an unknown id) — same contract as
771    /// [`inferred_signature`](Self::inferred_signature).
772    ///
773    /// **Advisory-only**: nothing in `story_data`/`lir_product`/`diagnostics`
774    /// reads this, so the row is additive metadata that leaves compiled output
775    /// byte-identical. Lazy — calling this is the only thing that triggers the
776    /// underlying atom harvest + per-SCC fixpoint.
777    pub fn effects(&self, def: DefinitionId) -> Option<Arc<EffectRow>> {
778        effects_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
779    }
780
781    /// The B3a UFCS resolution verdict for the call site at `range` in
782    /// `file` (issue #1507) — reads the same memoized `ufcs_resolution_query`
783    /// (#1506) LIR lowering already shares, rather than re-running the
784    /// analyzer's `ufcs` pass a second time for IDE hover/go-to-def. `None`
785    /// when the pass recorded no verdict at this exact range: not a
786    /// UFCS-shaped call site, an unresolved one (already diagnosed
787    /// E140–E143 elsewhere), or the project has no dotted-callee call
788    /// anywhere (`ufcs_resolution_query`'s own laziness gate).
789    pub fn ufcs_verdict(
790        &self,
791        file: FileId,
792        range: rowan::TextRange,
793    ) -> Option<&brink_ir::lir::UfcsVerdict> {
794        ufcs_resolution_query(&self.salsa, self.project)
795            .table
796            .get(file, range)
797    }
798
799    /// Every UFCS call site (`recv.verb(args)`) whose verdict desugars to a
800    /// free function targeting `target`, project-wide (issue #1539) — reads
801    /// the same memoized `ufcs_resolution_query` table
802    /// [`ufcs_verdict`](Self::ufcs_verdict) does. The `find_references`/
803    /// `rename` counterpart to that single-site lookup: renaming or listing
804    /// references to a free function must also reach every UFCS call site
805    /// that resolves to it, not just its plain `ResolutionMap` references.
806    #[must_use]
807    pub fn ufcs_call_sites_for_target(
808        &self,
809        target: DefinitionId,
810    ) -> Vec<(FileId, rowan::TextRange)> {
811        ufcs_resolution_query(&self.salsa, self.project)
812            .table
813            .call_sites_for_target(target)
814    }
815
816    /// Per-file type diagnostics (`type_diagnostics(FileId)`). Advisory-only
817    /// in this slice — always empty (see `type_diagnostics_query`'s docs).
818    pub fn type_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
819        let file = self.files.get(&id)?;
820        Some(type_diagnostics_query(&self.salsa, self.project, *file).as_slice())
821    }
822
823    /// Whole-project LIR lowering (layer 3). `None` until an entry point is
824    /// set via [`set_entry`](Self::set_entry).
825    pub fn lir_product(&self) -> Option<&LirProduct> {
826        self.project.entry(&self.salsa)?;
827        Some(lir_query(&self.salsa, self.project))
828    }
829
830    /// Whether the project has at least one Error-severity diagnostic after
831    /// suppression filtering (issue #791 / FG-4a) — the narrow boolean
832    /// projection [`lir_product`](Self::lir_product)'s gate reads instead of
833    /// the full diagnostics vector. `false` (never `None`) when no entry
834    /// point is set, matching `partition_diagnostics`'s empty-`errors`
835    /// default in that case. Exposed for the dependency-edge tests; a
836    /// diagnostics-content edit that leaves this boolean unchanged proves
837    /// the cutoff seam backdated.
838    pub fn has_errors(&self) -> bool {
839        has_errors_query(&self.salsa, self.project)
840    }
841
842    /// FG-4d non-re-execution probe (issue #830): the `Arc<ScopeChunk>` the
843    /// per-knot LIR chunk memo stores for the `knot_index`-th knot of `file`.
844    /// `Arc::ptr_eq` on the result across an edit proves the memo validated
845    /// without re-executing — salsa only hands back the same allocation when
846    /// a query's inputs (this file's HIR, the project resolutions, and the
847    /// struct-shape projection) are all unchanged. Exposed for the
848    /// dependency-edge tests, mirroring [`resolutions_index`](Self::resolutions_index).
849    #[doc(hidden)]
850    #[must_use]
851    pub fn knot_chunk(&self, file: FileId, knot_index: u32) -> Arc<brink_ir::lir::ScopeChunk> {
852        let key = KnotChunkKey::new(&self.salsa, file, knot_index);
853        lir_knot_chunk_query(&self.salsa, self.project, key).chunk
854    }
855
856    /// FG-4e non-re-execution probe (issue #839): the
857    /// `Arc<brink_ir::lir::PreludeDecls>` the whole-project prelude-decls
858    /// memo stores. `Arc::ptr_eq` on the result across an edit proves the
859    /// memo validated without re-executing — salsa only hands back the same
860    /// allocation when every entry-reachable file's [decl-only HIR
861    /// projection](crate::queries::PreludeDeclsResult) is unchanged, which
862    /// holds across a knot-body-only edit. Exposed for the dependency-edge
863    /// tests, mirroring [`knot_chunk`](Self::knot_chunk).
864    #[doc(hidden)]
865    #[must_use]
866    pub fn lir_prelude_decls(&self) -> Arc<brink_ir::lir::PreludeDecls> {
867        lir_prelude_decls_query(&self.salsa, self.project).decls
868    }
869
870    /// Whole-project compile to [`brink_format::StoryData`] (layer 3,
871    /// `story_data()`). `None` until an entry point is set via
872    /// [`set_entry`](Self::set_entry).
873    pub fn story_data(&self) -> Option<&CompileProduct> {
874        self.project.entry(&self.salsa)?;
875        Some(story_data_query(&self.salsa, self.project))
876    }
877
878    /// Snapshot salsa's memo-table memory usage — one row per salsa
879    /// ingredient (input/tracked struct or memoized query function), sorted
880    /// for deterministic output. Behind the `memory-introspection` feature
881    /// (issue #529); see `brink-test-harness`'s `editor_session_bench`.
882    #[cfg(feature = "memory-introspection")]
883    pub fn memory_snapshot(&self) -> Vec<crate::memory::IngredientMemory> {
884        crate::memory::snapshot(&self.salsa)
885    }
886
887    // ── Internal helpers ──────────────────────────────────────────────
888
889    fn include_graph(&self) -> &crate::include_graph::IncludeGraph {
890        include_graph_query(&self.salsa, self.project)
891    }
892
893    /// Test-only escape hatch: hand out the raw salsa handle and project
894    /// input so crate-internal `#[cfg(test)]` code elsewhere (e.g.
895    /// `queries::tests`) can call `pub(crate)` queries — `call_graph_query`,
896    /// `def_effect_atoms_query` — directly instead of only through this
897    /// façade's own accessors (issue #1736 finding: a direct edge-set
898    /// parity guard needs both raw pieces).
899    #[cfg(test)]
900    pub(crate) fn salsa_and_project(&self) -> (&BrinkDatabase, ProjectInput) {
901        (&self.salsa, self.project)
902    }
903}
904
905impl Default for ProjectDb {
906    fn default() -> Self {
907        Self::new()
908    }
909}
910
911/// Resolve an INCLUDE path relative to the including file's directory.
912///
913/// Uses string-based path manipulation (`rfind('/')`) rather than
914/// `std::path::Path` to avoid platform-specific separator issues and
915/// to work in WASM contexts. The joined path is normalized so `.`/`..`
916/// segments collapse to a clean project-relative key (e.g.
917/// `a/b/../d.ink` → `a/d.ink`) — consistent across the compiler, runtime,
918/// and IDE so upward-relative includes resolve to real files.
919pub fn resolve_include_path(from_file: &str, include_path: &str) -> String {
920    let joined = match from_file.rfind('/') {
921        Some(i) => format!("{}/{include_path}", &from_file[..i]),
922        None => include_path.to_string(),
923    };
924    normalize_path(&joined)
925}
926
927/// Collapse `.` and `..` segments in a `/`-separated path. A `..` pops the
928/// previous real segment; a `..` with nothing to pop (or above an existing
929/// `..`) is kept literally rather than escaping the root. A leading `/`
930/// (absolute path) is preserved — the test harness and disk-backed compiles
931/// resolve against absolute filesystem paths.
932fn normalize_path(path: &str) -> String {
933    let absolute = path.starts_with('/');
934    let mut out: Vec<&str> = Vec::new();
935    for seg in path.split('/') {
936        match seg {
937            "" | "." => {}
938            ".." if matches!(out.last(), Some(&s) if s != "..") => {
939                out.pop();
940            }
941            s => out.push(s),
942        }
943    }
944    let joined = out.join("/");
945    if absolute {
946        format!("/{joined}")
947    } else {
948        joined
949    }
950}
951
952/// Compute the relative INCLUDE target to reach `to_file` from `from_file`'s
953/// directory — the inverse of [`resolve_include_path`]:
954/// `normalize(resolve_include_path(from_file, compute_relative_path(from_file, to_file))) == to_file`
955/// for both forward and `..`-traversing layouts. Used when a file is
956/// renamed/moved to rewrite every `INCLUDE` that points at it (and the moved
957/// file's own includes).
958pub fn compute_relative_path(from_file: &str, to_file: &str) -> String {
959    let mut from_dirs: Vec<&str> = from_file.split('/').collect();
960    from_dirs.pop(); // drop the including file's own name
961    let to_all: Vec<&str> = to_file.split('/').collect();
962    let Some((to_name, to_dirs)) = to_all.split_last() else {
963        return to_file.to_owned();
964    };
965
966    // Longest common directory prefix.
967    let mut k = 0;
968    while k < from_dirs.len() && k < to_dirs.len() && from_dirs[k] == to_dirs[k] {
969        k += 1;
970    }
971
972    let mut parts: Vec<&str> = Vec::new();
973    parts.extend(std::iter::repeat_n("..", from_dirs.len() - k));
974    parts.extend_from_slice(&to_dirs[k..]);
975    parts.push(to_name);
976    parts.join("/")
977}
978
979#[cfg(test)]
980mod path_tests {
981    use super::{compute_relative_path, resolve_include_path};
982
983    #[test]
984    fn resolve_forward_includes() {
985        assert_eq!(
986            resolve_include_path("src/main.ink", "utils.ink"),
987            "src/utils.ink"
988        );
989        assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
990        assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
991    }
992
993    #[test]
994    fn resolve_normalizes_dot_and_dotdot() {
995        assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
996        assert_eq!(resolve_include_path("a/b/c.ink", "./d.ink"), "a/b/d.ink");
997        assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
998        assert_eq!(
999            resolve_include_path("a/b/c.ink", "../x/../d.ink"),
1000            "a/d.ink"
1001        );
1002    }
1003
1004    #[test]
1005    fn compute_relative_is_inverse_of_resolve() {
1006        // (from including file, target file) round-trips through resolve.
1007        let cases = [
1008            ("main.ink", "scenes/intro.ink"), // move into a subdir
1009            ("a/b/c.ink", "a/d.ink"),         // sibling dir (needs ..)
1010            ("a/b/c.ink", "a/b/renamed.ink"), // rename in place
1011            ("scenes/intro.ink", "lib.ink"),  // up to root (needs ..)
1012            ("a/b/c.ink", "x/y/z.ink"),       // fully divergent
1013            ("main.ink", "other.ink"),        // both at root
1014        ];
1015        for (from, to) in cases {
1016            let rel = compute_relative_path(from, to);
1017            assert_eq!(
1018                resolve_include_path(from, &rel),
1019                to,
1020                "round-trip failed for from={from} to={to} rel={rel}",
1021            );
1022        }
1023    }
1024
1025    #[test]
1026    fn resolve_preserves_absolute_paths() {
1027        // The test harness / disk-backed compiles pass absolute paths — the
1028        // leading slash must survive normalization.
1029        assert_eq!(
1030            resolve_include_path("/proj/tier3/main.ink", "included.ink"),
1031            "/proj/tier3/included.ink",
1032        );
1033        assert_eq!(
1034            resolve_include_path("/proj/a/b/c.ink", "../d.ink"),
1035            "/proj/a/d.ink"
1036        );
1037    }
1038
1039    #[test]
1040    fn compute_relative_rename_in_place_is_bare_name() {
1041        assert_eq!(
1042            compute_relative_path("a/b/c.ink", "a/b/renamed.ink"),
1043            "renamed.ink"
1044        );
1045        assert_eq!(
1046            compute_relative_path("main.ink", "renamed.ink"),
1047            "renamed.ink"
1048        );
1049    }
1050
1051    #[test]
1052    fn compute_relative_move_shallower_is_bare_name() {
1053        // Regression (#318): after a shallower move (chapters/main.ink →
1054        // main.ink), the outbound-INCLUDE rewrite relativizes the resolved
1055        // target against the NEW path. From the root, a root-level sibling is a
1056        // bare name — no stale `chapters/` or `../` prefix.
1057        assert_eq!(compute_relative_path("main.ink", "host.ink"), "host.ink");
1058        // And from the OLD subdir location, the same root-level target is
1059        // `../host.ink` — relative to `chapters/`, reaching the root needs `..`.
1060        // (This is the value resolve→compute round-trips through; the rewrite
1061        // uses the NEW path above to drop the prefix.)
1062        assert_eq!(
1063            compute_relative_path("chapters/main.ink", "host.ink"),
1064            "../host.ink"
1065        );
1066        assert_eq!(
1067            resolve_include_path("chapters/main.ink", "../host.ink"),
1068            "host.ink"
1069        );
1070    }
1071}
1072
1073#[cfg(test)]
1074mod native_seam_tests {
1075    use super::ProjectDb;
1076
1077    use brink_analyzer::{AnalysisOptions, Dialect};
1078
1079    /// B0.10a gate (issue #1106): a native `.brink` file, registered by the
1080    /// plain public db API, must compile all the way through the *real* salsa
1081    /// pipeline to `StoryData` — proving the frontend seam in `lowered_query`
1082    /// dispatches on the `.brink` extension and that everything downstream of
1083    /// lowering is frontend-agnostic. This flow falls off the end (the
1084    /// `lower_native` implicit `-> DONE`), so no explicit terminator is needed.
1085    #[test]
1086    fn native_brink_file_compiles_through_to_story_data() {
1087        let mut db = ProjectDb::new();
1088        // Native compiles under the brink dialect (the analysis posture the
1089        // first-light native harness uses).
1090        db.set_analysis_options(AnalysisOptions {
1091            dialect: Dialect::Brink,
1092            ..AnalysisOptions::default()
1093        });
1094        let id = db.set_file(
1095            "scene.brink",
1096            "flow main() {\n  Hello, world.\n}\n".to_owned(),
1097        );
1098        db.set_entry("scene.brink");
1099
1100        // No parse/lowering diagnostics, and the non-suppressible admission
1101        // gate is clean.
1102        assert_eq!(
1103            db.file_diagnostics(id),
1104            Some(&[][..]),
1105            "native lowering must produce no per-file diagnostics"
1106        );
1107        assert_eq!(
1108            db.admission_diagnostics(id),
1109            Some(&[][..]),
1110            "native HIR must pass the B0.3 admission gate"
1111        );
1112
1113        // End-to-end: parse_native -> lower_native -> analyze -> LIR ->
1114        // codegen -> StoryData, all via the public `story_data()` accessor.
1115        let product = db
1116            .story_data()
1117            .expect("entry is set, so story_data is Some");
1118        assert!(
1119            product.errors.is_empty(),
1120            "native compile must be error-free, got: {:?}",
1121            product.errors
1122        );
1123        assert!(
1124            product.story.is_some(),
1125            "native compile must yield a StoryData"
1126        );
1127    }
1128
1129    /// SAVE-KEY INVARIANT (decision-log 2026-07-22 "Native module identity"):
1130    /// a native symbol's `DefinitionId` is hashed from its **path-derived**
1131    /// module (`native_module_path`) + name, and nothing else. Two properties
1132    /// follow, both of which keep player saves stable across recompiles:
1133    ///   1. Identity is qualified by the file's *location* — the same-named
1134    ///      flow at a different path is a different definition.
1135    ///   2. Identity is independent of `FileId` (assigned in discovery order)
1136    ///      — adding an unrelated file cannot change it.
1137    #[test]
1138    fn native_definition_id_is_path_qualified_and_fileid_independent() {
1139        use brink_format::DefinitionId;
1140
1141        fn hero_id(files: &[(&str, &str)]) -> DefinitionId {
1142            let mut db = ProjectDb::new();
1143            db.set_analysis_options(AnalysisOptions {
1144                dialect: Dialect::Brink,
1145                ..AnalysisOptions::default()
1146            });
1147            for (path, src) in files {
1148                db.set_file(path, (*src).to_owned());
1149            }
1150            db.set_entry(files[0].0);
1151            let index = db.symbol_index();
1152            let ids = index.by_name.get("hero").expect("`hero` is defined");
1153            assert_eq!(ids.len(), 1, "exactly one `hero`");
1154            ids[0]
1155        }
1156
1157        let hero = "flow hero() {\n  hi\n}\n";
1158        let other = "flow other() {\n  x\n}\n";
1159
1160        // `flow hero()` in `market/barter.brink`, compiled alone → FileId(0).
1161        let solo = hero_id(&[("market/barter.brink", hero)]);
1162
1163        // Same file, but an unrelated sibling is registered FIRST, so
1164        // `market/barter.brink` is now FileId(1) — its `FileId` shifted. If
1165        // `FileId` leaked into identity, `hero`'s `DefinitionId` would change.
1166        let with_sibling = hero_id(&[("aaa/early.brink", other), ("market/barter.brink", hero)]);
1167        assert_eq!(
1168            solo, with_sibling,
1169            "adding a file must not change `market/barter`'s `hero` identity — \
1170             `FileId` must never enter `DefinitionId`"
1171        );
1172
1173        // The SAME `flow hero()` at a DIFFERENT path is a different module, so a
1174        // distinct identity — the path-derived module qualifies.
1175        let elsewhere = hero_id(&[("shop/wares.brink", hero)]);
1176        assert_ne!(
1177            solo, elsewhere,
1178            "`story::market::barter::hero` and `story::shop::wares::hero` must be distinct"
1179        );
1180    }
1181
1182    /// NATIVE `@[was]` RENAME MIGRATION (issue #1286, the save-key companion to
1183    /// path-derived native module identity). A native module's `DefinitionId`
1184    /// is `hash(native_module_path, name)`, so *moving* the file changes every
1185    /// id and breaks saves keyed on the old ones. A file-level
1186    /// `@[was("old::path")]` is the migration record: it must emit an
1187    /// `AliasEntry { old, new }` mapping each pre-rename id to its current one,
1188    /// so `brink-runtime`'s miss-path lookup still resolves an old save.
1189    #[test]
1190    fn native_was_annotation_produces_pre_rename_alias() {
1191        use brink_format::DefinitionId;
1192
1193        fn hero_id(path: &str, src: &str) -> DefinitionId {
1194            let mut db = ProjectDb::new();
1195            db.set_analysis_options(AnalysisOptions {
1196                dialect: Dialect::Brink,
1197                ..AnalysisOptions::default()
1198            });
1199            db.set_file(path, src.to_owned());
1200            db.set_entry(path);
1201            let index = db.symbol_index();
1202            index.by_name.get("hero").expect("`hero` is defined")[0]
1203        }
1204
1205        let plain = "flow hero() {\n  hi\n}\n";
1206        // The OLD identity: `hero` when the module lived at `old/barter.brink`
1207        // (module `story::old::barter`), before any rename.
1208        let old_id = hero_id("old/barter.brink", plain);
1209
1210        // The renamed module: same `hero`, now at `market/barter.brink`
1211        // (module `story::market::barter`), declaring where it came from.
1212        let renamed_src = "@[was(\"story::old::barter\")]\nflow hero() {\n  hi\n}\n";
1213        let mut db = ProjectDb::new();
1214        db.set_analysis_options(AnalysisOptions {
1215            dialect: Dialect::Brink,
1216            ..AnalysisOptions::default()
1217        });
1218        db.set_file("market/barter.brink", renamed_src.to_owned());
1219        db.set_entry("market/barter.brink");
1220
1221        let index = db.symbol_index();
1222        let new_id = index.by_name.get("hero").expect("`hero` is defined")[0];
1223        assert_ne!(
1224            old_id, new_id,
1225            "moving the module changes `hero`'s identity — that is the problem \
1226             `@[was]` migrates"
1227        );
1228        assert!(
1229            index
1230                .aliases
1231                .iter()
1232                .any(|a| a.old == old_id && a.new == new_id),
1233            "`@[was(\"story::old::barter\")]` must alias the pre-rename id to the \
1234             current one; aliases: {:?}",
1235            index.aliases
1236        );
1237    }
1238
1239    /// The end-to-end proof #1286 claimed but issue #1355 found not actually
1240    /// reachable: the unquoted `::`-path spelling of `@[was(…)]` (issue
1241    /// #1349's grammar) must migrate a pre-rename `DefinitionId` exactly like
1242    /// the quoted-string spelling in
1243    /// [`native_was_annotation_produces_pre_rename_alias`] — same alias, not
1244    /// just a clean parse.
1245    #[test]
1246    fn native_was_annotation_unquoted_path_produces_pre_rename_alias() {
1247        use brink_format::DefinitionId;
1248
1249        fn hero_id(path: &str, src: &str) -> DefinitionId {
1250            let mut db = ProjectDb::new();
1251            db.set_analysis_options(AnalysisOptions {
1252                dialect: Dialect::Brink,
1253                ..AnalysisOptions::default()
1254            });
1255            db.set_file(path, src.to_owned());
1256            db.set_entry(path);
1257            let index = db.symbol_index();
1258            index.by_name.get("hero").expect("`hero` is defined")[0]
1259        }
1260
1261        let plain = "flow hero() {\n  hi\n}\n";
1262        // The OLD identity: `hero` when the module lived at `old/barter.brink`
1263        // (module `story::old::barter`), before any rename.
1264        let old_id = hero_id("old/barter.brink", plain);
1265
1266        // The renamed module: same `hero`, now at `market/barter.brink`
1267        // (module `story::market::barter`), declaring where it came from
1268        // using the **unquoted** `::`-path spelling.
1269        let renamed_src = "@[was(story::old::barter)]\nflow hero() {\n  hi\n}\n";
1270        let mut db = ProjectDb::new();
1271        db.set_analysis_options(AnalysisOptions {
1272            dialect: Dialect::Brink,
1273            ..AnalysisOptions::default()
1274        });
1275        db.set_file("market/barter.brink", renamed_src.to_owned());
1276        db.set_entry("market/barter.brink");
1277
1278        let index = db.symbol_index();
1279        let new_id = index.by_name.get("hero").expect("`hero` is defined")[0];
1280        assert_ne!(
1281            old_id, new_id,
1282            "moving the module changes `hero`'s identity — that is the problem \
1283             `@[was]` migrates"
1284        );
1285        assert!(
1286            index
1287                .aliases
1288                .iter()
1289                .any(|a| a.old == old_id && a.new == new_id),
1290            "`@[was(story::old::barter)]` (unquoted) must alias the pre-rename \
1291             id to the current one; aliases: {:?}",
1292            index.aliases
1293        );
1294    }
1295
1296    /// The negative control for [`native_was_annotation_produces_pre_rename_alias`]:
1297    /// WITHOUT `@[was]`, the same physical move changes `hero`'s `DefinitionId`
1298    /// and produces **no** alias — an old save silently fails to resolve. This
1299    /// is exactly why `@[was]` is required before native is used for real saves.
1300    #[test]
1301    fn native_rename_without_was_leaves_no_alias() {
1302        use brink_format::DefinitionId;
1303
1304        fn setup(path: &str, src: &str) -> (DefinitionId, Vec<brink_format::AliasEntry>) {
1305            let mut db = ProjectDb::new();
1306            db.set_analysis_options(AnalysisOptions {
1307                dialect: Dialect::Brink,
1308                ..AnalysisOptions::default()
1309            });
1310            db.set_file(path, src.to_owned());
1311            db.set_entry(path);
1312            let index = db.symbol_index();
1313            let id = index.by_name.get("hero").expect("`hero` is defined")[0];
1314            (id, index.aliases.clone())
1315        }
1316
1317        let plain = "flow hero() {\n  hi\n}\n";
1318        let (old_id, _) = setup("old/barter.brink", plain);
1319        let (new_id, aliases) = setup("market/barter.brink", plain);
1320        assert_ne!(old_id, new_id, "the move still changes identity");
1321        assert!(
1322            !aliases.iter().any(|a| a.old == old_id),
1323            "no `@[was]` means no migration path — the old id is unrecoverable"
1324        );
1325    }
1326
1327    /// NATIVE MULTI-FILE LINKING (issue #1296, decision-log 2026-07-23): a
1328    /// multi-file native project links **every discovered `.brink` module**
1329    /// into the one `StoryData` — the discovery set is the compilation unit.
1330    /// Native files carry no `INCLUDE` edges, so before the codegen-closure
1331    /// fix only the *entry* file reached codegen and the sibling module's
1332    /// definitions silently vanished from the compiled story. Here the sibling
1333    /// `helper.brink` is never referenced from `main.brink`, yet its `helper`
1334    /// flow must still appear as a container in the linked `StoryData`.
1335    #[test]
1336    fn native_sibling_module_links_into_one_story_data() {
1337        let mut db = ProjectDb::new();
1338        db.set_analysis_options(AnalysisOptions {
1339            dialect: Dialect::Brink,
1340            ..AnalysisOptions::default()
1341        });
1342        db.set_file("main.brink", "flow main() {\n  Hello.\n}\n".to_owned());
1343        db.set_file("helper.brink", "flow helper() {\n  Aside.\n}\n".to_owned());
1344        db.set_entry("main.brink");
1345
1346        let product = db
1347            .story_data()
1348            .expect("entry is set, so story_data is Some");
1349        assert!(
1350            product.errors.is_empty(),
1351            "two clean native modules must compile: {:?}",
1352            product.errors
1353        );
1354        let story = product
1355            .story
1356            .as_ref()
1357            .expect("native multi-file compile must yield a StoryData");
1358
1359        let index = db.symbol_index();
1360        let main_id = index.by_name.get("main").expect("`main` is defined")[0];
1361        let helper_id = index.by_name.get("helper").expect("`helper` is defined")[0];
1362
1363        assert!(
1364            story.containers.iter().any(|c| c.id == main_id),
1365            "entry module's `main` flow must be linked"
1366        );
1367        assert!(
1368            story.containers.iter().any(|c| c.id == helper_id),
1369            "unreferenced sibling module's `helper` flow must ALSO be linked — \
1370             the whole discovered `.brink` tree is the compilation unit"
1371        );
1372    }
1373
1374    /// RUST PARITY (issue #1296, decision-log 2026-07-23): a `.brink` file that
1375    /// fails to compile is an error **even if no other module references it**.
1376    /// Because the native codegen closure is every discovered module, a broken
1377    /// unreferenced sibling's Error-severity diagnostic (`E037` for a malformed
1378    /// flow header) is inside the build gate's closure and must fail the whole
1379    /// build — the entry file's clean flow does not rescue it.
1380    #[test]
1381    fn broken_unreferenced_native_sibling_fails_the_build() {
1382        let mut db = ProjectDb::new();
1383        db.set_analysis_options(AnalysisOptions {
1384            dialect: Dialect::Brink,
1385            ..AnalysisOptions::default()
1386        });
1387        db.set_file("main.brink", "flow main() {\n  Hello.\n}\n".to_owned());
1388        // Malformed flow header (bad parameter list) → a `ParseSeverity::Error`
1389        // that surfaces as the non-suppressible `E037` compile diagnostic.
1390        db.set_file("broken.brink", "flow broken( {\n}\n".to_owned());
1391        db.set_entry("main.brink");
1392
1393        let product = db
1394            .story_data()
1395            .expect("entry is set, so story_data is Some");
1396        assert!(
1397            !product.errors.is_empty(),
1398            "a broken unreferenced native sibling must fail the build"
1399        );
1400        assert!(
1401            product.story.is_none(),
1402            "no StoryData may be produced when any discovered native module is broken"
1403        );
1404    }
1405
1406    /// The seam must not leak: an `.ink` file still runs the ink frontend and
1407    /// compiles as before (the native parser is never invoked for it).
1408    #[test]
1409    fn ink_file_still_compiles_via_ink_frontend() {
1410        let mut db = ProjectDb::new();
1411        db.set_file("main.ink", "Hello, world.\n-> DONE\n".to_owned());
1412        db.set_entry("main.ink");
1413        let product = db.story_data().expect("entry is set");
1414        assert!(
1415            product.errors.is_empty(),
1416            "ink path unchanged: {:?}",
1417            product.errors
1418        );
1419        assert!(product.story.is_some());
1420    }
1421}
1422
1423#[cfg(test)]
1424mod remove_readd_tests {
1425    use super::ProjectDb;
1426    use brink_ir::FileId;
1427
1428    #[test]
1429    fn readd_reuses_original_file_id() {
1430        let mut db = ProjectDb::new();
1431        let a = db.set_file("a.ink", "== ka ==\ntext\n".to_owned());
1432        let b = db.set_file("b.ink", "== kb ==\ntext\n".to_owned());
1433        assert_eq!(a, FileId(0));
1434        assert_eq!(b, FileId(1));
1435
1436        db.remove_file("a.ink");
1437        let a2 = db.set_file("a.ink", "== ka2 ==\ntext\n".to_owned());
1438        assert_eq!(a2, a, "re-added path must reuse its original FileId");
1439
1440        // A genuinely new path still gets a fresh id — reuse never aliases.
1441        let c = db.set_file("c.ink", "== kc ==\ntext\n".to_owned());
1442        assert_eq!(c, FileId(2));
1443
1444        // The project file list stays sorted by FileId even though the
1445        // reinstated id (0) is smaller than the ids minted after it.
1446        let ids: Vec<FileId> = db.file_ids().collect();
1447        assert_eq!(ids, vec![a, b, c]);
1448        let meta_ids: Vec<FileId> = db
1449            .file_metadata()
1450            .into_iter()
1451            .map(|(id, _, _)| id)
1452            .collect();
1453        assert_eq!(meta_ids, vec![a, b, c]);
1454    }
1455
1456    #[test]
1457    fn removed_file_is_invisible_to_every_accessor() {
1458        let mut db = ProjectDb::new();
1459        db.set_file("main.ink", "INCLUDE sub.ink\n-> DONE\n".to_owned());
1460        let sub = db.set_file("sub.ink", "== s ==\ntext\n-> DONE\n".to_owned());
1461
1462        db.remove_file("sub.ink");
1463
1464        assert_eq!(db.file_id("sub.ink"), None);
1465        assert_eq!(db.file_path(sub), None);
1466        assert!(db.file_ids().all(|id| id != sub));
1467        assert!(db.file_metadata().iter().all(|(id, _, _)| *id != sub));
1468        assert!(db.analysis_inputs().iter().all(|(id, _, _)| *id != sub));
1469        assert!(db.source(sub).is_none());
1470        assert!(db.parse(sub).is_none());
1471        assert!(db.hir(sub).is_none());
1472        assert!(db.manifest(sub).is_none());
1473        assert!(db.diagnostics(sub).is_none());
1474        assert!(db.suppressions(sub).is_none());
1475        assert!(db.resolve(sub).is_none());
1476    }
1477
1478    #[test]
1479    fn removed_include_matches_never_added_diagnostics() {
1480        let source = "INCLUDE sub.ink\n-> s\n";
1481
1482        // Db where sub.ink existed and was removed.
1483        let mut removed = ProjectDb::new();
1484        removed.set_file("main.ink", source.to_owned());
1485        removed.set_file("sub.ink", "== s ==\ntext\n-> DONE\n".to_owned());
1486        removed.set_entry("main.ink");
1487        // Pull through the whole pipeline while sub.ink is live, so the
1488        // removed-state read below exercises invalidation, not a cold start.
1489        assert!(removed.story_data().is_some());
1490        removed.remove_file("sub.ink");
1491
1492        // Db where sub.ink never existed (main.ink gets FileId(0) in both).
1493        let mut fresh = ProjectDb::new();
1494        fresh.set_file("main.ink", source.to_owned());
1495        fresh.set_entry("main.ink");
1496
1497        let main_removed = removed.file_id("main.ink").expect("main");
1498        let main_fresh = fresh.file_id("main.ink").expect("main");
1499        assert_eq!(main_removed, main_fresh);
1500        assert_eq!(
1501            removed.diagnostics(main_removed),
1502            fresh.diagnostics(main_fresh),
1503            "a removed INCLUDE target must diagnose exactly like a missing one"
1504        );
1505        assert_eq!(
1506            removed.story_data().map(|p| p.errors.clone()),
1507            fresh.story_data().map(|p| p.errors.clone()),
1508        );
1509    }
1510
1511    #[test]
1512    fn readd_recomputes_from_new_content() {
1513        let mut db = ProjectDb::new();
1514        let id = db.set_file("a.ink", "== old_knot ==\ntext\n-> DONE\n".to_owned());
1515        // Materialize memos for the original content.
1516        assert!(
1517            db.manifest(id)
1518                .is_some_and(|m| m.knots.iter().any(|k| k.name == "old_knot"))
1519        );
1520
1521        db.remove_file("a.ink");
1522        let id2 = db.set_file("a.ink", "== new_knot ==\ntext\n-> DONE\n".to_owned());
1523        assert_eq!(id2, id);
1524
1525        let manifest = db.manifest(id).expect("manifest after re-add");
1526        assert!(
1527            manifest.knots.iter().any(|k| k.name == "new_knot"),
1528            "re-added content must win over stale memos"
1529        );
1530        assert!(
1531            !manifest.knots.iter().any(|k| k.name == "old_knot"),
1532            "old content must not survive the tombstone round-trip"
1533        );
1534        assert_eq!(db.source(id), Some("== new_knot ==\ntext\n-> DONE\n"));
1535    }
1536
1537    #[test]
1538    fn remove_clears_entry_and_readd_does_not_restore_it() {
1539        let mut db = ProjectDb::new();
1540        db.set_file("main.ink", "-> DONE\n".to_owned());
1541        db.set_entry("main.ink");
1542        assert!(db.entry().is_some());
1543
1544        db.remove_file("main.ink");
1545        assert_eq!(db.entry(), None);
1546
1547        db.set_file("main.ink", "-> DONE\n".to_owned());
1548        assert_eq!(db.entry(), None, "re-add must not silently restore entry");
1549    }
1550}
1551
1552#[cfg(test)]
1553mod reachable_tests {
1554    use super::ProjectDb;
1555
1556    /// Load files then read reachability — the include graph is a tracked
1557    /// query, so no rebuild step is needed regardless of insertion order.
1558    fn db_with(files: &[(&str, &str)]) -> ProjectDb {
1559        let mut db = ProjectDb::new();
1560        for (path, src) in files {
1561            db.set_file(path, (*src).to_owned());
1562        }
1563        db
1564    }
1565
1566    #[test]
1567    fn entry_is_always_reachable_from_itself() {
1568        let db = db_with(&[("main.ink", "== hub ==\ntext\n")]);
1569        let main = db.file_id("main.ink").expect("main");
1570        let reachable = db.reachable_from(main);
1571        assert_eq!(reachable.into_iter().collect::<Vec<_>>(), vec![main]);
1572    }
1573
1574    #[test]
1575    fn direct_includes_are_reachable() {
1576        let db = db_with(&[
1577            ("main.ink", "INCLUDE a.ink\nINCLUDE b.ink\n"),
1578            ("a.ink", "== a ==\n"),
1579            ("b.ink", "== b ==\n"),
1580        ]);
1581        let main = db.file_id("main.ink").expect("main");
1582        let a = db.file_id("a.ink").expect("a");
1583        let b = db.file_id("b.ink").expect("b");
1584        let reachable: Vec<_> = db.reachable_from(main).into_iter().collect();
1585        assert!(reachable.contains(&main));
1586        assert!(reachable.contains(&a));
1587        assert!(reachable.contains(&b));
1588        assert_eq!(reachable.len(), 3);
1589    }
1590
1591    #[test]
1592    fn transitive_includes_are_reachable() {
1593        let db = db_with(&[
1594            ("main.ink", "INCLUDE a.ink\n"),
1595            ("a.ink", "INCLUDE b.ink\n"),
1596            ("b.ink", "== b ==\n"),
1597            ("unrelated.ink", "== x ==\n"),
1598        ]);
1599        let main = db.file_id("main.ink").expect("main");
1600        let a = db.file_id("a.ink").expect("a");
1601        let b = db.file_id("b.ink").expect("b");
1602        let unrelated = db.file_id("unrelated.ink").expect("unrelated");
1603        let reachable = db.reachable_from(main);
1604        assert!(reachable.contains(&main));
1605        assert!(reachable.contains(&a));
1606        assert!(reachable.contains(&b));
1607        assert!(
1608            !reachable.contains(&unrelated),
1609            "unrelated file is not reachable"
1610        );
1611    }
1612
1613    #[test]
1614    fn reachable_terminates_on_cycles() {
1615        // a -> b -> a; reachability must not loop forever.
1616        let db = db_with(&[("a.ink", "INCLUDE b.ink\n"), ("b.ink", "INCLUDE a.ink\n")]);
1617        let a = db.file_id("a.ink").expect("a");
1618        let b = db.file_id("b.ink").expect("b");
1619        let reachable: Vec<_> = db.reachable_from(a).into_iter().collect();
1620        assert!(reachable.contains(&a));
1621        assert!(reachable.contains(&b));
1622        assert_eq!(reachable.len(), 2);
1623    }
1624}
1625
1626#[cfg(test)]
1627mod type_inference_tests {
1628    use super::ProjectDb;
1629    use brink_ir::SymbolKind;
1630
1631    /// End-to-end reachability proof (TM-1, #617): `infer_body`/
1632    /// `type_inference` are reachable through the same public `ProjectDb`
1633    /// surface every other query surfaces through, and return a real
1634    /// inferred type for a param whose body use pins it — not a stub.
1635    #[test]
1636    fn infer_body_is_reachable_through_project_db() {
1637        let mut db = ProjectDb::new();
1638        db.set_file(
1639            "main.ink",
1640            "=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n".to_owned(),
1641        );
1642        db.set_entry("main.ink");
1643
1644        let index = db.symbol_index();
1645        let heal = index
1646            .by_name
1647            .get("heal")
1648            .and_then(|ids| ids.first())
1649            .copied()
1650            .expect("heal knot indexed");
1651        assert_eq!(
1652            index.symbols.get(&heal).map(|i| i.kind),
1653            Some(SymbolKind::Knot)
1654        );
1655
1656        let body = db.infer_body(heal).expect("heal has an inferable body");
1657        assert_eq!(body.params.len(), 1);
1658        assert_eq!(body.params[0].0, "hp");
1659        assert_eq!(body.params[0].1.display(), "int");
1660
1661        // Same view via the whole-project result and via `type_diagnostics`
1662        // (advisory-only: empty, but reachable and correctly shaped).
1663        assert!(db.type_inference().signatures.contains_key(&heal));
1664        let main = db.file_id("main.ink").expect("main");
1665        assert_eq!(db.type_diagnostics(main), Some(&[][..]));
1666    }
1667
1668    #[test]
1669    fn infer_body_is_none_for_a_non_callable_def() {
1670        let mut db = ProjectDb::new();
1671        db.set_file("main.ink", "VAR gold = 10\n-> DONE\n".to_owned());
1672        let index = db.symbol_index();
1673        let gold = index
1674            .by_name
1675            .get("gold")
1676            .and_then(|ids| ids.first())
1677            .copied()
1678            .expect("gold indexed");
1679        assert_eq!(db.infer_body(gold), None, "a VAR has no inferable body");
1680    }
1681
1682    #[test]
1683    fn type_inference_is_independent_of_the_compile_path() {
1684        // Pulling every other query surface (diagnostics, story_data) first
1685        // — neither reads `type_inference_query` (see its module docs), so
1686        // this exercises `infer_body` cold, after, and must still return the
1687        // same correct result: nothing about the compile path's query graph
1688        // secretly depends on inference having (or not having) run yet.
1689        let mut db = ProjectDb::new();
1690        db.set_file(
1691            "main.ink",
1692            "=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n".to_owned(),
1693        );
1694        db.set_entry("main.ink");
1695        let main = db.file_id("main.ink").expect("main");
1696        let _ = db.diagnostics(main);
1697        let _ = db.story_data();
1698
1699        let index = db.symbol_index();
1700        let heal = index
1701            .by_name
1702            .get("heal")
1703            .and_then(|ids| ids.first())
1704            .copied()
1705            .expect("heal knot indexed");
1706        let body = db.infer_body(heal).expect("heal has an inferable body");
1707        assert_eq!(body.params[0].1.display(), "int");
1708    }
1709}
1710
1711#[cfg(test)]
1712mod module_tests {
1713    //! M-1 modules (docs/modules-spec.md §1/§5): end-to-end reachability of
1714    //! module-qualified identity through the same public `ProjectDb`
1715    //! symbol-index surface the compiler (and IDE) use — the path that feeds
1716    //! codegen and the checked-in `.inkb`.
1717    use super::ProjectDb;
1718    use brink_ir::DiagnosticCode;
1719
1720    fn knot_id(db: &ProjectDb, name: &str) -> u64 {
1721        db.symbol_index()
1722            .by_name
1723            .get(name)
1724            .and_then(|ids| ids.first())
1725            .map(|id| id.to_raw())
1726            .expect("knot indexed")
1727    }
1728
1729    #[test]
1730    fn undeclared_file_keeps_bare_identity() {
1731        // The identity gate, exercised through the real db pipeline: an
1732        // undeclared single-file module hashes exactly as a bare-name build.
1733        // Byte-exact identity of a knot in an undeclared file is pinned in
1734        // the analyzer's `known_good_bare_definition_ids`; here we prove the
1735        // db path itself resolves an undeclared file to a *non-qualifying*
1736        // module — two undeclared files with different stems hash the knot
1737        // identically (the stem never enters the hash).
1738        let mut one = ProjectDb::new();
1739        one.set_file("story.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1740        let mut other = ProjectDb::new();
1741        other.set_file("elsewhere.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1742        assert_eq!(
1743            knot_id(&one, "start"),
1744            knot_id(&other, "start"),
1745            "two undeclared files (different stems) hash the knot identically"
1746        );
1747    }
1748
1749    #[test]
1750    fn declared_module_qualifies_identity_through_db() {
1751        let mut bare = ProjectDb::new();
1752        bare.set_file("story.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1753
1754        let mut declared = ProjectDb::new();
1755        declared.set_file(
1756            "story.ink",
1757            "#@module(quest)\n== start ==\nHi\n-> DONE\n".to_owned(),
1758        );
1759
1760        assert_ne!(
1761            knot_id(&bare, "start"),
1762            knot_id(&declared, "start"),
1763            "declaring a module must qualify (change) the knot's DefinitionId"
1764        );
1765    }
1766
1767    #[test]
1768    fn included_file_inherits_module_identity() {
1769        // Standalone `part.ink` (undeclared) vs the same file INCLUDE-glued
1770        // under a declaring head — the included knot's identity must follow
1771        // the head's module.
1772        let mut standalone = ProjectDb::new();
1773        standalone.set_file("part.ink", "== helper ==\nHi\n-> DONE\n".to_owned());
1774
1775        let mut glued = ProjectDb::new();
1776        glued.set_file(
1777            "head.ink",
1778            "#@module(quest)\nINCLUDE part.ink\n-> helper\n".to_owned(),
1779        );
1780        glued.set_file("part.ink", "== helper ==\nHi\n-> DONE\n".to_owned());
1781        glued.set_entry("head.ink");
1782
1783        assert_ne!(
1784            knot_id(&standalone, "helper"),
1785            knot_id(&glued, "helper"),
1786            "an INCLUDE-glued file inherits the includer's declared module"
1787        );
1788    }
1789
1790    #[test]
1791    fn stem_collision_with_declared_module_is_e085_through_db() {
1792        let mut db = ProjectDb::new();
1793        // `a.ink` declares module `shared`; `shared.ink` is an undeclared
1794        // file whose stem is *also* `shared` — the forbidden footgun.
1795        db.set_file("a.ink", "#@module(shared)\n== a_knot ==\nHi\n".to_owned());
1796        db.set_file("shared.ink", "== other ==\nHi\n".to_owned());
1797
1798        let codes: Vec<_> = db
1799            .symbol_index_diagnostics()
1800            .iter()
1801            .map(|d| d.code)
1802            .collect();
1803        assert!(
1804            codes.contains(&DiagnosticCode::E085),
1805            "expected E085 stem collision, got {codes:?}"
1806        );
1807    }
1808
1809    /// End-to-end reachability for M-2 cross-module visibility (§4/§7): a
1810    /// `#@private` knot in declared module `quest`, diverted to from a
1811    /// different declared module `town`, surfaces `E087` through the same
1812    /// production diagnostics path the compiler/studio read.
1813    #[test]
1814    fn private_cross_module_reference_is_e087_through_db() {
1815        let mut db = ProjectDb::new();
1816        db.set_file(
1817            "quest.ink",
1818            "#@module(quest)\n== ambush ==\n#@private\nGotcha!\n-> DONE\n".to_owned(),
1819        );
1820        let town = db.set_file(
1821            "town.ink",
1822            "#@module(town)\n== square ==\nHi\n-> ambush\n".to_owned(),
1823        );
1824
1825        let codes: Vec<_> = db
1826            .diagnostics(town)
1827            .unwrap_or_default()
1828            .iter()
1829            .map(|d| d.code)
1830            .collect();
1831        assert!(
1832            codes.contains(&DiagnosticCode::E087),
1833            "expected E087 private-cross-module reference, got {codes:?}"
1834        );
1835    }
1836
1837    /// An explicitly `#@public` knot in another declared module, diverted to
1838    /// from a file that **imports** it, resolves cleanly — no E087 (public,
1839    /// visibility-keyed) and no E025 (the import licenses the crossing, §2).
1840    #[test]
1841    fn imported_public_cross_module_reference_is_clean() {
1842        let mut db = ProjectDb::new();
1843        db.set_file(
1844            "quest.ink",
1845            "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1846        );
1847        let town = db.set_file(
1848            "town.ink",
1849            "#@module(town)\nIMPORT { ambush } FROM quest\n== square ==\nHi\n-> ambush\n"
1850                .to_owned(),
1851        );
1852
1853        let codes: Vec<_> = db
1854            .diagnostics(town)
1855            .unwrap_or_default()
1856            .iter()
1857            .map(|d| d.code)
1858            .collect();
1859        assert!(
1860            !codes.contains(&DiagnosticCode::E087),
1861            "public cross-module reference must not be E087, got {codes:?}"
1862        );
1863        assert!(
1864            !codes.contains(&DiagnosticCode::E025),
1865            "an imported public cross-module reference must not be E025, got {codes:?}"
1866        );
1867    }
1868
1869    /// M-2c (§2): a *public* knot in another **declared** module, referenced
1870    /// from a file that did **not** `IMPORT` it, is `E025` — names cross
1871    /// module boundaries only via import. Bringing the name in (bare import)
1872    /// clears it (proven by `imported_public_cross_module_reference_is_clean`).
1873    #[test]
1874    fn public_cross_module_reference_without_import_is_e025() {
1875        let mut db = ProjectDb::new();
1876        db.set_file(
1877            "quest.ink",
1878            "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1879        );
1880        let town = db.set_file(
1881            "town.ink",
1882            "#@module(town)\n== square ==\nHi\n-> ambush\n".to_owned(),
1883        );
1884
1885        let codes: Vec<_> = db
1886            .diagnostics(town)
1887            .unwrap_or_default()
1888            .iter()
1889            .map(|d| d.code)
1890            .collect();
1891        assert!(
1892            codes.contains(&DiagnosticCode::E025),
1893            "a non-imported public cross-module reference must be E025, got {codes:?}"
1894        );
1895    }
1896
1897    /// The qualified import form (`IMPORT quest`) also licenses references to
1898    /// the module's exports — no E025.
1899    #[test]
1900    fn qualified_import_licenses_cross_module_reference() {
1901        let mut db = ProjectDb::new();
1902        db.set_file(
1903            "quest.ink",
1904            "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1905        );
1906        let town = db.set_file(
1907            "town.ink",
1908            "#@module(town)\nIMPORT quest\n== square ==\nHi\n-> ambush\n".to_owned(),
1909        );
1910
1911        let codes: Vec<_> = db
1912            .diagnostics(town)
1913            .unwrap_or_default()
1914            .iter()
1915            .map(|d| d.code)
1916            .collect();
1917        assert!(
1918            !codes.contains(&DiagnosticCode::E025),
1919            "a qualified import must license the crossing, got {codes:?}"
1920        );
1921    }
1922
1923    /// The import-required restriction is keyed on the *target's* module being
1924    /// **declared**: a plain multi-file project with no `#@module` anywhere is
1925    /// one big default-public module (§3), so a cross-*file* bare reference
1926    /// keeps resolving with no E025 — the byte-identical legacy guarantee.
1927    #[test]
1928    fn cross_file_reference_in_undeclared_project_is_not_e025() {
1929        let mut db = ProjectDb::new();
1930        // `main.ink` INCLUDEs `helpers.ink`; neither declares a module.
1931        db.set_file(
1932            "helpers.ink",
1933            "== helper ==\nHelping.\n-> DONE\n".to_owned(),
1934        );
1935        let main = db.set_file(
1936            "main.ink",
1937            "INCLUDE helpers.ink\n== start ==\nHi\n-> helper\n".to_owned(),
1938        );
1939
1940        let codes: Vec<_> = db
1941            .diagnostics(main)
1942            .unwrap_or_default()
1943            .iter()
1944            .map(|d| d.code)
1945            .collect();
1946        assert!(
1947            !codes.contains(&DiagnosticCode::E025),
1948            "an undeclared multi-file project must not trip the import gate, got {codes:?}"
1949        );
1950    }
1951
1952    /// M-2c (§2): a `IMPORT quest` (qualified) whose module name also names a
1953    /// knot visible bare in the same file makes `quest.y` ambiguous — `E091`.
1954    #[test]
1955    fn qualified_import_colliding_with_definition_is_e091() {
1956        let mut db = ProjectDb::new();
1957        db.set_file(
1958            "quest.ink",
1959            "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1960        );
1961        // `town` has its own knot named `quest` AND imports module `quest`.
1962        let town = db.set_file(
1963            "town.ink",
1964            "#@module(town)\nIMPORT quest\n== quest ==\nHi\n-> DONE\n".to_owned(),
1965        );
1966
1967        let codes: Vec<_> = db
1968            .diagnostics(town)
1969            .unwrap_or_default()
1970            .iter()
1971            .map(|d| d.code)
1972            .collect();
1973        assert!(
1974            codes.contains(&DiagnosticCode::E091),
1975            "expected E091 qualified module-vs-definition ambiguity, got {codes:?}"
1976        );
1977    }
1978
1979    /// The `E092` redundant-override warning is reachable end-to-end: a
1980    /// `#@private` on a definition in a **declared** module restates that
1981    /// module's private-by-default (§4), so it is redundant.
1982    #[test]
1983    fn redundant_private_in_declared_module_is_e092() {
1984        let mut db = ProjectDb::new();
1985        let f = db.set_file(
1986            "quest.ink",
1987            "#@module(quest)\n== ambush ==\n#@private\nHi\n-> DONE\n".to_owned(),
1988        );
1989
1990        let codes: Vec<_> = db
1991            .diagnostics(f)
1992            .unwrap_or_default()
1993            .iter()
1994            .map(|d| d.code)
1995            .collect();
1996        assert!(
1997            codes.contains(&DiagnosticCode::E092),
1998            "expected E092 redundant-override warning, got {codes:?}"
1999        );
2000    }
2001
2002    /// A `#@public` on a definition in an **undeclared** stem-module restates
2003    /// the public-by-default (§4) — also redundant (`E092`).
2004    #[test]
2005    fn redundant_public_in_undeclared_module_is_e092() {
2006        let mut db = ProjectDb::new();
2007        let f = db.set_file(
2008            "story.ink",
2009            "== ambush ==\n#@public\nHi\n-> DONE\n".to_owned(),
2010        );
2011
2012        let codes: Vec<_> = db
2013            .diagnostics(f)
2014            .unwrap_or_default()
2015            .iter()
2016            .map(|d| d.code)
2017            .collect();
2018        assert!(
2019            codes.contains(&DiagnosticCode::E092),
2020            "expected E092 redundant-override warning, got {codes:?}"
2021        );
2022    }
2023
2024    /// A module importing itself surfaces `E090` through the db.
2025    #[test]
2026    fn self_import_is_e090_through_db() {
2027        let mut db = ProjectDb::new();
2028        let f = db.set_file(
2029            "quest.ink",
2030            "#@module(quest)\nIMPORT quest\n== start ==\nHi\n-> DONE\n".to_owned(),
2031        );
2032
2033        let codes: Vec<_> = db
2034            .diagnostics(f)
2035            .unwrap_or_default()
2036            .iter()
2037            .map(|d| d.code)
2038            .collect();
2039        assert!(
2040            codes.contains(&DiagnosticCode::E090),
2041            "expected E090 self-import, got {codes:?}"
2042        );
2043    }
2044
2045    /// A bare import naming a definition the (declared) module does not
2046    /// publicly export surfaces `E088`; a repeated local name surfaces
2047    /// `E089`.
2048    #[test]
2049    fn unresolved_and_duplicate_bare_import_through_db() {
2050        let mut db = ProjectDb::new();
2051        db.set_file(
2052            "quest.ink",
2053            "#@module(quest)\n== ambush ==\n#@public\nHi\n-> DONE\n".to_owned(),
2054        );
2055        // `ambush` twice (duplicate local name) and `nope` (not exported).
2056        let town = db.set_file(
2057            "town.ink",
2058            "#@module(town)\nIMPORT { ambush, ambush, nope } FROM quest\n== square ==\nHi\n-> DONE\n"
2059                .to_owned(),
2060        );
2061
2062        let codes: Vec<_> = db
2063            .diagnostics(town)
2064            .unwrap_or_default()
2065            .iter()
2066            .map(|d| d.code)
2067            .collect();
2068        assert!(
2069            codes.contains(&DiagnosticCode::E089),
2070            "expected E089 duplicate import, got {codes:?}"
2071        );
2072        assert!(
2073            codes.contains(&DiagnosticCode::E088),
2074            "expected E088 unresolved import, got {codes:?}"
2075        );
2076    }
2077
2078    /// A single file declaring `#@module(quest)` whose knots reference
2079    /// sibling definitions bare (issue #795): a self-reference inside the
2080    /// declared module must never be E087, no matter which of the file's
2081    /// symbols the index's `HashMap` happens to yield first (locals carry
2082    /// `module: None` and must not poison the file's module attribution).
2083    /// The bug was nondeterministic — repeated fresh-db runs (fresh
2084    /// `HashMap` seeds each time) cover the iteration-order space; the
2085    /// order-independent analyzer-level regression lives in
2086    /// `brink-analyzer`'s `modules::tests`.
2087    #[test]
2088    fn single_file_declared_module_self_reference_is_not_e087() {
2089        for _ in 0..16 {
2090            let mut db = ProjectDb::new();
2091            let f = db.set_file(
2092                "main.ink",
2093                "#@module(quest)\nVAR target = -> ambush\n-> ambush\n== ambush ==\n~ temp x = 1\nGotcha!\n-> reader\n== reader ==\nDone.\n-> DONE\n".to_owned(),
2094            );
2095
2096            let codes: Vec<_> = db
2097                .diagnostics(f)
2098                .unwrap_or_default()
2099                .iter()
2100                .map(|d| d.code)
2101                .collect();
2102            assert!(
2103                !codes.contains(&DiagnosticCode::E087),
2104                "same-module self-reference must not be E087, got {codes:?}"
2105            );
2106        }
2107    }
2108
2109    /// A file that belongs to a declared module but declares no top-level
2110    /// symbols of its own (only root content) must still resolve to that
2111    /// module — a referrer in the *same* declared module referencing a
2112    /// `#@private` sibling def must not be wrongly flagged `E087` just
2113    /// because the referrer's own module couldn't be derived from its
2114    /// (nonexistent) symbols.
2115    #[test]
2116    fn symbol_less_file_in_same_module_is_not_e087() {
2117        let mut db = ProjectDb::new();
2118        db.set_file(
2119            "a.ink",
2120            "#@module(town)\n== square ==\n#@private\nGotcha!\n-> DONE\n".to_owned(),
2121        );
2122        // `b.ink` declares the same module but has only root content — no
2123        // knot/VAR/CONST/LIST/STRUCT of its own.
2124        let b = db.set_file("b.ink", "#@module(town)\n-> square\n".to_owned());
2125
2126        let codes: Vec<_> = db
2127            .diagnostics(b)
2128            .unwrap_or_default()
2129            .iter()
2130            .map(|d| d.code)
2131            .collect();
2132        assert!(
2133            !codes.contains(&DiagnosticCode::E087),
2134            "same-module reference from a symbol-less file must not be E087, got {codes:?}"
2135        );
2136    }
2137
2138    /// A symbol-less file (only root content) that imports its own declared
2139    /// module must still trip `E090` self-import — the same derivation gap
2140    /// that caused the `E087` false positive above also caused this false
2141    /// negative (the referrer's own module resolved to `None`).
2142    #[test]
2143    fn symbol_less_file_self_import_is_e090() {
2144        let mut db = ProjectDb::new();
2145        let f = db.set_file(
2146            "quest.ink",
2147            "#@module(quest)\nIMPORT quest\n-> DONE\n".to_owned(),
2148        );
2149
2150        let codes: Vec<_> = db
2151            .diagnostics(f)
2152            .unwrap_or_default()
2153            .iter()
2154            .map(|d| d.code)
2155            .collect();
2156        assert!(
2157            codes.contains(&DiagnosticCode::E090),
2158            "expected E090 self-import from a symbol-less file, got {codes:?}"
2159        );
2160    }
2161
2162    // ── M-2c cross-module collisions (issue #784, decision-log
2163    // "Cross-module name collisions" 2026-07-14) ────────────────────────
2164
2165    fn brink_opts() -> brink_analyzer::AnalysisOptions {
2166        brink_analyzer::AnalysisOptions {
2167            dialect: brink_analyzer::Dialect::Brink,
2168            ..brink_analyzer::AnalysisOptions::default()
2169        }
2170    }
2171
2172    /// Two **different** declared modules exporting the same public knot
2173    /// name now **coexist** under `dialect = brink` (M-2d, issue #790 —
2174    /// the E096 stopgap relaxed): no diagnostic, and both definitions land
2175    /// in the index, through the same `symbol_index_query` path the
2176    /// compiler/studio read.
2177    #[test]
2178    fn cross_declared_module_duplicate_knot_coexists_under_brink() {
2179        let mut db = ProjectDb::new();
2180        db.set_analysis_options(brink_opts());
2181        db.set_file(
2182            "quest.ink",
2183            "#@module(quest)\n== start ==\n#@public\nHi from quest\n-> DONE\n".to_owned(),
2184        );
2185        db.set_file(
2186            "town.ink",
2187            "#@module(town)\n== start ==\n#@public\nHi from town\n-> DONE\n".to_owned(),
2188        );
2189
2190        let diags = db.symbol_index_diagnostics();
2191        assert!(
2192            diags.iter().all(|d| d.code != DiagnosticCode::E096),
2193            "E096 is relaxed — cross-declared-module homonyms must coexist, got {diags:?}"
2194        );
2195        // Both public `start` knots survive in the index under the shared
2196        // bare name — the raw material import-scoped resolution binds
2197        // per-importer.
2198        let index = db.symbol_index();
2199        assert_eq!(
2200            index.by_name.get("start").map(Vec::len),
2201            Some(2),
2202            "both modules' `start` knots must be indexed"
2203        );
2204    }
2205
2206    /// Two files sharing the **same** declared module (a multi-file module)
2207    /// that both define `start` stay the ordinary within-module warning
2208    /// (`E022`) — never `E096` — even under `dialect = brink`.
2209    #[test]
2210    fn same_declared_module_duplicate_knot_still_warns_e022() {
2211        let mut db = ProjectDb::new();
2212        db.set_analysis_options(brink_opts());
2213        db.set_file(
2214            "a.ink",
2215            "#@module(quest)\n== start ==\nHi from a\n-> DONE\n".to_owned(),
2216        );
2217        db.set_file(
2218            "b.ink",
2219            "#@module(quest)\n== start ==\nHi from b\n-> DONE\n".to_owned(),
2220        );
2221
2222        let codes: Vec<_> = db
2223            .symbol_index_diagnostics()
2224            .iter()
2225            .map(|d| d.code)
2226            .collect();
2227        assert!(
2228            codes.contains(&DiagnosticCode::E022),
2229            "expected the within-module E022 warning, got {codes:?}"
2230        );
2231        assert!(
2232            !codes.contains(&DiagnosticCode::E096),
2233            "same declared module must never escalate to E096, got {codes:?}"
2234        );
2235    }
2236
2237    /// Undeclared (legacy/soup) files duplicating a knot name are unchanged
2238    /// by M-2c: still `E022`, never `E096`, even under `dialect = brink`.
2239    #[test]
2240    fn undeclared_duplicate_knot_unchanged_under_brink() {
2241        let mut db = ProjectDb::new();
2242        db.set_analysis_options(brink_opts());
2243        db.set_file("a.ink", "== start ==\nHi from a\n-> DONE\n".to_owned());
2244        db.set_file("b.ink", "== start ==\nHi from b\n-> DONE\n".to_owned());
2245
2246        let codes: Vec<_> = db
2247            .symbol_index_diagnostics()
2248            .iter()
2249            .map(|d| d.code)
2250            .collect();
2251        assert!(
2252            codes.contains(&DiagnosticCode::E022),
2253            "expected the legacy E022 warning, got {codes:?}"
2254        );
2255        assert!(
2256            !codes.contains(&DiagnosticCode::E096),
2257            "undeclared legacy soup must never escalate to E096, got {codes:?}"
2258        );
2259    }
2260
2261    /// Under `strict-ink` (the default), a cross-declared-module duplicate
2262    /// stays the ordinary `E022` warning — the compat corpus is untouched.
2263    #[test]
2264    fn cross_declared_module_duplicate_stays_e022_under_strict_ink() {
2265        let mut db = ProjectDb::new();
2266        // Default AnalysisOptions -> Dialect::StrictInk; no set_analysis_options call.
2267        db.set_file(
2268            "quest.ink",
2269            "#@module(quest)\n== start ==\n#@public\nHi from quest\n-> DONE\n".to_owned(),
2270        );
2271        db.set_file(
2272            "town.ink",
2273            "#@module(town)\n== start ==\n#@public\nHi from town\n-> DONE\n".to_owned(),
2274        );
2275
2276        let codes: Vec<_> = db
2277            .symbol_index_diagnostics()
2278            .iter()
2279            .map(|d| d.code)
2280            .collect();
2281        assert!(
2282            codes.contains(&DiagnosticCode::E022),
2283            "expected E022 under strict-ink, got {codes:?}"
2284        );
2285        assert!(
2286            !codes.contains(&DiagnosticCode::E096),
2287            "strict-ink must never see E096, got {codes:?}"
2288        );
2289    }
2290
2291    /// The M-2d flagship (issue #790): two modules each export a public
2292    /// `ambush`; two files each bare-import a *different* one. Import-scoped
2293    /// resolution binds each file's `-> ambush` to the module it imported —
2294    /// not to the flat duplicate-winner — and the whole project compiles
2295    /// clean (no E025 import-required, no E096). Driven through the real
2296    /// `ProjectDb`/`resolve_query` path the compiler reads.
2297    #[test]
2298    fn two_modules_export_ambush_each_file_binds_its_own() {
2299        let mut db = ProjectDb::new();
2300        db.set_analysis_options(brink_opts());
2301        db.set_file(
2302            "quest_a.ink",
2303            "#@module(quest_a)\n== ambush ==\n#@public\nFrom A\n-> DONE\n".to_owned(),
2304        );
2305        db.set_file(
2306            "quest_b.ink",
2307            "#@module(quest_b)\n== ambush ==\n#@public\nFrom B\n-> DONE\n".to_owned(),
2308        );
2309        let main_a = db.set_file(
2310            "main_a.ink",
2311            "IMPORT { ambush } FROM quest_a\n-> ambush\n".to_owned(),
2312        );
2313        let main_b = db.set_file(
2314            "main_b.ink",
2315            "IMPORT { ambush } FROM quest_b\n-> ambush\n".to_owned(),
2316        );
2317
2318        // The two `ambush` knots coexist, module-qualified.
2319        let index = db.symbol_index();
2320        let ambush_ids = index.by_name.get("ambush").expect("both ambush knots");
2321        assert_eq!(ambush_ids.len(), 2, "both modules' `ambush` are indexed");
2322        let module_of = |target: brink_format::DefinitionId| -> Option<String> {
2323            index
2324                .symbols
2325                .get(&target)
2326                .and_then(|info| info.module.clone())
2327        };
2328
2329        // Each importing file's `-> ambush` binds to the module it imported.
2330        let targets = |file| -> Vec<brink_format::DefinitionId> {
2331            let (map, _diags) = db.resolve(file).expect("file resolves");
2332            map.iter().map(|r| r.target).collect()
2333        };
2334        let a_targets = targets(main_a);
2335        assert!(
2336            a_targets
2337                .iter()
2338                .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2339            "main_a's `ambush` must bind to module quest_a, got {:?}",
2340            a_targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2341        );
2342        assert!(
2343            !a_targets
2344                .iter()
2345                .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2346            "main_a must NOT bind quest_b's `ambush`"
2347        );
2348
2349        let b_targets = targets(main_b);
2350        assert!(
2351            b_targets
2352                .iter()
2353                .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2354            "main_b's `ambush` must bind to module quest_b"
2355        );
2356        assert!(
2357            !b_targets
2358                .iter()
2359                .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2360            "main_b must NOT bind quest_a's `ambush`"
2361        );
2362
2363        // The whole project compiles clean: no import-required error, no
2364        // stopgap collision error, on any file.
2365        for file in [main_a, main_b] {
2366            let diags = db.diagnostics(file).expect("diagnostics");
2367            assert!(
2368                diags
2369                    .iter()
2370                    .all(|d| d.code != DiagnosticCode::E025 && d.code != DiagnosticCode::E096),
2371                "import-scoped resolution must leave the correctly-imported file clean, got {diags:?}"
2372            );
2373        }
2374    }
2375
2376    /// `ImportScope` granularity regression (issue #790 review): a bare
2377    /// `IMPORT { other } FROM quest_a` must not license `quest_a`'s *other*
2378    /// public exports — only the name actually named. Two modules each
2379    /// export public `ambush`; the referring file bare-imports an unrelated
2380    /// name from `quest_a` and bare-imports `ambush` itself only from
2381    /// `quest_b`. Before the fix, `ImportScope` collapsed every import to
2382    /// just its module name, so `quest_a` counted as "imported" for *any*
2383    /// name — `-> ambush` could silently mis-resolve to `quest_a.ambush`
2384    /// and then draw a spurious `E025` telling the author to import `ambush`
2385    /// from `quest_a`, on a program that should compile clean. Resolution
2386    /// and the `E025` checker must agree at (module, name) granularity for
2387    /// bare imports.
2388    #[test]
2389    fn bare_import_is_name_precise_no_spurious_e025() {
2390        let mut db = ProjectDb::new();
2391        db.set_analysis_options(brink_opts());
2392        db.set_file(
2393            "quest_a.ink",
2394            "#@module(quest_a)\n== ambush ==\n#@public\nFrom A\n-> DONE\n== other ==\n#@public\nOther A\n-> DONE\n".to_owned(),
2395        );
2396        db.set_file(
2397            "quest_b.ink",
2398            "#@module(quest_b)\n== ambush ==\n#@public\nFrom B\n-> DONE\n".to_owned(),
2399        );
2400        let main = db.set_file(
2401            "main.ink",
2402            "IMPORT { other } FROM quest_a\nIMPORT { ambush } FROM quest_b\n-> ambush\n".to_owned(),
2403        );
2404
2405        let index = db.symbol_index();
2406        let module_of = |target: brink_format::DefinitionId| -> Option<String> {
2407            index
2408                .symbols
2409                .get(&target)
2410                .and_then(|info| info.module.clone())
2411        };
2412
2413        let (map, _diags) = db.resolve(main).expect("file resolves");
2414        let targets: Vec<brink_format::DefinitionId> = map.iter().map(|r| r.target).collect();
2415        assert!(
2416            targets
2417                .iter()
2418                .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2419            "bare-importing `ambush` from quest_b must bind it to quest_b, got {:?}",
2420            targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2421        );
2422        assert!(
2423            !targets
2424                .iter()
2425                .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2426            "bare-importing only `other` from quest_a must NOT license quest_a's `ambush`, got {:?}",
2427            targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2428        );
2429
2430        let diags = db.diagnostics(main).expect("diagnostics");
2431        assert!(
2432            diags.iter().all(|d| d.code != DiagnosticCode::E025),
2433            "a correctly bare-imported `ambush` must never draw a spurious E025 \
2434             pointing at the unrelated module that only imported a different name, got {diags:?}"
2435        );
2436    }
2437}