brink_db/queries/analysis.rs
1//! The `analysis_query` family (issue #632 / FG-3), extracted out of
2//! `queries.rs` per issue #662: pure code movement, no semantic change, no
3//! signature change. See the parent module's doc comment for how these fit
4//! into the overall query-shaped pipeline.
5
6// ─── FG-3 (issue #632): decomposed analysis_query ─────────────────────
7//
8// `analysis_query`'s only cutoff used to be `PartialEq` over the whole
9// `AnalysisResult` — index, resolutions, diagnostics (range-laden), and
10// symbol_meta bundled into one struct — so it almost never backdated, and
11// every file's validate/dialect_gate/annotation-content checks re-ran on
12// nearly any edit, since they were three whole-project passes each looping
13// every file. This section splits that into:
14//
15// - [`resolutions_index_query`] — index + resolutions, no diagnostics: the
16// RESOLUTIONS/INDEX half.
17// - [`per_file_diagnostics_query`] / [`contributor_diagnostics_query`] — the
18// genuinely per-file diagnostic contributors (validate, dialect_gate,
19// annotation content checks) behind a thin whole-project aggregator, so a
20// body edit re-runs only the edited file's own contributor.
21// - [`whole_project_diagnostics_query`] — now a thin aggregator (issue
22// #750, FG-3 completion): the external-check family is decomposed into
23// [`inline_docs_query`] / [`external_meta_query`] /
24// [`call_site_metas_query`] and the per-file [`value_meta_query`] /
25// [`call_site_diagnostics_query`]; only the M-2 modules pass and the
26// strict typed-mode pass remain genuinely whole-project — reading the
27// narrow [`resolutions_index_query`] projection and the
28// already-FG-2/FG-2.1-narrowed `type_inference_query`, never the
29// diagnostics-laden bundle.
30// - [`analysis_diagnostics_query`] — the DIAGNOSTICS half: every diagnostic
31// source merged, in the same order `finish_analysis` produces them, so
32// `db.analysis()` stays output-identical to the monolithic, module-aware
33// `brink_analyzer::analyze_with_modules` path (pinned by
34// `query_equivalence.rs`) — only equal to the module-*blind*
35// `analyze_with_options` for ink projects without a declared `#@module`,
36// see `ProjectDb::module_map`'s doc (issue #1526).
37// - [`analysis_query`] — kept as a thin assembler over the above three for
38// `db.analysis()`'s existing LSP/IDE/CLI-facing `AnalysisResult` shape.
39// [`diagnostics_query`] and [`lir_query`] read
40// [`analysis_diagnostics_query`]/[`resolutions_index_query`] directly
41// instead of through this bundle, so a diagnostics-only edit never forces
42// a resolutions-only reader to recompute and vice versa.
43
44use std::collections::BTreeMap;
45use std::sync::Arc;
46
47use brink_analyzer::{AnalysisResult, ExternalCheckSeverity, SymbolMeta, TypePolicy};
48use brink_format::DefinitionId;
49use brink_ir::{
50 Diagnostic, DocBlock, FileId, HirFile, ResolutionMap, SymbolIndex, SymbolKind, SymbolManifest,
51};
52
53use crate::determinism::LookupSet;
54
55use super::{
56 DefKey, ProjectInput, SourceFile, effects_query, file_import_scope_query,
57 inference_index_query, is_source_file, lowered_query, module_map_query, raw_lowered_query,
58 resolution_index_query, resolve_query, symbol_index_query, type_inference_query,
59};
60
61/// Index + resolutions, aggregated across every file's [`resolve_query`]
62/// (issue #632 / FG-3) — deliberately without diagnostics, so this struct's
63/// `PartialEq` never touches a diagnostic's range. Neither
64/// [`symbol_index_query`] nor any file's [`resolve_query`] reads
65/// `project.analysis_options`, so an `AnalysisOptions` edit that only
66/// changes diagnostics (e.g. raising `semantic_type_check` to `Error`) never
67/// even triggers salsa to re-run this query's closure — not just a
68/// backdate, a full skip (pinned by `fg3_dependency_edges.rs`).
69///
70/// `Arc`-wrapped (design doc §2 Fork 2's "Arc<plain>" ruling, applied here):
71/// pointer identity is the observable a re-execution-vs-cutoff test needs —
72/// see `fg3_dependency_edges.rs`.
73#[derive(Debug, Clone, PartialEq)]
74pub struct ResolvedProject {
75 pub index: Arc<SymbolIndex>,
76 pub resolutions: ResolutionMap,
77}
78
79#[salsa::tracked]
80pub(crate) fn resolutions_index_query(
81 db: &dyn salsa::Database,
82 project: ProjectInput,
83) -> Arc<ResolvedProject> {
84 let (index, _diags) = symbol_index_query(db, project).clone();
85 let mut resolutions = ResolutionMap::new();
86 for file in project.files(db) {
87 // Issue #2329: a non-source document never contributes resolutions —
88 // see `is_source_file`'s own doc for the full gated-surface list.
89 if !is_source_file(file.path(db)) {
90 continue;
91 }
92 let (file_map, _file_diags) = resolve_query(db, project, *file);
93 resolutions.extend(file_map.iter().cloned());
94 }
95 Arc::new(ResolvedProject { index, resolutions })
96}
97
98/// One file's per-file diagnostic contributors (issue #632 / FG-3 design doc
99/// §1 item 4 — [`brink_analyzer::per_file_diagnostics`]): structural
100/// validation, the dialect gate, and (brink dialect only) annotation-content
101/// checks. Reads only this file's own `lowered_query`/`resolve_query`, plus
102/// the narrow, cutoff-friendly [`resolution_index_query`] projection (for
103/// annotation content checks' declared-`LIST`-name lookup — range-free, so
104/// it doesn't reintroduce whole-project churn), the registered host
105/// manifest (T1d-2, docs/t1d-spec.md §3 — `Handle<K>` annotation content
106/// checks' declared-handle-kind lookup), and (issue #2272 review finding)
107/// [`file_import_scope_query`]'s `ImportScope` for the referrer-scoped
108/// `E061` struct-name lookup. The manifest is project-wide, host-set config,
109/// not derived from any file's edits — reading it here is the same coarse,
110/// range-free dependency shape as `dialect`, already read two lines below,
111/// so it doesn't reintroduce the whole-project churn FG-3 eliminated.
112/// `file_import_scope_query` is a *new* dependency edge onto
113/// [`module_map_query`] this query didn't carry before #2272 —
114/// `module_map_query` is itself built from every file's `raw_lowered_query`,
115/// so a declared-module-affecting edit anywhere in the project can in
116/// principle reach this query — but the edge is cutoff-safe: `ImportScope`
117/// carries no `TextRange` (its own doc), so `file_import_scope_query`'s
118/// `PartialEq` backdates on any edit that leaves every file's declared
119/// module name and this file's own `IMPORT` list unchanged, which is the
120/// overwhelming majority of edits (in particular every body-only edit, in
121/// this file or any other). Reading `module_map_query` directly here
122/// instead (as an earlier draft did) would NOT have this property — see
123/// `file_import_scope_query`'s own doc for why. Otherwise still: never
124/// another file's HIR: a body edit in file Y leaves file X's memo fully
125/// validated (same `Arc`/pointer), not re-executed.
126/// `Arc`-wrapped for the same pointer-identity reason as [`ResolvedProject`].
127///
128/// Also the B0.9 native strict-only enforcement point
129/// ([`brink_analyzer::native_strict_only_error`], issue #1342): this is the
130/// narrowest seam that has both a file's own [`super::Language`]
131/// classification (`super::file_language`) and `AnalysisOptions` access —
132/// `super::lower_native_file` has neither (issue #1179's finding), so the
133/// check cannot live there. Reading `opts.types` here doesn't widen this
134/// query's dependency edge: `opts` (the whole `AnalysisOptions`) is already
135/// read for `dialect`/`host_manifest` above.
136///
137/// Same seam decouples the T1b dialect gate from native files (issue #1348):
138/// `dialect` is an ink-only axis (docs/t1b-surface-spec.md §1), orthogonal to
139/// this file's [`super::Language`] classification, so
140/// `brink_analyzer::per_file_diagnostics`'s `is_native` flag — computed here,
141/// once, and reused for both calls below — skips the gate for a native file
142/// exactly the way `native_strict_only_error` above is native-conditional.
143///
144/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
145///
146/// Gated on [`is_source_file`] (issue #2329 review finding): this is a
147/// direct per-file entry point (`ProjectDb::per_file_diagnostics`), reachable
148/// without going through [`contributor_diagnostics_query`]'s own gate, so a
149/// non-source document must be excluded here too or its bogus ink-lowered
150/// HIR still reaches `brink_analyzer::per_file_diagnostics`.
151#[salsa::tracked(lru = 4096)]
152pub(crate) fn per_file_diagnostics_query(
153 db: &dyn salsa::Database,
154 project: ProjectInput,
155 file: SourceFile,
156) -> Arc<Vec<Diagnostic>> {
157 if !is_source_file(file.path(db)) {
158 return Arc::new(Vec::new());
159 }
160 let file_id = file.file_id(db);
161 let hir = &lowered_query(db, project, file).hir;
162 let (file_resolutions, _diags) = resolve_query(db, project, file);
163 let index = resolution_index_query(db, project);
164 let opts = project.analysis_options(db);
165 let is_native = super::file_language(file.path(db)) == super::Language::Native;
166 // Issue #2272 (review finding: share, don't re-derive): the same
167 // **declared-module** `ImportScope` `resolve_query` builds for this file
168 // via `file_import_scope_query` — the literal same tracked query, not a
169 // second copy of the derivation — so `annotations::check`'s
170 // referrer-scoped struct-name lookup agrees with
171 // `resolve::resolve_type_ref`'s own `RefKind::Type` resolution on
172 // exactly the same scope. Deriving it from `hir.module` in isolation
173 // would be wrong for a native file — see that field's own doc and
174 // `analyze_with_modules`'s matching comment. Going through
175 // `file_import_scope_query` (range-free `ImportScope` output) rather
176 // than reading `module_map_query` directly here avoids adding a new
177 // *effective* dependency on its range-bearing module diagnostics — see
178 // that query's own doc for why that distinction matters for cutoff.
179 let scope = file_import_scope_query(db, project, file);
180 let mut diagnostics = brink_analyzer::per_file_diagnostics(
181 file_id,
182 hir,
183 file_resolutions,
184 index,
185 opts.dialect,
186 is_native,
187 opts.host_manifest.as_ref(),
188 scope,
189 );
190 if is_native {
191 diagnostics.extend(brink_analyzer::native_strict_only_error(
192 file_id, opts.types,
193 ));
194 }
195 Arc::new(diagnostics)
196}
197
198/// Aggregated per-file diagnostic contributors across the whole project
199/// (issue #632 / FG-3 — "a thin aggregator" per the design doc). The loop
200/// itself is cheap: each iteration is a salsa memo lookup, not a HIR walk —
201/// [`per_file_diagnostics_query`]'s actual `validate`/`dialect_gate`/
202/// annotation-content work only re-runs for the file(s) whose own
203/// dependencies changed.
204#[salsa::tracked(returns(ref))]
205pub(crate) fn contributor_diagnostics_query(
206 db: &dyn salsa::Database,
207 project: ProjectInput,
208) -> Vec<Diagnostic> {
209 let mut out = Vec::new();
210 for file in project.files(db) {
211 // Issue #2329: a non-source document never runs validate/
212 // dialect_gate/annotation-content checks — its bogus "parse"
213 // diagnostics must never surface.
214 if !is_source_file(file.path(db)) {
215 continue;
216 }
217 out.extend(
218 per_file_diagnostics_query(db, project, *file)
219 .iter()
220 .cloned(),
221 );
222 }
223 out
224}
225
226/// The project-wide inline `///` doc merge (issue #750 / FG-3 completion —
227/// [`brink_analyzer::project_inline_docs`]), keyed by `(kind, declared
228/// name)`. Reads every file's manifest, but the output is range-free
229/// ([`DocBlock`] carries parsed doc content only), so any edit that leaves
230/// every `///` block intact backdates this memo — the `Eq`-cutoff seam
231/// between per-file manifest churn and the doc-consuming enrichment passes
232/// ([`external_meta_query`], [`value_meta_query`]).
233#[salsa::tracked(returns(ref))]
234pub(crate) fn inline_docs_query(
235 db: &dyn salsa::Database,
236 project: ProjectInput,
237) -> Arc<BTreeMap<(SymbolKind, String), DocBlock>> {
238 let manifest_inputs: Vec<(FileId, &SymbolManifest)> = project
239 .files(db)
240 .iter()
241 .filter(|f| is_source_file(f.path(db)))
242 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).manifest))
243 .collect();
244 Arc::new(brink_analyzer::project_inline_docs(&manifest_inputs))
245}
246
247/// The index-driven half of the external-check family (issue #750 / FG-3
248/// completion — [`brink_analyzer::external_meta_diagnostics`]): host-
249/// manifest enrichment + checks for externals (`E039`/`E040`) plus
250/// knot/stitch doc enrichment. Reads the *full ranged* [`symbol_index_query`]
251/// (diagnostic spans need real ranges) and [`inline_docs_query`] — never any
252/// file's HIR, which is the decomposition's point: the pre-#750 shape ran
253/// this inside a query that also walked every file's HIR, so any body edit
254/// re-ran the whole family. Cheap to re-execute (proportional to
255/// externals/callables, no HIR walk); its range-free `symbol_meta` half
256/// backdates dependents via [`call_site_metas_query`].
257#[derive(Debug, Clone, PartialEq)]
258pub(crate) struct ExternalMeta {
259 pub symbol_meta: BTreeMap<DefinitionId, SymbolMeta>,
260 pub diagnostics: Vec<Diagnostic>,
261}
262
263#[salsa::tracked(returns(ref))]
264pub(crate) fn external_meta_query(db: &dyn salsa::Database, project: ProjectInput) -> ExternalMeta {
265 let (index, _diags) = symbol_index_query(db, project);
266 let inline_docs = inline_docs_query(db, project);
267 let opts = project.analysis_options(db);
268 let (symbol_meta, diagnostics) =
269 brink_analyzer::external_meta_diagnostics(index, inline_docs, opts);
270 ExternalMeta {
271 symbol_meta,
272 diagnostics,
273 }
274}
275
276/// Name-keyed external metas for the call-site checks (issue #750 / FG-3
277/// completion — [`brink_analyzer::call_site_metas`]): the range-free
278/// projection of [`external_meta_query`]'s enrichment map, filtered to
279/// `SymbolKind::External`. This is the cutoff seam guarding every file's
280/// [`call_site_diagnostics_query`] memo (the `resolution_index` playbook):
281/// a body edit shifts declaration ranges → the full index changes →
282/// [`external_meta_query`] re-executes — but as long as no external's
283/// *content* (docs/manifest/params) changed, this projection comes out
284/// `Eq`, and every other file's call-site memo stays fully validated
285/// without re-executing. `Arc`-wrapped for the same pointer-identity
286/// reason as [`per_file_diagnostics_query`].
287#[salsa::tracked]
288pub(crate) fn call_site_metas_query(
289 db: &dyn salsa::Database,
290 project: ProjectInput,
291) -> Arc<BTreeMap<String, SymbolMeta>> {
292 let (index, _diags) = symbol_index_query(db, project);
293 let ext = external_meta_query(db, project);
294 Arc::new(brink_analyzer::call_site_metas(index, &ext.symbol_meta))
295}
296
297/// One file's VAR/CONST/LIST initializer/doc enrichment (issue #750 / FG-3
298/// completion — [`brink_analyzer::file_value_meta`]): purely presentational
299/// `symbol_meta` entries, no diagnostics. Reads only this file's own
300/// `lowered_query`, the range-zeroed [`inference_index_query`] projection
301/// (the pass reads `by_name` + `kind`, never a symbol's range — see the
302/// analyzer seam's doc), and [`inline_docs_query`] — so a body edit in file
303/// Y leaves file X's memo fully validated (same `Arc`), not re-executed.
304///
305/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
306#[salsa::tracked(lru = 4096)]
307pub(crate) fn value_meta_query(
308 db: &dyn salsa::Database,
309 project: ProjectInput,
310 file: SourceFile,
311) -> Arc<BTreeMap<DefinitionId, SymbolMeta>> {
312 let hir = &lowered_query(db, project, file).hir;
313 let index = inference_index_query(db, project);
314 let inline_docs = inline_docs_query(db, project);
315 Arc::new(brink_analyzer::file_value_meta(
316 file.file_id(db),
317 hir,
318 index,
319 inline_docs,
320 ))
321}
322
323/// One file's external call-site literal checks (`E041`/`E042`) — issue
324/// #750 / FG-3 completion, [`brink_analyzer::file_call_site_diagnostics`].
325/// Reads only this file's own `lowered_query` plus the range-free
326/// [`call_site_metas_query`] projection, so a body edit in file Y leaves
327/// file X's memo fully validated (same `Arc`), not re-executed — the last
328/// per-file HIR walk `finish_analysis` still ran project-wide. Empty when
329/// the `external_check` severity is `Off` (the same gate the monolithic
330/// path applies before walking any file).
331///
332/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
333#[salsa::tracked(lru = 4096)]
334pub(crate) fn call_site_diagnostics_query(
335 db: &dyn salsa::Database,
336 project: ProjectInput,
337 file: SourceFile,
338) -> Arc<Vec<Diagnostic>> {
339 if project.analysis_options(db).external_check == ExternalCheckSeverity::Off {
340 return Arc::new(Vec::new());
341 }
342 let metas = call_site_metas_query(db, project);
343 let hir = &lowered_query(db, project, file).hir;
344 Arc::new(brink_analyzer::file_call_site_diagnostics(
345 file.file_id(db),
346 hir,
347 &metas,
348 ))
349}
350
351/// One file's `#@effects(…)` exceedance diagnostics (T2-2,
352/// docs/effects-spec.md §10, issue #861). Brink-only, same TM-2
353/// content-check precedent [`per_file_diagnostics_query`]'s doc cites: under
354/// `strict-ink` the directive is already rejected whole by `dialect_gate`'s
355/// `E051`, so checking its declared names here would be noise.
356///
357/// Reads only the def ids [`brink_analyzer::effects_assertion_defs`] finds
358/// in *this file's* HIR (a structural scan — no inference triggered by the
359/// scan itself) and, for exactly those defs, the salsa-memoized per-def
360/// [`effects_query`]. A file with no `#@effects` directive at all never
361/// calls `effects_query`, so an unannotated project stays effect-inference-
362/// free — T2-1's advisory/lazy posture, preserved.
363///
364/// The assertion's `reads`/`writes`/`calls` clause names are resolved
365/// through this file's own [`brink_analyzer::ImportScope`] (issue #881, the
366/// T2 follow-up to M-2d/#790), built from [`module_map_query`] + this file's
367/// own `IMPORT`s exactly like [`resolve_query`] builds it — so the checker
368/// can never attribute a clause to a different declared module's same-name
369/// cell than the one this file's own resolution binds.
370///
371/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
372#[salsa::tracked(lru = 4096)]
373pub(crate) fn effects_assertion_diagnostics_query(
374 db: &dyn salsa::Database,
375 project: ProjectInput,
376 file: SourceFile,
377) -> Arc<Vec<Diagnostic>> {
378 if project.analysis_options(db).dialect != brink_analyzer::Dialect::Brink {
379 return Arc::new(Vec::new());
380 }
381 let file_id = file.file_id(db);
382 let hir = &lowered_query(db, project, file).hir;
383 let index = resolution_index_query(db, project);
384 let def_ids = brink_analyzer::effects_assertion_defs(hir, index, file_id);
385 if def_ids.is_empty() {
386 return Arc::new(Vec::new());
387 }
388 let mut rows = BTreeMap::new();
389 for id in def_ids {
390 if let Some(row) = effects_query(db, project, DefKey::new(db, id)) {
391 rows.insert(id, (*row).clone());
392 }
393 }
394 let (module_map, _module_diags) = module_map_query(db, project);
395 let file_module = module_map
396 .get(&file_id)
397 .filter(|m| m.declared)
398 .map(|m| m.name.clone());
399 let scope = brink_analyzer::ImportScope::new(file_module, &hir.imports);
400 Arc::new(brink_analyzer::effects_assertion_diagnostics(
401 file_id, hir, index, &scope, &rows,
402 ))
403}
404
405/// One file's FS-2 `await`-condition purity diagnostics (E105,
406/// docs/flow-suspension-spec.md §3/§5, issue #928). Brink-only + lazy, the
407/// same posture as [`effects_assertion_diagnostics_query`]: a file with no
408/// `await` never fetches a single per-def effect row, so an await-free project
409/// stays effect-inference-free.
410///
411/// Unlike the assertion query (which knows its target defs up front), the
412/// callees a condition names are discovered by resolving the condition's
413/// calls ([`brink_analyzer::await_condition_callees`]); each is judged by its
414/// salsa-memoized per-def [`effects_query`] row — the incremental analogue of
415/// the monolithic path's whole-project `effects_project` table.
416///
417/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
418#[salsa::tracked(lru = 4096)]
419pub(crate) fn await_purity_diagnostics_query(
420 db: &dyn salsa::Database,
421 project: ProjectInput,
422 file: SourceFile,
423) -> Arc<Vec<Diagnostic>> {
424 if project.analysis_options(db).dialect != brink_analyzer::Dialect::Brink {
425 return Arc::new(Vec::new());
426 }
427 let file_id = file.file_id(db);
428 let hir = &lowered_query(db, project, file).hir;
429 if !brink_analyzer::hir_has_await(hir) {
430 return Arc::new(Vec::new());
431 }
432 let (file_resolutions, _diags) = resolve_query(db, project, file);
433 let index = resolution_index_query(db, project);
434 let callee_defs = brink_analyzer::await_condition_callees(file_id, hir, file_resolutions);
435 let mut rows = BTreeMap::new();
436 for id in callee_defs {
437 if let Some(row) = effects_query(db, project, DefKey::new(db, id)) {
438 rows.insert(id, (*row).clone());
439 }
440 }
441 Arc::new(brink_analyzer::await_purity_diagnostics(
442 file_id,
443 hir,
444 index,
445 file_resolutions,
446 &rows,
447 ))
448}
449
450/// The module name the project's `[project] conventions` pointer names, or
451/// `None` when there is nothing to resolve.
452///
453/// The ONE place the `conventions` pointer is turned into a module name, so
454/// the two consumers that need it — [`conventions_confinement_diagnostics_query`]
455/// (which asks "is *this* file that module?") and
456/// [`conventions_projection_query`] (which asks "*which* file is that
457/// module?") — cannot drift apart on the answer. Both must agree exactly:
458/// a projection built from a file that confinement does not consider the
459/// conventions module would report handlers the compiler is simultaneously
460/// diagnosing as misplaced.
461///
462/// `None` covers the two cases both consumers treat identically and
463/// silently (see `brink_analyzer::conventions_module_diagnostics`'s own
464/// module doc): an unset `conventions` key, and a **bare preset name**
465/// (`conventions = "screenplay"`), which names a `std::conventions::*` module
466/// rather than a project file — `brink_analyzer::BUILTIN_ELEMENT_PRESETS`'s
467/// own doc records that nothing resolves a preset name to its mounted
468/// source yet (it needs #1582's pub marker and #2167's closure-scoped
469/// confinement, neither built). `Some` does NOT mean a file with that
470/// module name exists; each caller checks that against
471/// [`module_map_query`] itself, because they warn differently about it.
472fn expected_conventions_module(db: &dyn salsa::Database, project: ProjectInput) -> Option<String> {
473 let opts = project.analysis_options(db);
474 let pointer = opts.conventions.as_deref()?;
475 if !brink_analyzer::is_path_shaped_conventions_pointer(pointer) {
476 return None;
477 }
478 // `conventions_pointer_key`, NOT `root_relative_key` (issue #2320): a
479 // relative pointer is root-relative by definition — it is written in
480 // `brink.toml`, the file whose directory defines the root — so it must
481 // never be resolved against the process cwd the way a registered file
482 // key's relative spelling must be. See that helper's own doc for the
483 // `brink-lsp` launch-cwd failure this distinction fixes.
484 Some(crate::modules::native_module_path(
485 &crate::modules::conventions_pointer_key(project.native_root(db).as_deref(), pointer),
486 ))
487}
488
489/// One file's conventions-module confinement diagnostics (`E169`, issue
490/// #1844 — the MODULE half of the 2026-07-31 §9.1 ruling's item (4); #1838/
491/// #1847 cover the *placement* half, `E112`). A pattern-claiming
492/// `@[convention(claims = "…", order = N)]` handler is legal only in the project's
493/// configured conventions module (`brink.toml`'s `[project] conventions`,
494/// renamed from `elements` by issue #2180); this is the db-direct seam that
495/// has both a file's real module identity ([`module_map_query`]'s native
496/// branch, `crate::modules::native_module_path`) and the resolved
497/// `AnalysisOptions` the pointer travels on. `brink_analyzer::
498/// analyze_with_modules` (the off-db road: `IdeSnapshot::analyze`,
499/// `brink-lsp`'s `analysis_loop`) is now a second such seam — issue #2335
500/// added `brink_analyzer::conventions_confinement_diagnostics`, which
501/// mirrors this query file-for-file via its own caller-computed module
502/// identity, since a caller with no `ProjectDb` cannot ask
503/// [`module_map_query`] directly.
504///
505/// Lazy in the same shape as [`await_purity_diagnostics_query`]/
506/// [`comparator_contract_diagnostics_query`]: a file with no declared claim
507/// handler never even reads [`module_map_query`]. One case stays
508/// intentionally silent (not merely lazy) — see `brink_analyzer::
509/// conventions_module_diagnostics`'s own module doc for why: a bare preset
510/// name (`conventions = "screenplay"`, which names a `std::conventions::*`
511/// module rather than a project file — no path in the tree to compare
512/// against without a preset registry this slice doesn't build). A
513/// path-shaped pointer that resolves to no file that actually exists in
514/// `project.files(db)` (a typo, a moved/deleted target, an `.ink`-suffixed
515/// path, or a pointer whose minted module doesn't line up with the file
516/// keys — e.g. a `brink.toml` discovered at a nested key) used to be a
517/// second such silent case, `tracing::warn!`-only; as of issue #2320 it
518/// reports a real `E169` per declared handler instead — checked HERE,
519/// against [`module_map_query`]'s real module set, before any file is
520/// compared against it, and worded to blame the *pointer* rather than the
521/// handlers' placement (see the arm's own comment below for why
522/// per-handler, and why the wording differs from the confinement
523/// message).
524///
525/// An **entirely unset `conventions` key is NO LONGER one of those silent
526/// cases** (issue #2289, part 2 of the 2026-08-05 ruling): a declared claim
527/// handler names no module to belong to, which is a misconfiguration, not
528/// an opt-out — see [`brink_analyzer::conventions_unconfigured_diagnostics`]'s
529/// own doc.
530///
531/// **A file mounted under a reserved peer root is exempt entirely**
532/// (`std::…`, `brink_ir::symbols::is_reserved_root_module`, issue #2251) —
533/// found while implementing the unset-key case above: `brink-environment`
534/// unconditionally mounts `std::conventions::screenplay` into every
535/// compiled project's file set (issue #2080) regardless of whether that
536/// project's own `brink.toml` ever names it, so with no exemption every
537/// project with *no* `conventions` key configured would suddenly fail to
538/// compile at all — the mounted preset's own `heading`/`transition`/`cue`/
539/// `parenthetical` handlers would all misfire the new unconfigured-`E169`
540/// above (verified against a real `brink compile` run on a bare
541/// `[project]` toml before this exemption was added). The SAME exemption
542/// also fixes a latent, pre-#2289 instance of this bug: a project that
543/// *did* configure `conventions` to one of its own files already flagged
544/// the mounted preset's handlers as "outside the configured module",
545/// which was never reachable before because the unset-key short-circuit
546/// silently protected the far more common no-`conventions`-at-all case
547/// from ever exercising this code path at all.
548///
549/// `lru = 4096`: per-file runaway-guard ceiling (issue #647), matching
550/// every other per-file diagnostic query in this module.
551#[salsa::tracked(lru = 4096)]
552pub(crate) fn conventions_confinement_diagnostics_query(
553 db: &dyn salsa::Database,
554 project: ProjectInput,
555 file: SourceFile,
556) -> Arc<Vec<Diagnostic>> {
557 let hir = &lowered_query(db, project, file).hir;
558 if hir.claim_handlers.is_empty() {
559 return Arc::new(Vec::new());
560 }
561 let file_id = file.file_id(db);
562 let (module_map, _module_diags) = module_map_query(db, project);
563 if module_map
564 .get(&file_id)
565 .is_some_and(|m| brink_ir::symbols::is_reserved_root_module(&m.name))
566 {
567 return Arc::new(Vec::new());
568 }
569 let opts = project.analysis_options(db);
570 let Some(pointer) = opts.conventions.as_deref() else {
571 // Issue #2289 part 2: no module configured at all is now an error,
572 // not a silent pass — see this query's own doc.
573 return Arc::new(brink_analyzer::conventions_unconfigured_diagnostics(
574 file_id, hir,
575 ));
576 };
577 // Shared with `conventions_projection_query` so the two cannot disagree
578 // about which module the pointer names — see the helper's own doc. The
579 // path-shape check `origin/main` renamed (`is_path_shaped_elements_pointer`
580 // -> `is_path_shaped_conventions_pointer`, #2180) lives inside that helper
581 // now, so this call site subsumes it rather than duplicating it.
582 let Some(expected_module) = expected_conventions_module(db, project) else {
583 return Arc::new(Vec::new());
584 };
585 let Some(this_module) = module_map.get(&file_id).map(|m| m.name.as_str()) else {
586 return Arc::new(Vec::new());
587 };
588 // The pointer must resolve against a REAL file in the project before it
589 // can confine anything. A typo'd `conventions` value, a moved/deleted
590 // target, an `.ink`-suffixed path, or a `brink.toml` discovered at a
591 // nested key all produce an `expected_module` no file actually has —
592 // without this check, every claiming handler in the project (including
593 // the one in the real intended conventions module) would be flagged
594 // with the confinement message, telling the author to move it into a
595 // file that does not exist, with no signal that the config itself is
596 // at fault. That misleading-message storm is what this guard prevents;
597 // it does NOT mean the case goes unreported. As of issue #2320 the arm
598 // below emits a real `E169` per declared handler — deliberately still
599 // per-handler, not one per project: this query is per-file (a
600 // project-level singleton would need an arbitrary anchor file, which a
601 // per-file salsa query cannot pick without reading every other file's
602 // handlers), each handler's annotation gives the diagnostic a real
603 // range to attach to, and the per-handler shape matches
604 // `conventions_unconfigured_diagnostics`'s (issue #2289) treatment of
605 // the sibling "no module configured at all" misconfiguration. What
606 // makes it not-a-storm is the WORDING: it blames the pointer ("does
607 // not match any file… fix the `conventions` pointer"), never the
608 // handler's placement. `module_map`'s iteration order can't affect
609 // this check: `any` only asks whether *some* file matches, never
610 // which one.
611 if !module_map.values().any(|m| m.name == expected_module) {
612 // The log line is kept alongside the diagnostic (server/CLI
613 // contexts still get it), but it is no longer the ONLY signal —
614 // `brink-web`'s wasm build has no `tracing` subscriber at all, so
615 // a warn-only report was invisible to every wasm consumer.
616 tracing::warn!(
617 "[project] conventions = \"{pointer}\" does not match any file in the project \
618 (expected module `{expected_module}`) — conventions-module confinement (E169) \
619 is skipped until this is fixed"
620 );
621 // Issue #2320: mirrored in the off-db road's sibling
622 // (`brink_analyzer::conventions_confinement_diagnostics`, the one
623 // `IdeSnapshot::analyze`/`brink-web` actually call) so the two
624 // roads stay behaviorally aligned rather than diverging on which
625 // one got fixed.
626 return Arc::new(
627 brink_analyzer::conventions_pointer_unresolvable_diagnostics(file_id, hir, pointer),
628 );
629 }
630 let is_conventions_module = this_module == expected_module;
631 Arc::new(brink_analyzer::conventions_module_diagnostics(
632 file_id,
633 hir,
634 is_conventions_module,
635 pointer,
636 ))
637}
638
639/// The project's cross-file claiming injection seam (issue #2289,
640/// correcting the file-local claiming defect the 2026-08-05 ruling names:
641/// *"it's never file local. you configure conventions for a project,
642/// that's why they're conventions and not 'local patterns'."*): the
643/// configured conventions module's OWN declared `@[convention]` handlers,
644/// plus which file that module is, so [`super::lowered_query`] knows both
645/// what to inject into every other file and which file to skip (itself).
646///
647/// `None` in every case [`conventions_projection_query`] already treats as
648/// "nothing to inject" — no `conventions` key, a bare preset pointer, or a
649/// path-shaped pointer that resolves to no real project file (the same
650/// "warn, never silently drop" channel that query and
651/// [`conventions_confinement_diagnostics_query`] both use). This query
652/// deliberately re-derives its own small "which file is the expected
653/// module" resolution rather than sharing either sibling's — see
654/// [`expected_conventions_module`]'s own doc for why the pointer-to-name
655/// step itself IS shared, and this module's existing precedent of each
656/// consumer owning its own file-lookup walk (`conventions_confinement_
657/// diagnostics_query`'s `any`, `conventions_projection_query`'s
658/// `min_by_key`) — this query's own `min_by_key` matches the latter's
659/// deterministic tie-break.
660///
661/// Reads [`raw_lowered_query`] for the conventions module's own file, never
662/// the project-aware [`lowered_query`]: `HirFile::claim_handlers` is always
663/// the file's own LOCAL declarations regardless of what (if anything) that
664/// file itself was lowered with injected (`Elements::handler_decls` in
665/// `brink-ir` never reads an injected handler — see that method's own
666/// doc), so the two queries agree here by construction. Reading the
667/// project-aware query instead would close a cycle: `lowered_query` calls
668/// this query to decide what to inject, so this query calling back into
669/// `lowered_query` for the very file it is about to hand off would recurse
670/// on itself the moment that file needed lowering.
671///
672/// `Arc`-wrapped: every native file in the project reads this once per
673/// project revision ([`super::lowered_query`]'s dependency), and `Option<
674/// (FileId, Vec<ClaimHandlerDecl>)>`'s derived `PartialEq` backdates it
675/// across an edit that leaves the conventions module's declared handler
676/// set unchanged (e.g. a body-only edit inside one of its handlers) — the
677/// same early-cutoff shape [`import_closure_query`]/[`conventions_projection_query`]
678/// already rely on.
679#[salsa::tracked(returns(ref))]
680pub(crate) fn external_claim_handlers_query(
681 db: &dyn salsa::Database,
682 project: ProjectInput,
683) -> Arc<Option<(FileId, Vec<brink_ir::ClaimHandlerDecl>)>> {
684 let opts = project.analysis_options(db);
685 let Some(pointer) = opts.conventions.as_deref() else {
686 return Arc::new(None);
687 };
688 let Some(expected_module) = expected_conventions_module(db, project) else {
689 return Arc::new(None);
690 };
691 let (module_map, _module_diags) = module_map_query(db, project);
692 let Some(conventions_file) = project
693 .files(db)
694 .iter()
695 .filter(|f| {
696 module_map
697 .get(&f.file_id(db))
698 .is_some_and(|m| m.name == expected_module)
699 })
700 .min_by_key(|f| f.path(db).clone())
701 .copied()
702 else {
703 // Same "warn, never silently drop" channel this query's siblings use
704 // for the identical unresolvable-pointer case.
705 tracing::warn!(
706 "[project] conventions = \"{pointer}\" does not match any file in the project \
707 (expected module `{expected_module}`) — cross-file claiming is skipped until \
708 this is fixed"
709 );
710 return Arc::new(None);
711 };
712 let hir = &raw_lowered_query(db, conventions_file).hir;
713 Arc::new(Some((
714 conventions_file.file_id(db),
715 hir.claim_handlers.clone(),
716 )))
717}
718
719/// The transitive `IMPORT` closure of `entry`: `entry` itself plus every
720/// module reachable by following native `IMPORT` statements outward,
721/// breadth over the module-name → file reverse index [`module_map_query`]
722/// already builds. Issue #2111 finding 3: built in a **reusable** shape,
723/// generic over any entry file rather than conventions-specific, so #2167's
724/// `E169` confinement relaxation (legalizing a claim handler that delegates
725/// to an imported preset) can call this exact query instead of re-deriving
726/// its own closure walk.
727///
728/// Ink files have no `IMPORT` (only `INCLUDE`, which
729/// [`super::compilation_closure_files`]/`IncludeGraph` already cover) — an
730/// ink `entry` simply has no imports to walk and the closure is `[entry]`.
731///
732/// Sorted ascending by path (not discovery/traversal order) before
733/// returning: two callers that both need "first file wins on a name
734/// collision" (this module's own [`conventions_projection_query`], and any
735/// future #2167 use) get the same deterministic tie-break without each
736/// having to re-sort, and the order can never depend on `project.files`'
737/// incoming order or on which import statement happened to be visited
738/// first.
739///
740/// The same path-sorted, first-wins rule governs name resolution *while
741/// walking imports*, too: when two files in the project declare the same
742/// module name, the import-target lookup resolves to the lowest-path file,
743/// deterministically — never whichever one `project.files` happened to
744/// iterate over last. Both the closure's final **order** and its
745/// **membership** (which file a duplicate name resolves to) are therefore
746/// independent of `project.files`' incoming order.
747///
748/// A named-but-unresolvable import (a typo, a module that doesn't exist)
749/// is simply not followed — this query only ever widens by real, resolved
750/// files, never by a dangling name a diagnostic elsewhere already reports.
751#[salsa::tracked(returns(ref))]
752pub(crate) fn import_closure_query(
753 db: &dyn salsa::Database,
754 project: ProjectInput,
755 entry: SourceFile,
756) -> Arc<Vec<SourceFile>> {
757 let (module_map, _module_diags) = module_map_query(db, project);
758 // First-wins on a duplicate module name, over a path-sorted iteration —
759 // not the `.collect()`-into-map last-write-wins this replaced. A plain
760 // `.collect()` made a duplicate name's WINNER (not just its resolution
761 // order) depend on `project.files`' incoming order, contradicting this
762 // query's own "the order can never depend on `project.files`' incoming
763 // order" doc and the `min_by_key` determinism this same file applies
764 // ~30 lines below for the conventions file itself.
765 let mut files_by_path: Vec<SourceFile> = project.files(db).clone();
766 files_by_path.sort_by_key(|f| f.path(db).clone());
767 let mut by_name: BTreeMap<&str, SourceFile> = BTreeMap::new();
768 for f in &files_by_path {
769 if let Some(m) = module_map.get(&f.file_id(db)) {
770 by_name.entry(m.name.as_str()).or_insert(*f);
771 }
772 }
773
774 let mut seen = std::collections::BTreeSet::new();
775 seen.insert(entry.file_id(db));
776 let mut stack = vec![entry];
777 let mut closure = Vec::new();
778 while let Some(file) = stack.pop() {
779 closure.push(file);
780 let hir = &lowered_query(db, project, file).hir;
781 for import in &hir.imports {
782 if let Some(target) = by_name.get(import.module.as_str()).copied()
783 && seen.insert(target.file_id(db))
784 {
785 stack.push(target);
786 }
787 }
788 }
789 closure.sort_by_key(|f| f.path(db).clone());
790 Arc::new(closure)
791}
792
793/// The project's conventions projection (issue #2111, NS-T seam 1/6):
794/// every `@[convention]` handler declared in the project's one configured
795/// conventions module, ascending by `order` — the editor-facing artifact
796/// the design-backport comment on #2111 (`docs/decision-log.md`
797/// 2026-08-03) calls "THE SOLE EDITOR INTERCHANGE": claims pattern, order,
798/// mode (attach/wrap), resulting disposition, and the `attach = StructName`
799/// schema, now RESOLVED to its fields and their types (issue #2111
800/// continuation, finding 1). Schema, never values — see
801/// [`ConventionsProjection`]'s own doc for that boundary, for why no
802/// comptime-fault/last-good case exists here (the mechanism that would have
803/// needed one, `fn conventions()` registration, is dissolved), and for the
804/// one part of #2111 this query still does not deliver (wire emission into
805/// `.inkb`/`StoryData` — see that type's doc).
806///
807/// # Resolution (shared with [`conventions_confinement_diagnostics_query`])
808///
809/// Both queries route the `[project] conventions` pointer through the same
810/// [`expected_conventions_module`] helper, so "which module is the
811/// conventions module" has exactly one answer:
812///
813/// - No `conventions` configured at all → empty projection. There is no
814/// conventions module to project.
815/// - A **bare preset name** (`conventions = "screenplay"`, not path-shaped) →
816/// ALSO empty, for now. `brink_analyzer::BUILTIN_ELEMENT_PRESETS`'s own
817/// doc states plainly that nothing resolves a preset name to its mounted
818/// source yet: "`std::conventions::screenplay` has no real
819/// `use`-importable module path… that needs #1582's pub marker and
820/// #2167's closure-scoped confinement, neither built yet." Minting a
821/// bespoke resolution here (bypassing that stated dependency) would be
822/// exactly the kind of undetermined-default invention the parent ruling's
823/// own "do not invent it" caution warns against — the issue's own
824/// "pre-frozen preset" note describes a *destination*, not something
825/// this slice can honestly claim to deliver ahead of #1582/#2167.
826/// - A path-shaped pointer that resolves to a real project file → that
827/// file's own [`ClaimHandlerDecl`]s, projected, with each `attach` name
828/// resolved against every struct visible from that file's [`import_closure_query`]
829/// (finding 3).
830/// - A path-shaped pointer that resolves to no real file → empty, with the
831/// same `tracing::warn!` this query's confinement sibling emits (never a
832/// silent drop).
833///
834/// # Invalidation
835///
836/// Reads: `project.analysis_options(db)` (for the `conventions` pointer and
837/// the native root), [`module_map_query`] (to find which file carries the
838/// expected module name, and to resolve `IMPORT` targets), the resolved
839/// conventions module's own [`import_closure_query`] (finding 3 — widened
840/// from the pre-continuation "conventions module alone" footprint), and
841/// every file in that closure's own [`lowered_query`] output (for their
842/// `structs`, to resolve `attach` names). `module_map_query` and
843/// `import_closure_query` are whole-project-shaped, so an edit elsewhere
844/// can *reach* this query — but only through a changed module map or a
845/// changed closure, both of which salsa backdates when their value is
846/// unchanged, so an ordinary edit to a file **outside** the closure never
847/// re-executes this query's closure (proven by
848/// `tests/issue_2111_conventions_projection.rs`'s `Arc::ptr_eq` cases,
849/// including the new one for an edit to an *imported* struct file). See
850/// [`ConventionsProjection`]'s own doc for why this is now the ruled "the
851/// conventions module and its import closure" invalidation contract exactly,
852/// not the narrower "conventions module alone" reading the pre-continuation
853/// slice used (load-bearing on the un-resolved-schema shape that slice
854/// carried, and no longer true now that `attach` is resolved).
855#[salsa::tracked(returns(ref))]
856pub(crate) fn conventions_projection_query(
857 db: &dyn salsa::Database,
858 project: ProjectInput,
859) -> Arc<brink_ir::ConventionsProjection> {
860 let opts = project.analysis_options(db);
861 let Some(pointer) = opts.conventions.as_deref() else {
862 return Arc::new(brink_ir::ConventionsProjection::default());
863 };
864 // `None` here = a bare preset name — see the helper's own doc for why
865 // that does not resolve to a projectable file yet.
866 let Some(expected_module) = expected_conventions_module(db, project) else {
867 return Arc::new(brink_ir::ConventionsProjection::default());
868 };
869 let (module_map, _module_diags) = module_map_query(db, project);
870 // `min_by_key` on the path, not `find`: unlike the confinement
871 // sibling's `any` (which only asks *whether* some file matches), this
872 // query has to pick *which* one, and a pick that depended on
873 // `project.files`' incoming order would be exactly the nondeterminism
874 // the house rule forbids. A collision should be impossible — a native
875 // file's module name is a pure function of its path — but "impossible"
876 // is not a reason to leave the tie-break to iteration order.
877 let Some(conventions_file) = project
878 .files(db)
879 .iter()
880 .filter(|f| {
881 module_map
882 .get(&f.file_id(db))
883 .is_some_and(|m| m.name == expected_module)
884 })
885 .min_by_key(|f| f.path(db).clone())
886 else {
887 // Same "warn, never silently drop" channel
888 // `conventions_confinement_diagnostics_query` uses for the
889 // identical unresolvable-pointer case.
890 tracing::warn!(
891 "[project] conventions = \"{pointer}\" does not match any file in the project \
892 (expected module `{expected_module}`) — the conventions projection is empty \
893 until this is fixed"
894 );
895 return Arc::new(brink_ir::ConventionsProjection::default());
896 };
897
898 // Issue #2111 finding 3: every struct visible from the conventions
899 // module's own file plus its transitive `IMPORT` closure, keyed by bare
900 // name. `import_closure_query` already returns files sorted ascending
901 // by path, and `entry(...).or_insert_with` is first-write-wins, so a
902 // name collision across two imported files resolves to the
903 // lexicographically-first path deterministically — the same tie-break
904 // posture `min_by_key` above uses for the conventions file itself.
905 let closure = import_closure_query(db, project, *conventions_file);
906 let mut structs: BTreeMap<String, Vec<brink_ir::ConventionAttachField>> = BTreeMap::new();
907 for file in closure.iter() {
908 let hir = &lowered_query(db, project, *file).hir;
909 for s in &hir.structs {
910 structs.entry(s.name.text.clone()).or_insert_with(|| {
911 s.fields
912 .iter()
913 .map(|f| brink_ir::ConventionAttachField {
914 name: f.name.text.clone(),
915 ty: brink_ir::SchemaTypeShape::from(&f.ty),
916 })
917 .collect()
918 });
919 }
920 }
921
922 let hir = &lowered_query(db, project, *conventions_file).hir;
923 // Issue #2352: `dispatch_handlers` (every `@[element(args = "…")]`
924 // `!name`-sigil handler declared in this same file) is the second row
925 // source `ConventionsProjection::from_decls` needs. Reading only THIS
926 // file's own `hir.dispatch_handlers` is a real, documented LIMITATION,
927 // not a design choice that makes this query honestly complete: `!name`
928 // dispatch is itself file-local at the LANGUAGE level
929 // (`hir::lower_native::element`'s own module doc, "Deliberately not
930 // here" — a `!name` handler is reachable only from lines in the SAME
931 // file it's declared in, wherever that file is), so this projection —
932 // which only ever reads the ONE configured conventions module's file —
933 // surfaces a dispatch row if and only if the handler happens to be
934 // declared in that particular file. A `!name` handler declared in an
935 // ordinary story file (the common case — dispatch has no confinement
936 // rule the way `@[convention]` does) contributes NO row here at all,
937 // even though it is a perfectly legal, live handler in its own file.
938 // See `brink_ir::ConventionsProjection::dispatch`'s own doc for the
939 // same limitation stated from the type's side, and issue #2352 for the
940 // open design question this leaves ("where do file-local `!name` rows
941 // live") that this query does not attempt to answer.
942 let projection = brink_ir::ConventionsProjection::from_decls(
943 &hir.claim_handlers,
944 &hir.dispatch_handlers,
945 &structs,
946 );
947 for entry in &projection.entries {
948 if let Some(brink_ir::ConventionAttachSchema::Unresolved(name)) = &entry.attach {
949 tracing::warn!(
950 "`@[convention(…, attach = {name})]` on `{}` does not resolve to any struct \
951 declared in the conventions module `{expected_module}` or its import closure \
952 — the projection carries this attach clause as `Unresolved` rather than \
953 dropping it",
954 entry.name.text
955 );
956 }
957 }
958 Arc::new(projection)
959}
960
961/// One file's NS-A4 comparator-contract diagnostics (E119,
962/// docs/stdlib-spec.md §4b, issue #1110 — extended to the fn-value verb
963/// trio `map`/`filter`/`fold` by issue #1679, §4): `sort_by`/`sorted_by`/
964/// `map`/`filter`/`fold` calls whose callback's row — named either by an
965/// inline `#fn(target)` literal (ink/brink) or, since issue #1887, a
966/// native bare-name reference — provably exceeds pure·silent. Brink-only
967/// + lazy, the exact
968/// [`await_purity_diagnostics_query`] shape: a file with no such site never
969/// fetches a single per-def effect row, so a callback-free project stays
970/// effect-inference-free.
971///
972/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
973#[salsa::tracked(lru = 4096)]
974pub(crate) fn comparator_contract_diagnostics_query(
975 db: &dyn salsa::Database,
976 project: ProjectInput,
977 file: SourceFile,
978) -> Arc<Vec<Diagnostic>> {
979 if project.analysis_options(db).dialect != brink_analyzer::Dialect::Brink {
980 return Arc::new(Vec::new());
981 }
982 let file_id = file.file_id(db);
983 let hir = &lowered_query(db, project, file).hir;
984 if !brink_analyzer::hir_has_comparator_site(hir) {
985 return Arc::new(Vec::new());
986 }
987 let (file_resolutions, _diags) = resolve_query(db, project, file);
988 let index = resolution_index_query(db, project);
989 let callee_defs = brink_analyzer::comparator_callees(file_id, hir, index, file_resolutions);
990 let mut rows = BTreeMap::new();
991 for id in callee_defs {
992 if let Some(row) = effects_query(db, project, DefKey::new(db, id)) {
993 rows.insert(id, (*row).clone());
994 }
995 }
996 Arc::new(brink_analyzer::comparator_contract_diagnostics(
997 file_id,
998 hir,
999 index,
1000 file_resolutions,
1001 &rows,
1002 ))
1003}
1004
1005/// Whole-project diagnostics + `symbol_meta` (issue #632 / FG-3 design doc
1006/// §1), now a thin aggregator (issue #750 / FG-3 completion) over the
1007/// decomposed external-check family — [`external_meta_query`] + per-file
1008/// [`value_meta_query`] / [`call_site_diagnostics_query`] — plus the two
1009/// genuinely whole-project passes left: the M-2 module import/visibility
1010/// checks ([`brink_analyzer::module_diagnostics`], which need every file's
1011/// HIR plus the project-wide resolutions) and, under `types = strict`, the
1012/// strict typed-mode checks ([`brink_analyzer::strict_diagnostics`], which
1013/// need a whole-project [`InferenceResult`] — the FG-4-era candidate for a
1014/// per-SCC-reading split, out of #750's scope). The aggregation loops are
1015/// salsa memo lookups, not HIR walks: a body edit in file Y re-runs only
1016/// Y's own value-meta/call-site contributors (plus the modules pass, which
1017/// post-dates #750's decomposition — M-1/M-2 landed while this slice was in
1018/// flight and is per-file-splittable follow-up work if it ever shows up
1019/// hot).
1020///
1021/// [`InferenceResult`]: brink_analyzer::InferenceResult
1022#[derive(Debug, Clone, PartialEq)]
1023pub(crate) struct WholeProjectDiagnostics {
1024 pub diagnostics: Vec<Diagnostic>,
1025 pub symbol_meta: BTreeMap<DefinitionId, SymbolMeta>,
1026}
1027
1028/// Run `per_file` over every SOURCE file in file order, skipping non-source
1029/// documents (issue #2329: brink.toml/.md/.json never contribute analysis).
1030/// The shared shape of every per-file pass in
1031/// [`whole_project_diagnostics_query`] — the gate lives here once, not
1032/// copy-pasted per loop (and clippy's `too_many_lines` on the aggregator is
1033/// what finally forced the extraction).
1034fn for_each_source_file(
1035 db: &dyn salsa::Database,
1036 project: ProjectInput,
1037 mut per_file: impl FnMut(SourceFile),
1038) {
1039 for file in project.files(db) {
1040 if is_source_file(file.path(db)) {
1041 per_file(*file);
1042 }
1043 }
1044}
1045
1046#[salsa::tracked(returns(ref))]
1047pub(crate) fn whole_project_diagnostics_query(
1048 db: &dyn salsa::Database,
1049 project: ProjectInput,
1050) -> WholeProjectDiagnostics {
1051 let opts = project.analysis_options(db);
1052 let resolved = resolutions_index_query(db, project);
1053 let hir_refs: Vec<(FileId, &HirFile)> = project
1054 .files(db)
1055 .iter()
1056 .filter(|f| is_source_file(f.path(db)))
1057 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1058 .collect();
1059
1060 // M-2 module import + visibility checks (docs/modules-spec.md
1061 // §2/§4/§7), first in diagnostic order (matching
1062 // `whole_project_diagnostics`'s monolithic composition).
1063 let mut diagnostics =
1064 brink_analyzer::module_diagnostics(&hir_refs, &resolved.index, &resolved.resolutions);
1065
1066 // TM-3 strict typed-mode pass (docs/typed-mode-spec.md §9-step-3),
1067 // second in diagnostic order. Under `types = strict` + `dialect =
1068 // brink`, reuse the already-memoized, FG-narrowed
1069 // `type_inference_query` instead of letting the analyzer recompute
1070 // inference from scratch via `infer_project` — the "inference finally
1071 // has a consumer" seam the per-def/per-SCC decomposition (FG-2, FG-2.1)
1072 // exists for. Gradual mode (the default) skips this block entirely.
1073 if opts.type_policy() == TypePolicy::Strict {
1074 let strict_inference = (opts.dialect == brink_analyzer::Dialect::Brink)
1075 .then(|| type_inference_query(db, project).as_ref());
1076 // `strict_inference` is always `Some` here whenever `dialect =
1077 // brink` (the only case `strict_diagnostics`'s own fallback would
1078 // otherwise run `infer_project`), so `inline_docs_query` is read
1079 // for uniformity with the pure path's signature (issue #805) —
1080 // `type_inference_query` -> `solve_scc_query` already reads the
1081 // same memo for the actual `EXTERNAL`-signature seeding.
1082 let inline_docs = inline_docs_query(db, project);
1083 // `is_native` (issue #1348): `dialect` is an ink-only axis — a
1084 // native project has no dialect to be wrong about, so the ink-only
1085 // `E064` config error must never fire for one.
1086 //
1087 // `project_is_all_native`, NOT the entry-derived
1088 // `project_is_native` (option A landing, 2026-08-24): an editor
1089 // session analyzes with no compile entry set, where the
1090 // entry-derived predicate answers `false` and the ink-arm `E064`
1091 // fires on an all-native session — the latent divergence
1092 // `native_rule_selection.rs`'s non-vacuity guards exposed the
1093 // moment the editor started reading this query. All-native is also
1094 // the classification the retired off-db road used (#1358, with the
1095 // #2318 non-source carve-out), and the correct one for a mixed
1096 // project under `types = strict` regardless of entry: any ink
1097 // member file genuinely needs `dialect = brink` for strict's
1098 // annotation syntax.
1099 diagnostics.extend(brink_analyzer::strict_diagnostics(
1100 &hir_refs,
1101 &resolved.index,
1102 &resolved.resolutions,
1103 opts,
1104 super::project_is_all_native(db, project),
1105 strict_inference,
1106 inline_docs,
1107 ));
1108 }
1109
1110 // Externals + callables (index-driven, memoized without HIR deps), then
1111 // per-file value metas (file order), then per-file call-site checks
1112 // (file order) — exactly `brink_analyzer::whole_project_diagnostics`'s
1113 // own composition order.
1114 let ext = external_meta_query(db, project);
1115 diagnostics.extend(ext.diagnostics.iter().cloned());
1116 let mut symbol_meta = ext.symbol_meta.clone();
1117
1118 // Every per-file pass below runs through `for_each_source_file`
1119 // (issue #2329): a non-source document never contributes a value-meta
1120 // entry, a call-site check, or any lazy effect/comparator/conventions
1121 // pass.
1122 for_each_source_file(db, project, |file| {
1123 symbol_meta.extend(
1124 value_meta_query(db, project, file)
1125 .iter()
1126 .map(|(k, v)| (*k, v.clone())),
1127 );
1128 });
1129 for_each_source_file(db, project, |file| {
1130 diagnostics.extend(
1131 call_site_diagnostics_query(db, project, file)
1132 .iter()
1133 .cloned(),
1134 );
1135 });
1136 // T2-2 `#@effects(…)` exceedance check (docs/effects-spec.md §10, issue
1137 // #861) — per-file, lazy (see `effects_assertion_diagnostics_query`'s
1138 // doc): a project with no `#@effects` directive never triggers effect
1139 // inference here.
1140 for_each_source_file(db, project, |file| {
1141 diagnostics.extend(
1142 effects_assertion_diagnostics_query(db, project, file)
1143 .iter()
1144 .cloned(),
1145 );
1146 });
1147 // FS-2 `await`-condition purity gate (E105,
1148 // docs/flow-suspension-spec.md §3/§5, issue #928) — per-file, lazy (see
1149 // `await_purity_diagnostics_query`'s doc): an await-free project never
1150 // triggers effect inference here.
1151 for_each_source_file(db, project, |file| {
1152 diagnostics.extend(
1153 await_purity_diagnostics_query(db, project, file)
1154 .iter()
1155 .cloned(),
1156 );
1157 });
1158 // NS-A4 comparator-contract gate (E119, docs/stdlib-spec.md §4b, issue
1159 // #1110 — extended to the fn-value verb trio `map`/`filter`/`fold` by
1160 // issue #1679, §4) — per-file, lazy (see
1161 // `comparator_contract_diagnostics_query`'s doc): a project with no
1162 // inline-`#fn` comparator/callback site never triggers effect
1163 // inference here.
1164 for_each_source_file(db, project, |file| {
1165 diagnostics.extend(
1166 comparator_contract_diagnostics_query(db, project, file)
1167 .iter()
1168 .cloned(),
1169 );
1170 });
1171 // Conventions-module confinement gate (E169, issue #1844) — per-file,
1172 // lazy (see `conventions_confinement_diagnostics_query`'s doc): a file
1173 // with no declared claim handler never even reads `module_map_query`.
1174 for_each_source_file(db, project, |file| {
1175 diagnostics.extend(
1176 conventions_confinement_diagnostics_query(db, project, file)
1177 .iter()
1178 .cloned(),
1179 );
1180 });
1181 // #2179 the `@[convention]` no-world-reads fence (`E182`,
1182 // docs/decision-log.md 2026-08-06) — reuses this aggregator's own
1183 // `hir_refs`/`resolved`/`symbol_meta` (the same whole-project inputs
1184 // `brink_analyzer::whole_project_diagnostics`' monolithic composition
1185 // hands its own copy of this check, keeping `query_equivalence.rs`
1186 // honest). Lazy inside `no_world_reads::check` itself, same shape as
1187 // `conventions_confinement_diagnostics_query` just above: a file with
1188 // no declared claim handler is skipped immediately.
1189 for file in project.files(db) {
1190 let hir = &lowered_query(db, project, *file).hir;
1191 diagnostics.extend(brink_analyzer::no_world_reads_diagnostics(
1192 file.file_id(db),
1193 hir,
1194 &hir_refs,
1195 &resolved.index,
1196 &resolved.resolutions,
1197 &symbol_meta,
1198 ));
1199 }
1200 // B3a UFCS resolution (issue #1482, D1–D5 RULED 2026-07-26) — last,
1201 // matching `brink_analyzer::whole_project_diagnostics`' own composition
1202 // order. The verdict table itself (issue #1506) is [`ufcs_resolution_
1203 // query`]'s own memo, shared with LIR lowering — this just takes the
1204 // diagnostics half.
1205 diagnostics.extend(
1206 ufcs_resolution_query(db, project)
1207 .diagnostics
1208 .iter()
1209 .cloned(),
1210 );
1211
1212 WholeProjectDiagnostics {
1213 diagnostics,
1214 symbol_meta,
1215 }
1216}
1217
1218/// B3a UFCS resolution (issue #1482/#1506): the project's verdict table,
1219/// translated to `brink-ir`'s own lowering-facing mirror type
1220/// (`brink_ir::lir::UfcsLookup`), plus the diagnostics the analyzer's `ufcs`
1221/// pass produced alongside it.
1222#[derive(Debug, Clone, PartialEq)]
1223pub(crate) struct UfcsResolution {
1224 pub table: brink_ir::lir::UfcsLookup,
1225 pub diagnostics: Vec<Diagnostic>,
1226}
1227
1228/// Compute [`UfcsResolution`], translating the analyzer's verdict table to
1229/// `brink-ir`'s own lowering-facing mirror type at this one seam — see that
1230/// type's doc for why `brink-ir` can't name `brink_analyzer::UfcsVerdict`
1231/// directly (it sits below `brink-analyzer` in the crate graph).
1232///
1233/// Memoized once per project and read by four call sites —
1234/// [`whole_project_diagnostics_query`] (the diagnostics half), (issue #1506)
1235/// `lir_knot_chunk_query`'s per-knot LIR lowering plus `lir_lowering_query`'s
1236/// own root-content step, and (issue #1507) `ProjectDb::ufcs_verdict`, which
1237/// `brink-ide`'s hover/go-to-def wiring reads through — so all four see the
1238/// same table rather than each re-running whole-project inference.
1239///
1240/// Lazy on the same argument [`whole_project_diagnostics_query`]'s old
1241/// inline check used: a project with no dotted-callee call anywhere never
1242/// triggers inference here (every ink project is in that set by
1243/// construction — ink's own lowering cannot produce a multi-segment callee
1244/// path; see `brink-analyzer`'s `ufcs` module doc), and builds (and stays
1245/// pointer-stable at) the empty table.
1246#[salsa::tracked(returns(ref))]
1247pub(crate) fn ufcs_resolution_query(
1248 db: &dyn salsa::Database,
1249 project: ProjectInput,
1250) -> UfcsResolution {
1251 let resolved = resolutions_index_query(db, project);
1252 let hir_refs: Vec<(FileId, &HirFile)> = project
1253 .files(db)
1254 .iter()
1255 .filter(|f| is_source_file(f.path(db)))
1256 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1257 .collect();
1258
1259 if !hir_refs
1260 .iter()
1261 .any(|&(_, hir)| brink_analyzer::project_has_ufcs_call(hir))
1262 {
1263 return UfcsResolution {
1264 table: brink_ir::lir::UfcsLookup::new(),
1265 diagnostics: Vec::new(),
1266 };
1267 }
1268
1269 // Reuses the FG-narrowed, per-SCC-memoized `type_inference_query`
1270 // rather than letting the analyzer recompute inference from scratch —
1271 // the same seam `whole_project_diagnostics_query`'s strict block above
1272 // reuses.
1273 let inference = type_inference_query(db, project);
1274 let (table, diagnostics) = brink_analyzer::ufcs_resolution(
1275 &hir_refs,
1276 &resolved.index,
1277 &resolved.resolutions,
1278 inference.as_ref(),
1279 );
1280
1281 UfcsResolution {
1282 // The one shared translation point (issue #1506) — see
1283 // `brink_analyzer::ufcs_lir_lookup`'s own doc.
1284 table: brink_analyzer::ufcs_lir_lookup(&table),
1285 diagnostics,
1286 }
1287}
1288
1289/// B1 `or`-coalescing typing (issue #1492/#1471): the project's recorded
1290/// per-step chain shapes, translated to `brink-ir`'s own lowering-facing
1291/// mirror type (`brink_ir::lir::CoalesceLookup`).
1292///
1293/// Only the **table** half of `brink_analyzer::coalesce_types` is kept: its
1294/// `E066` diagnostics are strict-mode-only and already reach
1295/// [`whole_project_diagnostics_query`] through `strict::check`'s own wiring
1296/// (see `brink_analyzer::coalesce_types`' doc — surfacing them from here
1297/// too would emit strict-only diagnostics under `types = gradual`, and
1298/// duplicate them under strict).
1299///
1300/// Deliberately **not** gated on the `types` policy: the recorded shapes are
1301/// a typing *record*, not a strict-mode check. Native's un-overridden
1302/// default is gradual (`brink-analyzer::strict::native_strict_only_error`'s
1303/// own doc), and a gradual chain whose operands *are* statically pinned
1304/// still deserves the right code shape; only genuinely unpinned steps come
1305/// back as `CoalesceShape::RuntimeCheck`.
1306///
1307/// Memoized once per project and read by the two LIR-lowering call sites
1308/// (`lir_knot_chunk_query`, `lir_lowering_query`'s root-content step), so
1309/// both see the same table. Lazy the same way [`ufcs_resolution_query`] is:
1310/// a project with no `or`-coalescing anywhere (every ink-dialect project,
1311/// by construction — `InfixOp::Coalesce` is native-lowering-only) never
1312/// triggers whole-project inference here and stays pointer-stable at the
1313/// empty table.
1314#[salsa::tracked(returns(ref))]
1315pub(crate) fn coalesce_types_query(
1316 db: &dyn salsa::Database,
1317 project: ProjectInput,
1318) -> brink_ir::lir::CoalesceLookup {
1319 let resolved = resolutions_index_query(db, project);
1320 let hir_refs: Vec<(FileId, &HirFile)> = project
1321 .files(db)
1322 .iter()
1323 .filter(|f| is_source_file(f.path(db)))
1324 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1325 .collect();
1326
1327 if !hir_refs
1328 .iter()
1329 .any(|&(_, hir)| brink_analyzer::project_has_coalesce(hir))
1330 {
1331 return brink_ir::lir::CoalesceLookup::new();
1332 }
1333
1334 // Reuses the FG-narrowed, per-SCC-memoized `type_inference_query`
1335 // rather than letting the analyzer recompute inference from scratch —
1336 // the same seam `ufcs_resolution_query` above reuses.
1337 let inference = type_inference_query(db, project);
1338 let (table, _strict_only_diagnostics) = brink_analyzer::coalesce_types(
1339 &hir_refs,
1340 &resolved.index,
1341 inference.as_ref(),
1342 &resolved.resolutions,
1343 );
1344 // The one shared translation point (issue #1471) — see
1345 // `brink_analyzer::coalesce_lir_lookup`'s own doc.
1346 brink_analyzer::coalesce_lir_lookup(&table)
1347}
1348
1349/// All analysis diagnostics, assembled in the exact order
1350/// [`brink_analyzer::finish_analysis`] would produce them (issue #632 /
1351/// FG-3): symbol-index diagnostics, every file's own `resolve_query`
1352/// diagnostics, the per-file contributors
1353/// ([`contributor_diagnostics_query`]), then the whole-project contributors
1354/// ([`whole_project_diagnostics_query`]). [`diagnostics_query`] filters this
1355/// by file; [`lir_query`] reads it directly for its error gate — neither
1356/// goes through the bundled [`analysis_query`] anymore.
1357#[salsa::tracked(returns(ref))]
1358pub(crate) fn analysis_diagnostics_query(
1359 db: &dyn salsa::Database,
1360 project: ProjectInput,
1361) -> Vec<Diagnostic> {
1362 let (_index, mut diagnostics) = symbol_index_query(db, project).clone();
1363 for file in project.files(db) {
1364 // Issue #2329: a non-source document's `resolve_query` diagnostics
1365 // never join the project-wide diagnostic stream.
1366 if !is_source_file(file.path(db)) {
1367 continue;
1368 }
1369 let (_file_map, file_diags) = resolve_query(db, project, *file);
1370 diagnostics.extend(file_diags.iter().cloned());
1371 }
1372 diagnostics.extend(contributor_diagnostics_query(db, project).iter().cloned());
1373 diagnostics.extend(
1374 whole_project_diagnostics_query(db, project)
1375 .diagnostics
1376 .iter()
1377 .cloned(),
1378 );
1379 diagnostics
1380}
1381
1382/// Full cross-file analysis (issue #632 / FG-3: now a thin assembler over
1383/// [`resolutions_index_query`] + [`analysis_diagnostics_query`] +
1384/// [`whole_project_diagnostics_query`] rather than calling
1385/// [`brink_analyzer::finish_analysis`] directly) — `db.analysis()`'s public
1386/// shape, kept for LSP/IDE/CLI consumers that want the whole bundled
1387/// result. Output-identical to the pre-FG-3 query and to the monolithic,
1388/// module-aware `analyze_with_modules` path (pinned by
1389/// `query_equivalence.rs`) — only equal to the module-*blind*
1390/// `analyze_with_options` for ink projects without a declared `#@module`,
1391/// see `ProjectDb::module_map`'s doc (issue #1526); the decomposition
1392/// changes *dependency edges*, not values. Narrower consumers
1393/// ([`diagnostics_query`], [`lir_query`]) read the three sub-queries
1394/// directly instead of through this bundle.
1395#[salsa::tracked(returns(ref))]
1396pub(crate) fn analysis_query(db: &dyn salsa::Database, project: ProjectInput) -> AnalysisResult {
1397 let resolved = resolutions_index_query(db, project);
1398 let diagnostics = analysis_diagnostics_query(db, project).clone();
1399 let whole = whole_project_diagnostics_query(db, project);
1400 AnalysisResult {
1401 index: Arc::clone(&resolved.index),
1402 resolutions: resolved.resolutions.clone(),
1403 diagnostics,
1404 symbol_meta: whole.symbol_meta.clone(),
1405 }
1406}
1407
1408/// Per-file diagnostics (spec §4 layer 3): this file's lowering + syntax
1409/// diagnostics plus its share of the cross-file analysis diagnostics. Raw —
1410/// suppression filtering stays a consumer concern (see
1411/// [`partition_diagnostics`]). Reads [`analysis_diagnostics_query`] directly
1412/// (issue #632 / FG-3) rather than through the bundled [`analysis_query`],
1413/// so a resolutions-only change (no diagnostic anywhere differs) leaves this
1414/// memo's dependency fully validated.
1415///
1416/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
1417///
1418/// Gated on [`is_source_file`] (issue #2329 review finding): this is a
1419/// direct per-file entry point (`ProjectDb::diagnostics`), reachable without
1420/// going through [`analysis_diagnostics_query`]'s own gate on this same
1421/// file's contribution, so a non-source document must be excluded here too
1422/// or its bogus ink-lowered `lowered_query` diagnostics still surface.
1423#[salsa::tracked(returns(ref), lru = 4096)]
1424pub(crate) fn diagnostics_query(
1425 db: &dyn salsa::Database,
1426 project: ProjectInput,
1427 file: SourceFile,
1428) -> Vec<Diagnostic> {
1429 if !is_source_file(file.path(db)) {
1430 return Vec::new();
1431 }
1432 let file_id = file.file_id(db);
1433 let mut out = lowered_query(db, project, file).diagnostics.clone();
1434 out.extend(
1435 analysis_diagnostics_query(db, project)
1436 .iter()
1437 .filter(|d| d.file == file_id)
1438 .cloned(),
1439 );
1440 out
1441}
1442
1443/// Whether the project has at least one Error-severity diagnostic after
1444/// suppression filtering and [`brink_analyzer::effective_severity`]
1445/// partitioning (issue #791 / FG-4a — PR #753's seam finding #3: "`lir_query`
1446/// still reads `analysis_diagnostics_query` wholesale for its error gate;
1447/// FG-4's per-container chunks will want a 'has any error' boolean
1448/// projection so chunk memos don't ride the full diagnostic vector's Eq").
1449///
1450/// Computes the exact same `errors.is_empty()` verdict [`super::lir_query`]'s
1451/// gate used to compute inline, from the exact same inputs
1452/// ([`analysis_diagnostics_query`] plus every file's lowering diagnostics,
1453/// suppressions, and the entry file's `disable_all` flag) via the same
1454/// shared [`partition_diagnostics`] — so this is a pure re-expression of the
1455/// gate as its own query, not a new rule. `bool`'s `PartialEq` is the
1456/// cheapest possible cutoff: a diagnostics edit that changes *content* (a
1457/// message, an added warning) without flipping whether any error exists
1458/// backdates this memo, so any dependent that reads only this boolean (not
1459/// the full `Vec<Diagnostic>`) stays fully validated across that edit — see
1460/// `fg4a_dependency_edges.rs`.
1461///
1462/// [`partition_diagnostics`]: super::partition_diagnostics
1463#[salsa::tracked]
1464pub(crate) fn has_errors_query(db: &dyn salsa::Database, project: ProjectInput) -> bool {
1465 let files = project.files(db);
1466 let Some(entry) = project.entry(db) else {
1467 return false;
1468 };
1469 let disable_all = files
1470 .iter()
1471 .find(|f| f.file_id(db) == entry)
1472 .is_some_and(|f| super::suppressions_query(db, *f).disable_all);
1473 // `is_source_file` (issue #2329): a non-source document's lowering
1474 // (bogus ink-parse) diagnostics never contribute to the error gate —
1475 // `lir_query`'s identical `inputs` construction mirrors this exactly, so
1476 // the two stay in lockstep (see this function's own doc).
1477 let inputs: Vec<super::FileDiagnostics<'_>> = files
1478 .iter()
1479 .filter(|f| is_source_file(f.path(db)))
1480 .map(|f| super::FileDiagnostics {
1481 file: f.file_id(db),
1482 source: f.text(db),
1483 suppressions: super::suppressions_query(db, *f),
1484 lowering: &lowered_query(db, project, *f).diagnostics,
1485 })
1486 .collect();
1487 let opts = project.analysis_options(db);
1488 let types = opts.type_policy();
1489 let diagnostics = analysis_diagnostics_query(db, project);
1490 let (errors, _warnings) =
1491 super::partition_diagnostics(&inputs, diagnostics, disable_all, types, &opts.lints);
1492 !errors.is_empty()
1493}
1494
1495/// The same [`partition_diagnostics`] "does at least one Error-severity
1496/// diagnostic exist" verdict as [`has_errors_query`], but scoped to the
1497/// project's **codegen closure** ([`super::compilation_closure_files`]) rather
1498/// than every file loaded into the project db — the same reachability
1499/// machinery `struct_shape_data_query`/`lir_prelude_decls_query`/
1500/// `lir_lowering_query` in `queries/mod.rs` already use. For an ink project
1501/// that closure is `entry`'s transitive `INCLUDE` closure (issue #815's
1502/// established narrowing); for a **native** project it is every discovered
1503/// `.brink` module (issue #1296), so a broken **unreferenced** sibling module
1504/// still fails this gate — the whole native module tree is the compilation
1505/// unit (Rust parity).
1506///
1507/// [`has_errors_query`] itself is untouched and stays whole-project: it feeds
1508/// `db.has_errors()`/`db.lir_product()`, IDE-surface reads FG-4a's
1509/// dependency-edge tests pin on purpose (issue #791) — a broken file
1510/// genuinely unrelated to any particular entry must still show up as a
1511/// project-wide error signal there. This narrower query is the *additional*
1512/// gate the #1032 collapse ruling adds for `compileProject`'s artifact path
1513/// ([`super::lir_in_closure_query`] / `db.story_data()`): once the editor's
1514/// session db and analysis db became the same db, a WIP scratch file or a
1515/// second, `INCLUDE`-unrelated story sharing that db could flip
1516/// `compileProject(entry)` from `ok:true` to `ok:false` even though codegen
1517/// only ever lowered `entry`'s own closure (#815) — a false-negative gate,
1518/// not corrupt output. Scoping the gate to match what codegen actually reads
1519/// closes that gap: an unrelated file's error still surfaces through
1520/// `diagnostics_query`/`db.diagnostics(file)` (both still whole-project,
1521/// unchanged), it just no longer blocks a different entry's build.
1522#[salsa::tracked]
1523pub(crate) fn has_errors_in_closure_query(db: &dyn salsa::Database, project: ProjectInput) -> bool {
1524 let Some(entry) = project.entry(db) else {
1525 return false;
1526 };
1527 let files = project.files(db);
1528 let closure: LookupSet<FileId> = super::compilation_closure_files(db, project)
1529 .into_iter()
1530 .collect();
1531 let disable_all = files
1532 .iter()
1533 .find(|f| f.file_id(db) == entry)
1534 .is_some_and(|f| super::suppressions_query(db, *f).disable_all);
1535 let inputs: Vec<super::FileDiagnostics<'_>> = files
1536 .iter()
1537 .filter(|f| closure.contains(&f.file_id(db)))
1538 .map(|f| super::FileDiagnostics {
1539 file: f.file_id(db),
1540 source: f.text(db),
1541 suppressions: super::suppressions_query(db, *f),
1542 lowering: &lowered_query(db, project, *f).diagnostics,
1543 })
1544 .collect();
1545 let opts = project.analysis_options(db);
1546 let types = opts.type_policy();
1547 let diagnostics: Vec<Diagnostic> = analysis_diagnostics_query(db, project)
1548 .iter()
1549 .filter(|d| closure.contains(&d.file))
1550 .cloned()
1551 .collect();
1552 let (errors, _warnings) =
1553 super::partition_diagnostics(&inputs, &diagnostics, disable_all, types, &opts.lints);
1554 !errors.is_empty()
1555}
1556
1557// ── Subset analysis (option A total, ruled 2026-08-24) ───────────────
1558//
1559// `brink-lsp` analyzes per PROJECT ROOT, and one db can hold several roots
1560// (`ProjectDb::compute_projects` — each include-graph component of the ink
1561// files, plus at most one native project). Subset-ness is load-bearing:
1562// two unrelated stories in one workspace must not cross-contaminate as
1563// duplicate-knot errors. This query is the retired
1564// `brink_analyzer::analyze_with_modules` monolith's composition RELOCATED
1565// into a member-set-keyed salsa query — same piece functions
1566// (`symbol_index_with_modules` → per-file `ImportScope`/`resolve` →
1567// `conventions_confinement_diagnostics` → `finish_analysis`), same
1568// per-subset granularity the LSP always had, now memoized by set (a
1569// no-change background pass costs a validation, not a re-analysis) and fed
1570// by the per-file `lowered_query` memos instead of cloned-out inputs.
1571//
1572// The FULL-set case deliberately does NOT route here: the whole-project
1573// chain (`analysis_query` and its FG-decomposed constituents) keeps its
1574// epic-tuned incremental edges. This query exists for proper subsets only.
1575//
1576// `strict_inference` is passed `None`: the memoized `type_inference_query`
1577// is whole-project, and reusing it for a subset would bleed cross-root
1578// inference — `strict_diagnostics`' self-contained fallback runs inference
1579// over the subset itself, matching the retired monolith's per-root
1580// behavior exactly.
1581
1582/// A canonical (sorted, deduped) member set — the subset-analysis key.
1583#[salsa::interned]
1584pub(crate) struct MemberSet<'db> {
1585 #[returns(ref)]
1586 pub members: Vec<FileId>,
1587}
1588
1589/// Whether every recognized source file in `members` is native — the
1590/// member-set view of [`super::project_is_all_native`] (#1358 semantics,
1591/// #2318 non-source carve-out).
1592fn members_all_native(db: &dyn salsa::Database, project: ProjectInput, members: &[FileId]) -> bool {
1593 let mut any = false;
1594 for file in project.files(db) {
1595 let id = file.file_id(db);
1596 if members.binary_search(&id).is_err() {
1597 continue;
1598 }
1599 let path = file.path(db);
1600 if !is_source_file(path) {
1601 continue;
1602 }
1603 match super::file_language(path) {
1604 super::Language::Native => any = true,
1605 super::Language::Ink => return false,
1606 }
1607 }
1608 any
1609}
1610
1611#[salsa::tracked(returns(ref))]
1612pub(crate) fn subset_analysis_query<'db>(
1613 db: &'db dyn salsa::Database,
1614 project: ProjectInput,
1615 set: MemberSet<'db>,
1616) -> AnalysisResult {
1617 let members = set.members(db);
1618 let opts = project.analysis_options(db);
1619 let (module_map, module_diags) = module_map_query(db, project);
1620 let is_native = members_all_native(db, project, members);
1621
1622 // Member inputs in db file order (deterministic — the same order the
1623 // retired `analysis_inputs_for` road produced), source files only.
1624 let lowered: Vec<(FileId, &Arc<super::LoweredFile>)> = project
1625 .files(db)
1626 .iter()
1627 .filter(|f| is_source_file(f.path(db)))
1628 .filter(|f| members.binary_search(&f.file_id(db)).is_ok())
1629 .map(|f| (f.file_id(db), lowered_query(db, project, *f)))
1630 .collect();
1631 let files: Vec<(FileId, &brink_ir::HirFile, &brink_ir::SymbolManifest)> = lowered
1632 .iter()
1633 .map(|(id, l)| (*id, &l.hir, &l.manifest))
1634 .collect();
1635
1636 let manifest_inputs: Vec<(FileId, &brink_ir::SymbolManifest)> =
1637 files.iter().map(|&(id, _hir, m)| (id, m)).collect();
1638 let (index, mut diagnostics) = brink_analyzer::symbol_index_with_modules(
1639 &manifest_inputs,
1640 module_map,
1641 opts.dialect,
1642 is_native,
1643 );
1644
1645 // The map's db-only diagnostics half, member-filtered — what the LSP's
1646 // retired `fold_module_diagnostics` and the editor's retired
1647 // `IdeSnapshot::analyze` each folded by hand (#1553).
1648 diagnostics.extend(
1649 module_diags
1650 .iter()
1651 .filter(|d| members.binary_search(&d.file).is_ok())
1652 .cloned(),
1653 );
1654
1655 let mut resolutions = brink_ir::ResolutionMap::new();
1656 let mut scopes: BTreeMap<FileId, brink_analyzer::ImportScope> = BTreeMap::new();
1657 for &(file_id, hir, manifest) in &files {
1658 let declared_module = match module_map.get(&file_id) {
1659 Some(resolved) => resolved.declared.then(|| resolved.name.clone()),
1660 None => hir.module.as_ref().map(|m| m.name.clone()),
1661 };
1662 let scope = brink_analyzer::ImportScope::new(declared_module, &hir.imports);
1663 let (file_map, file_diags) = brink_analyzer::resolve(file_id, manifest, &index, &scope);
1664 resolutions.extend(Arc::unwrap_or_clone(file_map));
1665 diagnostics.extend(file_diags);
1666 scopes.insert(file_id, scope);
1667 }
1668
1669 let hir_files: Vec<(FileId, &brink_ir::HirFile)> =
1670 files.iter().map(|&(id, hir, _)| (id, hir)).collect();
1671 diagnostics.extend(brink_analyzer::conventions_confinement_diagnostics(
1672 &hir_files,
1673 module_map,
1674 opts.conventions.as_deref(),
1675 ));
1676
1677 brink_analyzer::finish_analysis(
1678 &files,
1679 index,
1680 resolutions,
1681 diagnostics,
1682 opts,
1683 is_native,
1684 None,
1685 &scopes,
1686 )
1687}