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