Skip to main content

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}