Skip to main content

brink_db/queries/
mod.rs

1//! Salsa inputs and tracked queries — the query-shaped compiler pipeline
2//! (scripting-substrate spec §4, phase 0 slice B).
3//!
4//! Layer 0 (inputs): [`SourceFile`] (path + text) and [`ProjectInput`] (the
5//! file set, the entry point, and the analysis options). Editor overlays are
6//! plain input writes — there is no separate overlay pathway.
7//!
8//! Layer 1 (per file): [`parse_query`], [`lowered_query`] (HIR + manifest +
9//! lowering diagnostics, exactly the composition the old `set_file` cached
10//! — now project-aware, issue #2289: it merges in the conventions module's
11//! cross-file claiming reach via [`external_claim_handlers_query`], reading
12//! the project-*independent* [`raw_lowered_query`] underneath), and its two
13//! exceptions that read `raw_lowered_query` directly ([`suppressions_query`]
14//! and [`external_claim_handlers_query`] itself — see either's own doc for
15//! why), plus the project-wide [`include_graph_query`].
16//!
17//! Layer 2 (project-wide names): [`symbol_index_query`],
18//! [`harvest_index_query`] (the project-db harvest obligation over cue
19//! payloads and markup span kinds, issue #2114 — a sibling merge over the
20//! same per-file [`lowered_query`] outputs, not a per-file query),
21//! [`harvest_completion_index_query`] (the harvest index's own early-cutoff
22//! completion projection, issue #2134 — the sibling of
23//! [`resolution_index_query`] below, dropping every site's `TextRange` down
24//! to bare name sets),
25//! [`resolution_index_query`] (the early-cutoff seam — see below),
26//! [`resolve_query`], [`signature_query`], and [`analysis_query`] — the
27//! latter now a thin assembler (issue #632 / FG-3) over
28//! [`resolutions_index_query`] (index + resolutions, no diagnostics),
29//! [`per_file_diagnostics_query`]/[`contributor_diagnostics_query`] (the
30//! per-file validate/dialect_gate/annotation-content split),
31//! [`whole_project_diagnostics_query`] (now a thin aggregator — issue #750
32//! decomposed the external-check family into [`inline_docs_query`] /
33//! [`external_meta_query`] / [`call_site_metas_query`] and the per-file
34//! [`value_meta_query`] / [`call_site_diagnostics_query`]; only the M-2
35//! modules pass and the strict typed-mode pass remain genuinely
36//! whole-project), and
37//! [`analysis_diagnostics_query`] (every diagnostic source, merged). See
38//! the "FG-3" section below for the full rationale.
39//!
40//! Layer 3 (lowering/codegen, whole-project in this slice): [`lir_query`],
41//! [`story_data_query`], and the per-file [`diagnostics_query`] — both now
42//! read the decomposed FG-3 queries directly rather than through the
43//! bundled [`analysis_query`].
44//!
45//! # The `resolution_index` cutoff seam (slice-A findings 1+2, tightened by #517)
46//!
47//! The full [`SymbolIndex`] carries a `TextRange` per symbol, so nearly any
48//! edit shifts ranges and defeats `Eq`-cutoff on the index — dependents of
49//! `symbol_index` would re-run on every keystroke. [`resolution_index_query`]
50//! sits between the index and reference resolution: it is the full index with
51//! locals (`Param`/`Temp`) dropped and ranges zeroed for every remaining
52//! (declaration) symbol.
53//!
54//! Locals were originally kept in the projection (with real ranges) because
55//! `lookup_local_in_scope`'s closest-preceding pick was the one place
56//! resolution read symbol ranges. That left a gap (finding 1): a body edit
57//! that adds/removes a `~ temp` anywhere in the project changes a local's
58//! *identity*, not just its range, so `resolution_index_query`'s own output
59//! still differed and every file's `resolve` memo still re-ran. #517 closes
60//! the gap by having `resolve_query` read the declaring file's own
61//! `manifest.locals` instead of the merged index for local lookups (a knot's
62//! body lives in exactly one file, so this was always sufficient — see
63//! `brink_analyzer::resolve::lookup_local_in_scope`), which also fixes the
64//! finding-4 cross-file duplicate-`DefinitionId` aliasing: resolution no
65//! longer merges locals from different files, so it can no longer pick the
66//! wrong file's declaration. With locals gone, dropping the rest is
67//! behavior-neutral by construction (locked by the `query_equivalence` tests
68//! and the oracle gate).
69//!
70//! # Memory bounding (FG-5, issue #647, decision log "FG-5 memory bounding")
71//!
72//! The per-file query families (`parse`, `lowered`, `suppressions`,
73//! `resolve`, `per_file_diagnostics`, `value_meta`, `call_site_diagnostics`,
74//! `diagnostics`) and the per-def families keyed by [`DefKey`]
75//! (`signature`, `def_body`,
76//! `referenced_globals`, `call_edges`, `solve_scc`, `inferred_signature`,
77//! `infer_body`) each carry a salsa `lru` capacity — a **runaway guard**,
78//! not a working-set trim. Issue #537's measurement (large synthetic
79//! projects, 2,000-edit sessions) showed every one of these families scales
80//! with live project size and shows zero session-length growth, so a tight
81//! LRU would only evict live working-set entries and buy recompute churn on
82//! big projects, never save memory. The ceilings are sized far above
83//! realistic project scale on purpose (≈30× the measured 132-file scale for
84//! per-file families, ≈10× the measured 1,549-def scale for per-def
85//! families) so they never evict in steady state, and exist only to cap the
86//! pathological/runaway case. **No eviction *policy* design happened here**
87//! — that was explicitly ruled out by the data; see the decision log entry
88//! for the full ruling. `def_body`, `solve_scc`, `signature`, `infer_body`,
89//! and `lowered` additionally specify a `heap_size` estimator
90//! ([`heap_size`], issue #538) — the families #537 flagged as the dominant
91//! Arc-hidden payloads, so `crate::memory::snapshot`'s `heap_bytes` column
92//! reads `Some(_)` for them instead of the honest-`None` every query
93//! reported before this pass.
94
95use std::collections::{BTreeMap, BTreeSet};
96use std::sync::Arc;
97
98use brink_analyzer::{
99    AnalysisOptions, CallGraph, HarvestIndex, HarvestNames, ImportScope, InferenceResult, SccGraph,
100    Sig, TypePolicy,
101};
102use brink_format::{
103    CallAtom, CapabilityParam, DefinitionId, DirectEffects, EffectRowEntry, NameId, StoryData,
104};
105use brink_ir::suppressions::{Suppressions, apply_suppressions, parse_suppressions};
106use brink_ir::symbols::project_manifest;
107use brink_ir::{
108    Diagnostic, DiagnosticCode, FileId, HirFile, ResolutionMap, Severity, SymbolIndex, SymbolKind,
109    SymbolManifest, lower, lower_single_knot, lower_top_level,
110};
111use brink_syntax::Parse;
112use brink_syntax_native::Parse as NativeParse;
113
114use crate::db::resolve_include_path;
115use crate::determinism::{LookupMap, LookupSet};
116use crate::include_graph::IncludeGraph;
117
118mod analysis;
119mod heap_size;
120
121pub use analysis::ResolvedProject;
122pub(crate) use analysis::{
123    analysis_diagnostics_query, analysis_query, await_purity_diagnostics_query,
124    call_site_diagnostics_query, call_site_metas_query, coalesce_types_query,
125    comparator_contract_diagnostics_query, contributor_diagnostics_query,
126    conventions_confinement_diagnostics_query, conventions_projection_query, diagnostics_query,
127    effects_assertion_diagnostics_query, external_claim_handlers_query, external_meta_query,
128    has_errors_in_closure_query, has_errors_query, import_closure_query, inline_docs_query,
129    per_file_diagnostics_query, resolutions_index_query, ufcs_resolution_query, value_meta_query,
130    whole_project_diagnostics_query,
131};
132
133// ─── Database ────────────────────────────────────────────────────────
134
135/// The salsa database behind [`crate::ProjectDb`].
136///
137/// Ingredients are registered explicitly (salsa's `inventory` feature is
138/// off): link-time collection via life-before-main is exactly the kind of
139/// platform magic that breaks on wasm, and the explicit list keeps the query
140/// surface reviewable. A query missing from the list panics loudly on first
141/// use — any test exercising it catches that immediately.
142#[salsa::db]
143#[derive(Clone)]
144pub(crate) struct BrinkDatabase {
145    storage: salsa::Storage<Self>,
146}
147
148#[salsa::db]
149impl salsa::Database for BrinkDatabase {}
150
151impl Default for BrinkDatabase {
152    fn default() -> Self {
153        Self {
154            storage: salsa::Storage::builder()
155                // Inputs + interned keys.
156                .ingredient::<SourceFile>()
157                .ingredient::<ProjectInput>()
158                .ingredient::<DefKey<'_>>()
159                // Layer 1.
160                .ingredient::<parse_query>()
161                // B0.10a native compile seam (issue #1106): the frontend-
162                // specific parse ingredient, dispatched by `lowered_query`.
163                .ingredient::<parse_native_query>()
164                // Issue #2289: the project-*independent* raw lowering
165                // (`raw_lowered_query`) and the project-aware canonical
166                // entry point (`lowered_query`) that merges in the
167                // conventions module's cross-file claiming reach — see
168                // either query's own doc.
169                .ingredient::<raw_lowered_query>()
170                .ingredient::<lowered_query>()
171                .ingredient::<suppressions_query>()
172                .ingredient::<include_graph_query>()
173                // Layer 2.
174                .ingredient::<module_map_query>()
175                .ingredient::<symbol_index_query>()
176                .ingredient::<harvest_index_query>()
177                // Issue #2134: the harvest index's range-free completion
178                // projection — the sibling of `resolution_index_query`
179                // below, for the same Eq-cutoff reason (see this module's
180                // doc and `harvest_completion_index_query`'s own).
181                .ingredient::<harvest_completion_index_query>()
182                .ingredient::<resolution_index_query>()
183                // Issue #2272 review finding: the shared per-file
184                // `ImportScope` derivation `resolve_query` and
185                // `per_file_diagnostics_query` both now call.
186                .ingredient::<file_import_scope_query>()
187                .ingredient::<resolve_query>()
188                .ingredient::<signature_query>()
189                // Issue #530: the per-file locals path signature_query
190                // itself can't take — see local_signature_query's doc.
191                .ingredient::<local_signature_query>()
192                // FG-3 (issue #632): analysis_query decomposed into narrow
193                // cutoff-friendly projections. resolutions_index_query
194                // (index+resolutions, no diagnostics) and
195                // analysis_diagnostics_query (every diagnostic source,
196                // assembled from per-file contributors +
197                // whole_project_diagnostics_query) are independent queries
198                // now, so a diagnostics-only edit never invalidates a
199                // resolutions-only reader and vice versa.
200                // per_file_diagnostics_query/contributor_diagnostics_query
201                // are the per-file validate/dialect_gate/annotation-content
202                // split — a body edit in file Y leaves file X's contributor
203                // memo untouched. analysis_query itself survives as a thin
204                // assembler over these for `db.analysis()`'s existing
205                // LSP/IDE/CLI-facing shape.
206                .ingredient::<resolutions_index_query>()
207                .ingredient::<per_file_diagnostics_query>()
208                .ingredient::<contributor_diagnostics_query>()
209                // FG-3 completion (issue #750): the external-check family,
210                // decomposed. inline_docs_query (project doc merge, Eq
211                // cutoff) + external_meta_query (index-driven E039/E040 +
212                // enrichment, no HIR) + call_site_metas_query (the
213                // range-free name→meta cutoff seam) feed the per-file
214                // value_meta_query / call_site_diagnostics_query, so a body
215                // edit in file Y re-runs only Y's own value-meta and
216                // call-site walks; whole_project_diagnostics_query is now a
217                // thin aggregator (plus the genuinely whole-project M-2
218                // modules pass and strict pass).
219                .ingredient::<inline_docs_query>()
220                .ingredient::<external_meta_query>()
221                .ingredient::<call_site_metas_query>()
222                .ingredient::<value_meta_query>()
223                .ingredient::<call_site_diagnostics_query>()
224                .ingredient::<whole_project_diagnostics_query>()
225                // B3a UFCS (issue #1506): the verdict table, shared by
226                // `whole_project_diagnostics_query` (diagnostics half) and
227                // LIR lowering (`lir_knot_chunk_query`/`lir_lowering_query`).
228                .ingredient::<ufcs_resolution_query>()
229                // B1 `or`-coalescing (issue #1471/#1492): the recorded
230                // per-step chain shapes LIR lowering consumes
231                // (`lir_knot_chunk_query`/`lir_lowering_query`).
232                .ingredient::<coalesce_types_query>()
233                .ingredient::<analysis_diagnostics_query>()
234                .ingredient::<analysis_query>()
235                .ingredient::<diagnostics_query>()
236                // FG-4a (issue #791): the `has_errors` boolean projection
237                // (PR #753's seam finding #3) and the LIR-lowering split it
238                // gates — see `lir_query`'s doc comment. type_policy_query
239                // (issue #806) is the matching narrow `.types` projection so
240                // an unrelated AnalysisOptions edit can't re-execute the
241                // `no_eq` lowering memo.
242                .ingredient::<has_errors_query>()
243                // Issue #1032 collapse ruling: the closure-scoped counterpart
244                // `compileProject`'s artifact path (`story_data_query`) reads
245                // instead of the whole-project `has_errors_query`/`lir_query`
246                // above — see `has_errors_in_closure_query`'s doc comment.
247                .ingredient::<has_errors_in_closure_query>()
248                .ingredient::<type_policy_query>()
249                // The `.lints` sibling projection (issue #1160) — see
250                // `lint_policy_query`'s doc comment.
251                .ingredient::<lint_policy_query>()
252                // FG-4d (issue #830): per-knot LIR chunk memos + the
253                // cutoff-friendly struct-shape projection they read;
254                // `lir_lowering_query` is now the link phase assembling them.
255                .ingredient::<struct_shape_data_query>()
256                .ingredient::<normalized_stamped_query>()
257                // FG-4e (issue #839): decl_hir_query is the per-file
258                // backdating projection lir_prelude_decls_query reads
259                // instead of raw HIR, so a knot body edit doesn't force the
260                // whole-project declaration collection to re-execute.
261                .ingredient::<decl_hir_query>()
262                .ingredient::<lir_prelude_decls_query>()
263                .ingredient::<KnotChunkKey<'_>>()
264                // Issue #460: the knot-invariant half of a chunk's lowering
265                // environment, hoisted out of the per-knot memo so it is
266                // built once per revision instead of once per knot.
267                .ingredient::<chunk_lowering_ctx_query>()
268                .ingredient::<lir_knot_chunk_query>()
269                .ingredient::<lir_lowering_query>()
270                // Layer 2/3: type inference (TM-1, advisory-only).
271                // Per-def/per-SCC decomposition (FG-2, issue #631):
272                // call_edges(def) -> call_graph() -> scc_membership() ->
273                // solve_scc(SccId) -> inferred_signature(def)/infer_body(def).
274                // Lazy per-reference globals + full dependency narrowing
275                // (FG-2.1, issue #638): inferable_defs_query/def_body_query/
276                // referenced_globals_query are the new per-def projections
277                // call_edges_query/solve_scc_query read instead of every
278                // project file's HIR.
279                .ingredient::<inference_index_query>()
280                .ingredient::<inferable_defs_query>()
281                .ingredient::<def_body_query>()
282                .ingredient::<referenced_globals_query>()
283                .ingredient::<call_edges_query>()
284                .ingredient::<call_graph_query>()
285                .ingredient::<scc_membership_query>()
286                .ingredient::<solve_scc_query>()
287                .ingredient::<inferred_signature_query>()
288                .ingredient::<external_signatures_query>()
289                .ingredient::<type_inference_query>()
290                .ingredient::<infer_body_query>()
291                .ingredient::<type_diagnostics_query>()
292                // T2-1 (issue #860): advisory effect-row inference, sited
293                // beside inferred_signature. def_effect_atoms_query is the
294                // per-def atom harvest (same body walk referenced_globals/
295                // call_edges drive); effects_scc_query lifts solve_scc's
296                // per-SCC fixpoint to the effect lattice; effects_query is the
297                // per-def view.
298                .ingredient::<def_effect_atoms_query>()
299                .ingredient::<effects_scc_query>()
300                .ingredient::<effects_query>()
301                // T2-2 (issue #861): the `#@effects(…)` assertion's
302                // per-file exceedance check, reading `effects_query` only
303                // for defs that actually carry an assertion.
304                .ingredient::<effects_assertion_diagnostics_query>()
305                // FS-2 (issue #928): the `await`-condition purity gate (E105),
306                // reading `effects_query` only for defs a condition calls.
307                .ingredient::<await_purity_diagnostics_query>()
308                // NS-A4 (issue #1110, extended to the fn-value verb trio by
309                // issue #1679): the comparator-contract gate (E119),
310                // reading `effects_query` only for defs named as inline
311                // `#fn` comparators/callbacks of `sort_by`/`sorted_by`/
312                // `map`/`filter`/`fold`.
313                .ingredient::<comparator_contract_diagnostics_query>()
314                // Conventions-module confinement gate (E169, issue #1844):
315                // the MODULE half of the §9.1 claiming-handler confinement
316                // ruling. Reads `module_map_query` only for a file that
317                // declared at least one claiming handler.
318                .ingredient::<conventions_confinement_diagnostics_query>()
319                // The cross-file claiming injection seam (issue #2289):
320                // the project's configured conventions module's declared
321                // `@[convention]` handlers, read once per project revision
322                // and merged into every other native file's lowering by
323                // `lowered_query`.
324                .ingredient::<external_claim_handlers_query>()
325                // The reusable transitive `IMPORT` closure (issue #2111
326                // finding 3): generic over any entry file, so #2167 can
327                // reuse it for E169 confinement relaxation.
328                .ingredient::<import_closure_query>()
329                // The serialized conventions projection (issue #2111, NS-T
330                // seam 1/6): the editor-facing artifact, reading the
331                // resolved conventions module's import closure to resolve
332                // `attach = StructName` schemas (finding 1).
333                .ingredient::<conventions_projection_query>()
334                // Layer 3.
335                .ingredient::<lir_query>()
336                .ingredient::<lir_in_closure_query>()
337                .ingredient::<story_data_query>()
338                .build(),
339        }
340    }
341}
342
343// ─── Layer 0: inputs ─────────────────────────────────────────────────
344
345/// One source file: identity (stable [`FileId`] + project-relative path) and
346/// its current text. The text is the only mutable input — editor overlays and
347/// disk loads both go through `set_text`.
348#[salsa::input]
349pub(crate) struct SourceFile {
350    pub file_id: FileId,
351    #[returns(ref)]
352    pub path: String,
353    #[returns(ref)]
354    pub text: String,
355}
356
357/// The project-level input: the file set (sorted by [`FileId`]), the compile
358/// entry point, the analysis options (host manifest + external-check
359/// severity), and the native source root.
360#[salsa::input]
361pub(crate) struct ProjectInput {
362    #[returns(ref)]
363    pub files: Vec<SourceFile>,
364    pub entry: Option<FileId>,
365    #[returns(ref)]
366    pub analysis_options: AnalysisOptions,
367    /// The directory native `.brink` keys are root-relative *to*, for a
368    /// consumer that registers files under some other prefix (issue #1572:
369    /// the LSP keys by absolute OS path). `None` — every compile path, where
370    /// `discover_native` already keys root-relative — means "the keys are
371    /// already root-relative", and is byte-identical to the pre-#1572 world.
372    /// Only [`crate::modules::root_relative_key`] reads it, for native module
373    /// identity ([`module_map_query`]'s native branch).
374    #[returns(ref)]
375    pub native_root: Option<String>,
376    /// The directory `.ink` keys are root-relative *to*, for
377    /// [`hir::root_content_scope_path`](brink_ir::hir::root_content_scope_path)'s
378    /// qualifier (issue #1696) — ink's sibling of `native_root` above, reusing
379    /// the same [`crate::modules::root_relative_key`] mechanism #1572 built.
380    /// Unlike native, ink's CLI discovery has no `RealFs`-scoped tree to key
381    /// root-relative "for free": `brink-driver`'s `discover` BFS registers
382    /// files under whatever spelling the caller passed `prepare_driver`
383    /// (`brink-compiler/src/driver.rs`), so `main.ink`, `./main.ink`, and an
384    /// absolute spelling of the same file used to mint different anonymous
385    /// root-content `DefinitionId`s for byte-identical source. `None` (no
386    /// caller has registered a root) is byte-identical to the pre-#1696
387    /// world — `root_relative_key` returns every path unchanged.
388    #[returns(ref)]
389    pub ink_root: Option<String>,
390}
391
392// ─── Layer 1: per-file queries ───────────────────────────────────────
393
394/// Parse one file's text into a lossless CST.
395///
396/// `lru = 4096`: a per-file runaway-guard ceiling (issue #647, decision log
397/// "FG-5 memory bounding"), not a working-set trim — see this module's doc
398/// comment's "Memory bounding" section.
399#[salsa::tracked(returns(ref), lru = 4096)]
400pub(crate) fn parse_query(db: &dyn salsa::Database, file: SourceFile) -> Parse {
401    brink_syntax::parse(file.text(db))
402}
403
404/// Parse one native `.brink` file's text into a lossless CST.
405///
406/// The frontend-specific sibling of [`parse_query`] (B0.10a, the native
407/// compile seam, issue #1106). `brink_syntax_native::Parse` is structurally
408/// identical to `brink_syntax::Parse` (`{green, errors}`, `Clone + Eq`) but a
409/// distinct nominal type, so it needs its own tracked ingredient — matching
410/// attrs (`returns(ref)`, `lru = 4096`, the same per-file runaway-guard
411/// ceiling, issue #647). Only ever executed for files [`file_language`]
412/// classifies as [`Language::Native`], so an `.ink` file never runs the native
413/// parser and vice-versa — see [`lowered_query`].
414#[salsa::tracked(returns(ref), lru = 4096)]
415pub(crate) fn parse_native_query(db: &dyn salsa::Database, file: SourceFile) -> NativeParse {
416    brink_syntax_native::parse(file.text(db))
417}
418
419/// Per-file lowering output: assembled HIR, symbol manifest, and lowering +
420/// syntax diagnostics — the exact product the retired `FileState` cached.
421#[derive(Debug, Clone, PartialEq)]
422pub(crate) struct LoweredFile {
423    pub hir: HirFile,
424    pub manifest: SymbolManifest,
425    pub diagnostics: Vec<Diagnostic>,
426    /// B0.3 `validate_admission` output (docs/hir-admission-contract.md
427    /// §4.2, issue #1172), plus — for a native `.brink` file only — B0.9's
428    /// `validate_native_accept_list` output appended after it (issue
429    /// #1179) — kept deliberately separate from `diagnostics`: both
430    /// admission gates are non-suppressible (NF-6, always-on), so neither
431    /// must ever flow through `apply_suppressions` the way lowering/syntax
432    /// diagnostics do (`partition_diagnostics` below). Computed here so it
433    /// runs on every lowering, matching the "the validator runs on every
434    /// keystroke in the editor" perf posture — see `heap_size.rs`'s
435    /// `lowered_file_heap_size` for the matching estimator update.
436    pub admission: Vec<Diagnostic>,
437}
438
439/// Lower one file to HIR, ignoring project identity entirely. Salsa's
440/// dependency tracking on the `parse` input replaces the retired per-knot
441/// green-node/byte-offset cache (`knot_cache`): the composition below is
442/// byte-identical to what `set_file` produced.
443///
444/// The project-*independent* half of lowering — deliberately kept that way
445/// (no `ProjectInput` parameter) so an edit to one file can never, through
446/// this query alone, invalidate another file's memo. [`lowered_query`] is
447/// the project-aware entry point every other consumer should read instead
448/// (issue #2289's cross-file claiming reach needs project identity to know
449/// which file the conventions module is); this raw query now has exactly
450/// two callers: [`suppressions_query`] (whose `allow_scopes` are a pure
451/// CST scan, unaffected by which handlers a line ends up claimed by — see
452/// `brink_ir::hir::lower_native::annotation::allow_scopes`) and
453/// [`external_claim_handlers_query`] (reading the conventions module's OWN
454/// declared handlers, which are likewise invariant to whatever `external`
455/// set that file itself was lowered with — [`Elements::handler_decls`]
456/// only ever reads local declarations, never an injected one — see that
457/// method's own doc in `brink-ir`). Reading the raw query here, rather than
458/// the project-aware one, is what breaks the circular
459/// dependency [`lowered_query`] → [`external_claim_handlers_query`] would
460/// otherwise close on itself when lowering the conventions module's own
461/// file.
462///
463/// `lru = 4096`: per-file runaway-guard ceiling (issue #647). `heap_size`:
464/// one of the five #538/#647 estimators — #537 flagged this family's
465/// per-def analogues (`def_body`/`solve_scc`) as the dominant Arc-hidden
466/// payload; this is the per-file sibling.
467///
468/// `Arc`-wrapped (issue #2289 review finding): every OTHER native file in a
469/// project with no conventions module configured — the overwhelmingly
470/// common case, and every ink file in every project — takes
471/// [`lowered_query`]'s pass-through arm, which used to deep-clone this
472/// query's plain `LoweredFile` (the whole HIR/manifest/diagnostics payload)
473/// into a second salsa memo for zero semantic reason. `Arc::clone` on that
474/// same pass-through arm is a refcount bump instead — this needs no change
475/// at any of this module's many `&lowered_query(..).hir`-shaped call sites
476/// (deref coercion carries a `&Arc<LoweredFile>` through to `.hir` exactly
477/// like a `&LoweredFile` did), only here and at `lowered_query`'s own body.
478#[salsa::tracked(returns(ref), lru = 4096, heap_size = heap_size::lowered_file_heap_size)]
479pub(crate) fn raw_lowered_query(db: &dyn salsa::Database, file: SourceFile) -> Arc<LoweredFile> {
480    let file_id = file.file_id(db);
481    // Decide the frontend from the path *before* touching either parser
482    // (B0.10a, the native compile seam, issue #1106): this branch precedes
483    // the parse call, so an `.ink` file never runs the native parser and a
484    // native file never runs the ink one. The ink arm is byte-identical to
485    // the pre-seam body, keeping the oracle invariance a tautology.
486    Arc::new(match file_language(file.path(db)) {
487        Language::Ink => lower_file(file_id, parse_query(db, file)),
488        Language::Native => lower_native_file(file_id, parse_native_query(db, file), None),
489    })
490}
491
492/// Lower one file to HIR — the canonical, project-aware entry point every
493/// consumer besides [`suppressions_query`]/[`external_claim_handlers_query`]
494/// should read (see [`raw_lowered_query`]'s own doc for why those two are
495/// the exception).
496///
497/// For an ink file, or a native file that either IS the project's
498/// configured conventions module or has none configured, this is
499/// byte-identical to [`raw_lowered_query`] (a clone of its cached
500/// `Arc<LoweredFile>` — a refcount bump, no extra lowering work and,
501/// since the #2289 review finding below, no struct copy either). For every
502/// OTHER native file in a project with a conventions module configured,
503/// this re-lowers with [`external_claim_handlers_query`]'s ordered handler
504/// set merged in, so a `claims`-declared handler in that one module claims
505/// prose across the WHOLE project (issue #2289, correcting the file-local
506/// claiming defect the 2026-08-05 ruling names — see `brink_ir::hir::
507/// lower_native::element`'s module doc, "Cross-file claiming reach").
508///
509/// `Arc`-wrapped (issue #2289 review finding): before this, the pass-through
510/// arms below (every ink file, and every native file with no injection to
511/// do — the overwhelming majority of files in the overwhelming majority of
512/// projects) deep-cloned [`raw_lowered_query`]'s entire `LoweredFile`
513/// (HIR + manifest + diagnostics + admission) into a second salsa memo for
514/// zero semantic benefit, and both queries' `heap_size = heap_size::
515/// lowered_file_heap_size` estimator (issues #538/#647) double-counted that
516/// duplicated payload's residency. `Arc<LoweredFile>::clone` on those same
517/// arms is a refcount bump instead — see [`raw_lowered_query`]'s own doc.
518///
519/// `lru = 4096`/`heap_size`: same per-file runaway-guard/estimator posture
520/// as [`raw_lowered_query`] — see that query's own doc.
521#[salsa::tracked(returns(ref), lru = 4096, heap_size = heap_size::lowered_file_heap_size)]
522pub(crate) fn lowered_query(
523    db: &dyn salsa::Database,
524    project: ProjectInput,
525    file: SourceFile,
526) -> Arc<LoweredFile> {
527    let file_id = file.file_id(db);
528    if file_language(file.path(db)) != Language::Native {
529        return Arc::clone(raw_lowered_query(db, file));
530    }
531    let external = external_claim_handlers_query(db, project);
532    match &**external {
533        Some((conventions_file_id, decls)) if *conventions_file_id != file_id => {
534            Arc::new(lower_native_file(
535                file_id,
536                parse_native_query(db, file),
537                Some(decls.as_slice()),
538            ))
539        }
540        // No conventions module configured, or this file IS that module —
541        // either way, nothing to inject; byte-identical to the raw lowering.
542        _ => Arc::clone(raw_lowered_query(db, file)),
543    }
544}
545
546/// Which frontend a source file feeds — decided purely from its path (B0.10a,
547/// issue #1106). Deliberately *not* stored on any input, HIR, or
548/// `AnalysisOptions`: the "no dialect tag near HIR" posture keeps this an
549/// internal, ephemeral classification used only as [`file_language`]'s return.
550/// It is a different axis from `brink_analyzer::Dialect` (an ink-extension
551/// gate) — do not conflate the two.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub(crate) enum Language {
554    Ink,
555    Native,
556}
557
558/// Classify a source file's frontend from its path. A pure, deterministic
559/// extension test (`.brink` → native, everything else → ink) — no schema
560/// change, no `HashMap` iteration. Uses `Path::extension` to match the
561/// codebase's existing extension convention (e.g. `brink-lsp`'s `ext ==
562/// "ink"`).
563///
564/// Compared case-insensitively (issue #2329, the case-handling bug flagged
565/// on #2327's review): a real ink file spelled `story.INK` is reachable on
566/// a case-insensitive filesystem (macOS/Windows default) and must classify
567/// identically to `story.ink`, not fall through to the "everything else is
568/// ink" branch by accident of extension casing — which happens to give the
569/// same answer for `Language::Ink` but would have silently misclassified a
570/// `.BRINK` file as ink.
571pub(crate) fn file_language(path: &str) -> Language {
572    if std::path::Path::new(path)
573        .extension()
574        .is_some_and(|ext| ext.eq_ignore_ascii_case("brink"))
575    {
576        Language::Native
577    } else {
578        Language::Ink
579    }
580}
581
582/// Parsed suppression/expectation directives for one file — both channels
583/// merged into the one value every [`apply_suppressions`] call site reads.
584///
585/// The `//brink-disable`/`//brink-expect` comment channel is a pure text
586/// scan ([`parse_suppressions`]). The `@[allow(Exxx, …)]` annotation channel
587/// (issue #1161) rides the real `@[…]` grammar, so its declaration-scoped
588/// records are produced by lowering and picked up here off
589/// [`brink_ir::HirFile::allow_scopes`] — always empty for an ink file, whose
590/// annotation channel has no `allow` tenant. Reads [`raw_lowered_query`],
591/// not the project-aware [`lowered_query`] (issue #2289): `allow_scopes`
592/// is a pure CST scan (`brink_ir::hir::lower_native::annotation::
593/// allow_scopes`), unaffected by which handlers a claiming rewrite ends up
594/// using, so the project-independent lowering is exactly as correct here
595/// and avoids this query depending on project identity at all.
596///
597/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
598#[salsa::tracked(returns(ref), lru = 4096)]
599pub(crate) fn suppressions_query(db: &dyn salsa::Database, file: SourceFile) -> Suppressions {
600    let mut out = parse_suppressions(file.text(db));
601    out.allow_scopes
602        .clone_from(&raw_lowered_query(db, file).hir.allow_scopes);
603    out
604}
605
606/// The `INCLUDE` graph over the whole project. Always complete — edges are
607/// derived from every file's HIR against the full path set, so the old
608/// "rebuild after batch load" step no longer exists.
609///
610/// Reads [`raw_lowered_query`], not the project-aware [`lowered_query`]
611/// (issue #2289): `hir.includes` is an ink-only structural directive list,
612/// unaffected by claiming injection — and reading the project-aware query
613/// here would close a cycle, since [`external_claim_handlers_query`]
614/// reads [`module_map_query`], which reads this query.
615#[salsa::tracked(returns(ref))]
616pub(crate) fn include_graph_query(db: &dyn salsa::Database, project: ProjectInput) -> IncludeGraph {
617    let files = project.files(db);
618    let path_to_id: LookupMap<&str, FileId> = files
619        .iter()
620        .map(|f| (f.path(db).as_str(), f.file_id(db)))
621        .collect();
622
623    let mut graph = IncludeGraph::new();
624    for file in files {
625        // Issue #2329: a non-source document (`brink.toml`, `.md`, `.json`,
626        // `.ink.json`) never lowers through the ink frontend here — `path_to_id`
627        // above still lists it as a resolution *target* (an ink file could, in
628        // principle, name it in an `INCLUDE`, which is a different diagnostic's
629        // problem), but it never contributes edges of its own.
630        if !is_source_file(file.path(db)) {
631            continue;
632        }
633        let hir = &raw_lowered_query(db, *file).hir;
634        let include_ids: Vec<FileId> = hir
635            .includes
636            .iter()
637            .filter_map(|inc| {
638                let resolved = resolve_include_path(file.path(db), &inc.file_path);
639                path_to_id.get(resolved.as_str()).copied()
640            })
641            .collect();
642        graph.update(file.file_id(db), include_ids);
643    }
644    graph
645}
646
647/// The ordered set of files that participate in codegen for `project`, in
648/// compile (paste-before) order — the single native-vs-ink codegen-closure
649/// decision, shared by every codegen-scoping site
650/// ([`struct_shape_data_query`], [`lir_prelude_decls_query`],
651/// [`lir_lowering_query`], [`lir_in_closure_query`], and
652/// [`has_errors_in_closure_query`](crate::queries::analysis::has_errors_in_closure_query)).
653///
654/// **Ink** projects thread reachability through `INCLUDE`: the closure is
655/// `entry`'s transitive `INCLUDE` closure ([`IncludeGraph::topological_order`],
656/// the issue #815 narrowing every codegen path already used). This is exactly
657/// the previous behavior — a project whose entry is an `.ink` file is
658/// unaffected.
659///
660/// **Native** projects have no `INCLUDE` edges, so the ink closure would reach
661/// only `entry` and every sibling `.brink` module would silently miss codegen
662/// (issue #1296). The decision-log ruling *"Native multi-file linking"*
663/// (2026-07-23) makes the **discovered module set the compilation unit**: the
664/// closure is *every* discovered `.brink` module, ordered by `FileId` — which
665/// `brink_driver::discover_native` mints in sorted-key order, so the order is
666/// deterministic and mount-independent. Consequences that follow directly:
667///
668/// - All discovered modules link into the one `StoryData`; the entry file
669///   still only designates the *start flow* (compilation universe ≠ execution
670///   entry).
671/// - A `.brink` file that fails to compile is an error **even if no other
672///   module references it** — its diagnostics are inside the closure the build
673///   gate reads, so it fails the build (Rust parity: the whole module tree is
674///   the unit).
675///
676/// A project is "native" iff its entry file is a `.brink` module; the closure
677/// then ranges over the `.brink` files only (any stray `.ink` file sharing the
678/// session db is not a discovered native module and never enters it).
679/// Reachability-based dead-module elimination is an explicitly-deferred future
680/// subtraction (decision-log) — this closure is the full discovered set.
681pub(crate) fn compilation_closure_files(
682    db: &dyn salsa::Database,
683    project: ProjectInput,
684) -> Vec<FileId> {
685    let Some(entry) = project.entry(db) else {
686        return Vec::new();
687    };
688    let files = project.files(db);
689    let entry_is_native = files
690        .iter()
691        .find(|f| f.file_id(db) == entry)
692        .is_some_and(|f| file_language(f.path(db)) == Language::Native);
693
694    if entry_is_native {
695        // Every discovered `.brink` module is the compilation unit. Sort by
696        // `FileId` (minted in sorted-key order by `discover_native`) so the
697        // order is deterministic regardless of session-db insertion order.
698        let mut ids: Vec<FileId> = files
699            .iter()
700            .filter(|f| file_language(f.path(db)) == Language::Native)
701            .map(|f| f.file_id(db))
702            .collect();
703        ids.sort_unstable_by_key(|id| id.0);
704        ids
705    } else {
706        include_graph_query(db, project).topological_order(entry)
707    }
708}
709
710/// Whether `project` is a native compilation unit — its entry file is a
711/// `.brink` module (the same "entry file decides the frontend" rule
712/// [`compilation_closure_files`] documents). `false` when there is no entry
713/// file at all.
714///
715/// The T1b dialect-gate decoupling (issue #1348) reads this to skip the
716/// ink-only `E064` config error (`strict::config_error`, via
717/// [`brink_analyzer::strict_diagnostics`]'s `is_native` flag) for a native
718/// project — the whole-project sibling of [`per_file_diagnostics_query`]'s
719/// own per-file `file_language(file.path(db)) == Language::Native` check.
720pub(crate) fn project_is_native(db: &dyn salsa::Database, project: ProjectInput) -> bool {
721    let Some(entry) = project.entry(db) else {
722        return false;
723    };
724    project
725        .files(db)
726        .iter()
727        .find(|f| f.file_id(db) == entry)
728        .is_some_and(|f| file_language(f.path(db)) == Language::Native)
729}
730
731/// Whether `path` names a file this db treats as compiler source at all: no
732/// extension at all, or a recognized `.ink`/`.brink` extension (compared
733/// case-insensitively). Everything else — project config (`brink.toml`),
734/// documentation (`.md`), a stray `.txt` note, and the retired converter's/
735/// oracle-regeneration JSON family (`.json`, which also covers `.ink.json` —
736/// `Path::extension` only ever reports the last dotted segment, so
737/// `story.ink.json` already matches on `"json"`) — is not source (issue
738/// #2329).
739///
740/// An **allowlist** of `.ink`/`.brink` plus "no extension", not a blocklist
741/// of known-bad extensions: a blocklist only ever excludes the extensions
742/// it happens to name, so an unlisted one (`.txt`, `.gitignore`, a stray
743/// binary asset) would still lower through the ink frontend — exactly the
744/// bug this issue describes. The "no extension" carve-out matches
745/// [`file_language`]'s own "everything unrecognized is ink" fallback:
746/// `brink_compiler::compile_with_options`'s test/bench callers pass an
747/// in-memory pseudo-path with no extension at all as `entry`, relying on
748/// exactly that fallback, and excluding it here would silently drop those
749/// callers' entry file from parsing/the symbol index.
750///
751/// This is the **one shared predicate** every whole-project query that
752/// iterates `project.files(db)` for parsing/symbol-index/diagnostics
753/// purposes gates on — [`module_map_query`], [`symbol_index_query`],
754/// [`harvest_index_query`], [`include_graph_query`], and the
755/// diagnostics-aggregation family in `queries/analysis.rs`
756/// (`resolutions_index_query`, `contributor_diagnostics_query`,
757/// `inline_docs_query`, `whole_project_diagnostics_query`,
758/// `ufcs_resolution_query`, `coalesce_types_query`,
759/// `analysis_diagnostics_query`, `has_errors_query`,
760/// `per_file_diagnostics_query`, `diagnostics_query`) — rather than each
761/// re-deriving its own ad-hoc extension check (the #2334-family "shared
762/// seam, not N copies" lesson). Queries already scoped to
763/// [`compilation_closure_files`]'s reachable set (e.g.
764/// `struct_shape_data_query`, `lir_lowering_query`,
765/// `has_errors_in_closure_query`) don't need this gate: a non-source
766/// document is never reachable through an `INCLUDE`/native-module edge, so
767/// it can never enter that closure in the first place.
768///
769/// [`project_is_all_native`] deliberately does **not** read this predicate —
770/// see that function's own doc and [`has_recognized_source_extension`] for
771/// why the nativity vote needs a strict allowlist instead.
772pub(crate) fn is_source_file(path: &str) -> bool {
773    match std::path::Path::new(path).extension() {
774        None => true,
775        Some(ext) => ext.eq_ignore_ascii_case("ink") || ext.eq_ignore_ascii_case("brink"),
776    }
777}
778
779/// Whether `path` names a file with a recognized ink (`.ink`) or native
780/// (`.brink`) source extension, compared case-insensitively — used by
781/// [`project_is_all_native`]'s nativity vote, and now (issue #2368) also the
782/// **shared, public seam** `brink-lsp`'s own file-watcher/workspace-scan
783/// classification (`is_source_path` in `crates/brink-lsp/src/backend.rs`)
784/// routes through, rather than carrying its own ad-hoc, case-sensitive `ext
785/// == "ink" || ext == "brink"` copy — the same "shared seam, not N copies"
786/// fix #2329/PR #2357 applied inside this crate's own query surfaces.
787///
788/// Deliberately narrower than [`is_source_file`]: that predicate's "no
789/// extension still counts as source" carve-out exists only to keep
790/// `compile_with_options`'s extension-less in-memory pseudo-path parsing
791/// (a lowering/symbol-index concern), and does not belong in the nativity
792/// vote — an extension-less or otherwise-unrecognized tracked file must be
793/// invisible to "is every source file here native", neither disqualifying
794/// nativity nor counting toward it, exactly as it was before #2329
795/// introduced [`is_source_file`]. The two predicates answer different
796/// questions for the same unrecognized-extension input.
797pub fn has_recognized_source_extension(path: &str) -> bool {
798    std::path::Path::new(path)
799        .extension()
800        .is_some_and(|ext| ext.eq_ignore_ascii_case("brink") || ext.eq_ignore_ascii_case("ink"))
801}
802
803/// Whether `path` names a native (`.brink`) source file, compared
804/// case-insensitively — [`file_language`] narrowed to a boolean, and public
805/// for the same reason [`has_recognized_source_extension`] is (issue
806/// #2368): `brink-lsp` carried two of its own ad-hoc, **case-sensitive**
807/// `ext == "brink"` copies (`crates/brink-lsp/src/backend.rs`'s
808/// `is_native_path` and `crates/brink-lsp/src/backend/projects.rs`'s
809/// function of the same name) for a classification this crate already
810/// performs correctly — a real `.BRINK` file on a case-insensitive
811/// filesystem must classify identically to `.brink`, not silently fall
812/// through to "not native" the way `ext == "brink"` alone would.
813pub fn is_native_source_path(path: &str) -> bool {
814    file_language(path) == Language::Native
815}
816
817/// Whether every *recognized source file* (ink or native) currently tracked
818/// in `project` is a native `.brink` module — `false` for an empty project,
819/// one holding even a single ink source file, or one whose only tracked
820/// files are non-source documents (there is then no native file to be "all"
821/// of).
822///
823/// A tracked file with neither a `.brink` nor an `.ink` extension —
824/// [`has_recognized_source_extension`] is `false` — is invisible to this
825/// check in both directions: it neither disqualifies nativity nor counts
826/// toward it. Before this fix (issue #2318), a project's own `brink.toml`
827/// sharing a session with its native source files — exactly how
828/// `IdeSession`'s editor callers load it, so the Binder can show/edit it —
829/// silently classified as [`Language::Ink`] via [`file_language`]'s
830/// "everything else is ink" fallback and flipped this to `false`, disabling
831/// M-2d cross-declared-module coexistence for a project that was, in every
832/// sense a compile cares about, fully native. The visible symptom was a
833/// self-contradictory pair of diagnostics for any name a project's own
834/// module shared with the mounted stdlib: reported as both a duplicate
835/// definition (the collision fell through to the undeclared/legacy "true
836/// duplicate" arm) and as undeclared outside `use std::…` (the project's own
837/// declaration had just been dropped as that "duplicate").
838///
839/// Reads [`has_recognized_source_extension`], not [`is_source_file`]: the
840/// latter's "no extension still counts as source" contract exists for a
841/// different question (see its own doc) and would wrongly disqualify
842/// nativity for an all-native project holding a stray extension-less or
843/// otherwise-unrecognized tracked file — reachable the same way `brink.toml`
844/// was (`IdeSession`'s editor callers load every discovered path into the
845/// session with no source-vs-config distinction).
846///
847/// [`project_is_native`]'s "entry file decides the frontend" rule is right
848/// for a codegen-shaped question ("which frontend am I compiling"), which
849/// always has an explicit entry (the CLI's compile target). `symbol_index_query`
850/// asks a different question — "does this project have any ink file whose
851/// `dialect` could actually be wrong" — for `ProjectDb`'s single
852/// whole-workspace `ProjectInput`, which a long-lived LSP session never
853/// anchors to an entry at all (`Backend` never calls `ProjectDb::set_entry`;
854/// issue #1562 review finding). A project every one of whose *source* files
855/// is native has no such file, by the same "native has no dialect to be
856/// wrong about" reasoning [`project_is_native`]'s own doc gives —
857/// regardless of whether anything ever called `set_entry`.
858pub(crate) fn project_is_all_native(db: &dyn salsa::Database, project: ProjectInput) -> bool {
859    let files = project.files(db);
860    let mut saw_source = false;
861    for f in files {
862        let path = f.path(db);
863        if !has_recognized_source_extension(path) {
864            continue;
865        }
866        saw_source = true;
867        if file_language(path) != Language::Native {
868            return false;
869        }
870    }
871    saw_source
872}
873
874// ─── Layer 2: project-wide names ─────────────────────────────────────
875
876/// Every file's resolved module (M-1, docs/modules-spec.md §1/§5) plus the
877/// stem-collision diagnostics (`E085`). Extracted as its own memoized query
878/// (issue #790) so both [`symbol_index_query`] — which qualifies identity by
879/// declared module — and [`resolve_query`] — which needs each referring
880/// file's module + imports to scope resolution — share one computation.
881///
882/// Undeclared stem-modules (the entire pre-modules corpus) resolve to
883/// non-qualifying entries, so their `DefinitionId`s stay byte-identical.
884#[salsa::tracked(returns(ref))]
885pub(crate) fn module_map_query(
886    db: &dyn salsa::Database,
887    project: ProjectInput,
888) -> (brink_analyzer::ModuleMap, Vec<Diagnostic>) {
889    let files = project.files(db);
890
891    // Native `.brink` files derive their module PURELY from their
892    // root-relative path (decision-log 2026-07-22), bypassing `resolve_modules`
893    // entirely — they have no `#@module` inheritance and no INCLUDE graph, so
894    // routing them through the ink resolver would only couple their save-key
895    // identity to machinery they never use. Only ink files feed `resolve_modules`.
896    //
897    // `is_source_file`, not just `file_language(..) == Ink` (issue #2329):
898    // `file_language`'s "everything else is ink" fallback previously handed
899    // a project's own `brink.toml`/`.md`/`.json`/`.ink.json` straight into
900    // `resolve_modules` as if it were real ink source, minting it a bogus
901    // stem-derived module entry that could then collide with (or shadow) a
902    // real file's declared module.
903    let ink_inputs: Vec<crate::modules::FileModuleInput> = files
904        .iter()
905        .filter(|f| is_source_file(f.path(db)) && file_language(f.path(db)) == Language::Ink)
906        .map(|f| {
907            // `raw_lowered_query`, not the project-aware `lowered_query`
908            // (issue #2289): `hir.module` is a structural top-of-file
909            // directive, unaffected by claiming injection, and reading the
910            // project-aware query here would close a cycle back through
911            // `external_claim_handlers_query`, which itself reads this
912            // very query to resolve the conventions module's file.
913            let hir_module = raw_lowered_query(db, *f).hir.module.as_ref();
914            crate::modules::FileModuleInput {
915                file: f.file_id(db),
916                stem: crate::modules::file_stem(f.path(db)).to_string(),
917                declared: hir_module.map(|m| m.name.clone()),
918                was: hir_module.and_then(|m| m.was.as_ref().map(|(old, _)| old.clone())),
919            }
920        })
921        .collect();
922    let (mut map, diags) =
923        crate::modules::resolve_modules(&ink_inputs, include_graph_query(db, project));
924
925    // A native file's module NAME is `native_module_path(root-relative key)`,
926    // marked `declared` so it always qualifies `DefinitionId` (path on disk =
927    // identity). Only the *name* is path-derived and bypasses the resolver —
928    // that is the save-key-critical isolation (the name is a pure function of
929    // the path; `FileId` is only the map key, never hashed, so adding a file
930    // cannot shift another file's identity).
931    //
932    // The rest of the module system is the SAME feature, just path-spelled:
933    // `was` (rename migration, so a moved file's old saves still resolve) is
934    // read from the file's own `@[was("old::path")]` annotation via its HIR,
935    // exactly as the ink path reads `#@was` — never hard-dropped (issue #1286
936    // wired the native parse/lower; `lower_native::module`). `None` when the
937    // file authored no `@[was]`.
938    // The key handed to `native_module_path` is the file's path made
939    // root-relative to the project's registered `native_root` (issue #1572) —
940    // a no-op for every compile path (`discover_native` already keys
941    // root-relative, so `native_root` is `None`), and the normalization that
942    // makes a long-lived editor session's absolute-path keys mint the *same*
943    // module identity a real compile of the same tree does.
944    let native_root = project.native_root(db).as_deref();
945    for f in files {
946        if file_language(f.path(db)) == Language::Native {
947            // `raw_lowered_query` — see the identical note on the ink arm
948            // above.
949            let was = raw_lowered_query(db, *f)
950                .hir
951                .module
952                .as_ref()
953                .and_then(|m| m.was.as_ref().map(|(old, _)| old.clone()));
954            let key = crate::modules::root_relative_key(native_root, f.path(db));
955            map.insert(
956                f.file_id(db),
957                brink_analyzer::ResolvedModule {
958                    name: crate::modules::native_module_path(&key),
959                    declared: true,
960                    was,
961                },
962            );
963        }
964    }
965
966    (map, diags)
967}
968
969/// The merged project-wide symbol index plus indexing diagnostics
970/// (duplicates, built-in shadowing). Thin wrapper over
971/// [`brink_analyzer::symbol_index`].
972#[salsa::tracked(returns(ref))]
973pub(crate) fn symbol_index_query(
974    db: &dyn salsa::Database,
975    project: ProjectInput,
976) -> (Arc<SymbolIndex>, Vec<Diagnostic>) {
977    let files = project.files(db);
978    // `is_source_file` (issue #2329): a non-source document's manifest never
979    // joins the project symbol index — see that predicate's own doc for the
980    // full list of gated query surfaces.
981    let manifest_refs: Vec<(FileId, &SymbolManifest)> = files
982        .iter()
983        .filter(|f| is_source_file(f.path(db)))
984        .map(|f| (f.file_id(db), &lowered_query(db, project, *f).manifest))
985        .collect();
986
987    let (module_map, module_diags) = module_map_query(db, project);
988
989    // M-2c/M-2d (issues #784/#790): the cross-declared-module duplicate
990    // handling (E096 stopgap → coexistence) is dialect-gated (brink only)
991    // inside `symbol_index_with_modules` itself, so the project's configured
992    // dialect must reach it here.
993    let dialect = project.analysis_options(db).dialect;
994    // `is_native` (issue #1562 review finding): a native project has no
995    // dialect to be wrong about — the same reasoning `project_is_native`
996    // gives `whole_project_diagnostics_query` for skipping the ink-only
997    // `E064` config error — so M-2d cross-declared-module coexistence must
998    // not depend on a client having declared `dialect: "brink"`. Every
999    // `.brink` file's module is its path and always *declared*, so without
1000    // this a native workspace under the (default) `StrictInk` dialect would
1001    // drop one of two same-name definitions from the index instead of
1002    // letting them coexist.
1003    //
1004    // `project_is_all_native`, not `project_is_native`: this `project` is
1005    // `ProjectDb`'s single whole-workspace `ProjectInput`, which a
1006    // long-lived LSP session never anchors to a compile `entry`
1007    // (`project_is_native` always answers `false` without one) — see
1008    // `project_is_all_native`'s own doc.
1009    let is_native = project_is_all_native(db, project);
1010    let (index, mut diagnostics) =
1011        brink_analyzer::symbol_index_with_modules(&manifest_refs, module_map, dialect, is_native);
1012    diagnostics.extend(module_diags.clone());
1013    (index, diagnostics)
1014}
1015
1016/// The project-wide harvest index (issue #2114, `docs/prose-dialect-spec.md`
1017/// §5): every `@NAME` cue payload and every inline-markup span kind/
1018/// attribute name, harvested from every file's HIR and upgraded by the
1019/// registered host manifest's `markup` vocabulary — the compiler-side
1020/// sibling of [`symbol_index_query`]. Its dependency set is every file's
1021/// [`lowered_query`] output *plus* `project.analysis_options(db).host_manifest`
1022/// (read below to build the manifest upgrade) — a manifest edit does
1023/// invalidate this memo, unlike a plain per-file prose edit. That still
1024/// gives this query the same per-file early cutoff `symbol_index_query`
1025/// has for the `lowered_query` half: an edit to file A's prose only
1026/// recomputes this merge when file A's own `LoweredFile` output changes,
1027/// not on every keystroke project-wide.
1028///
1029/// Thin wrapper over [`brink_analyzer::harvest`] — see that function's own
1030/// doc, and `crate::db::ProjectDb::harvest_index` for the public surface a
1031/// completion consumer calls.
1032#[salsa::tracked(returns(ref))]
1033pub(crate) fn harvest_index_query(
1034    db: &dyn salsa::Database,
1035    project: ProjectInput,
1036) -> Arc<HarvestIndex> {
1037    let files = project.files(db);
1038    // `is_source_file` (issue #2329): a non-source document's HIR never
1039    // contributes cues/markup to the harvest index.
1040    let hir_refs: Vec<(FileId, &HirFile)> = files
1041        .iter()
1042        .filter(|f| is_source_file(f.path(db)))
1043        .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1044        .collect();
1045    let manifest = project.analysis_options(db).host_manifest.as_ref();
1046    Arc::new(brink_analyzer::harvest(&hir_refs, manifest))
1047}
1048
1049/// The early-cutoff projection of [`harvest_index_query`] a completion
1050/// consumer reads (issue #2134): every cue and span/attribute *name*, with
1051/// every [`brink_analyzer::HarvestSite`]'s `TextRange` dropped.
1052///
1053/// [`HarvestIndex`] can never `Eq`-cutoff on its own — its sites carry real
1054/// ranges, so nearly any edit changes its output — the exact gap that
1055/// forced [`resolution_index_query`] to exist for the symbol index (see
1056/// this module's own doc, "The `resolution_index` cutoff seam"). This query
1057/// is the harvest index's sibling of that seam: a prose edit that adds no
1058/// cue/span/attribute *name* anywhere makes **this** query's own output
1059/// `Eq`-identical to before, so a memoized reader of *this* projection can
1060/// backdate across it.
1061///
1062/// **Correction (review finding on #2134):** this query still calls
1063/// [`harvest_index_query`] directly above, so *this* memo's own body still
1064/// re-runs the whole-project harvest merge on every edit that changes any
1065/// file's `lowered_query` output — same as before this projection existed.
1066/// It does not skip that merge, and does not claim to. What it buys is
1067/// downstream: any *memoized* consumer that reads `harvest_completion_names`
1068/// (rather than `harvest_index` directly) sees an `Eq`-stable value across a
1069/// pure range-shifting edit and can backdate its own memo on it, the same
1070/// benefit `resolution_index_query` gives `resolve_query`. No such memoized
1071/// downstream consumer exists today — both `brink-lsp`'s `completion`
1072/// handler and `brink-web`'s `EditorSession::completions` read
1073/// `harvest_completion_names()` directly, per request, with nothing between
1074/// them and this query to backdate — so the measured present-day
1075/// incrementality delta is zero. The seam is built for the next memoized
1076/// consumer, not a win realized yet.
1077#[salsa::tracked(returns(ref))]
1078pub(crate) fn harvest_completion_index_query(
1079    db: &dyn salsa::Database,
1080    project: ProjectInput,
1081) -> Arc<HarvestNames> {
1082    let index = harvest_index_query(db, project);
1083    Arc::new(index.names())
1084}
1085
1086/// The early-cutoff projection of the symbol index used by resolution:
1087/// declarations only (locals dropped entirely — issue #517), ranges zeroed
1088/// for every remaining symbol (see module docs). Neither a body edit that
1089/// shifts a global declaration's
1090/// range nor one that adds/removes a `~ temp`/param anywhere in the project
1091/// changes this output, so every file's `resolve` memo survives untouched.
1092///
1093/// Locals are dropped rather than range-zeroed like the rest: a `Param`/
1094/// `Temp` entry's *identity* (not just its range) changes when a body edit
1095/// adds or removes a local, so zeroing its range alone would not have
1096/// stopped the churn (finding 1). Resolution never needs locals from this
1097/// projection — [`resolve_query`] feeds `lookup_local_in_scope` the
1098/// declaring file's own per-file `manifest.locals` instead (a knot's body
1099/// lives in exactly one file, so cross-file local lookup was never
1100/// semantically required — see `brink_analyzer::resolve::lookup_local_in_scope`).
1101#[salsa::tracked(returns(ref))]
1102pub(crate) fn resolution_index_query(
1103    db: &dyn salsa::Database,
1104    project: ProjectInput,
1105) -> Arc<SymbolIndex> {
1106    let (index, _diags) = symbol_index_query(db, project);
1107    let mut stripped: SymbolIndex = (**index).clone();
1108    stripped
1109        .symbols
1110        .retain(|_, info| !matches!(info.kind, SymbolKind::Param | SymbolKind::Temp));
1111    let live_ids: LookupSet<DefinitionId> = stripped.symbols.keys().copied().collect();
1112    stripped.by_name.retain(|_, ids| {
1113        ids.retain(|id| live_ids.contains(id));
1114        !ids.is_empty()
1115    });
1116    for info in stripped.symbols.values_mut() {
1117        info.range = rowan::TextRange::default();
1118    }
1119    Arc::new(stripped)
1120}
1121
1122/// One file's [`ImportScope`] (issue #2272 review finding): the single
1123/// derivation [`resolve_query`] and [`analysis::per_file_diagnostics_query`]
1124/// now both call, so "the declared-module import scope for this file" has
1125/// exactly one answer instead of two independently-maintained copies of the
1126/// same four lines that could silently diverge — the exact failure mode
1127/// #2272's own gate run spent effort fixing. `ImportScope` carries no
1128/// `TextRange` (see its own doc — names only), so this query's derived
1129/// `PartialEq`/`Eq` still lets a range-only edit elsewhere backdate for every
1130/// caller, the same cutoff shape [`resolution_index_query`] already relies
1131/// on; a caller reading [`module_map_query`] directly instead (as
1132/// `per_file_diagnostics_query` briefly did before this extraction) would
1133/// pick up its range-bearing module diagnostics too, re-executing on any
1134/// range shift project-wide — the whole-project churn FG-3 exists to
1135/// eliminate.
1136///
1137/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
1138#[salsa::tracked(returns(ref), lru = 4096)]
1139pub(crate) fn file_import_scope_query(
1140    db: &dyn salsa::Database,
1141    project: ProjectInput,
1142    file: SourceFile,
1143) -> ImportScope {
1144    // `file_module` comes from the shared module map (declared modules
1145    // only — INCLUDE inheritance already applied), matching how
1146    // `symbol_index_query` qualified identity.
1147    let (module_map, _module_diags) = module_map_query(db, project);
1148    let file_module = module_map
1149        .get(&file.file_id(db))
1150        .filter(|m| m.declared)
1151        .map(|m| m.name.clone());
1152    let hir = &lowered_query(db, project, file).hir;
1153    ImportScope::new(file_module, &hir.imports)
1154}
1155
1156/// Resolve one file's references against the project-wide names. Thin
1157/// wrapper over [`brink_analyzer::resolve`], fed the decls-only cutoff
1158/// projection for globals and this file's own `manifest.locals` for
1159/// param/temp lookups — the per-file dependency edge that lets a `~ temp`
1160/// edit in file Y leave file X's memo untouched (issue #517).
1161///
1162/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
1163#[salsa::tracked(returns(ref), lru = 4096)]
1164pub(crate) fn resolve_query(
1165    db: &dyn salsa::Database,
1166    project: ProjectInput,
1167    file: SourceFile,
1168) -> (Arc<ResolutionMap>, Vec<Diagnostic>) {
1169    let index = resolution_index_query(db, project);
1170    let lowered = lowered_query(db, project, file);
1171
1172    // Import-scoped resolution (M-2d, docs/modules-spec.md §2; issue #790):
1173    // feed the resolver this file's own **declared** module and its `IMPORT`
1174    // list so a bare reference with same-name candidates across declared
1175    // modules binds to the one this file imported. The scope is inert for
1176    // the pre-modules / single-module world (no declared module qualifies
1177    // identity, so every candidate carries `module: None` and the resolver's
1178    // fast path is byte-identical). Shared with `per_file_diagnostics_query`
1179    // via `file_import_scope_query` (issue #2272 review finding) so the two
1180    // consult the literal same `ImportScope` object per file.
1181    let scope = file_import_scope_query(db, project, file);
1182
1183    brink_analyzer::resolve(file.file_id(db), &lowered.manifest, index, scope)
1184}
1185
1186/// Interned key for [`signature_query`] and [`local_signature_query`].
1187/// Keyed on the content-addressed [`DefinitionId`] alone: colliding ids
1188/// among non-local declarations (duplicate names across files) map to a
1189/// *single* index entry chosen deterministically by the merge, so the memo
1190/// cannot diverge from what a non-memoized `signature(def)` call would
1191/// return for the same id. For `signature_query`, local (`Param`/`Temp`)
1192/// ids no longer collide across files in a way that matters here —
1193/// [`resolution_index_query`] drops locals entirely (issue #517). A local's
1194/// `DefinitionId` itself carries no file component, so a colliding id
1195/// *would* matter for [`local_signature_query`] — that query disambiguates
1196/// by taking its own explicit `file` parameter alongside this same `DefKey`
1197/// (issue #530), rather than relying on uniqueness of the id alone.
1198#[salsa::interned]
1199pub(crate) struct DefKey<'db> {
1200    pub def: DefinitionId,
1201}
1202
1203/// Per-declaration signature stub (spec §4 layer 2). Reads the decls-only,
1204/// range-stripped index projection — [`Sig`] carries no ranges, so this is
1205/// output-identical to reading the full index for declarations, while
1206/// backdating across whitespace/body edits. Locals are not addressable here
1207/// (returns `None` for a `Param`/`Temp` [`DefinitionId`], issue #517):
1208/// resolving one would require scanning every file's `manifest.locals` to
1209/// find the declaring file, reintroducing the project-wide invalidation this
1210/// projection exists to avoid. Locals stay permanently non-addressable via
1211/// this query — see [`local_signature_query`] for the per-file path hover
1212/// now uses instead (issue #530).
1213///
1214/// **Declaring-file dependency only (issue #630 / FG-1 §2.1).**
1215/// `brink_analyzer::signature` reads only the declaring file's HIR (looked
1216/// up by `SymbolInfo.file`, known from the index) — this query used to build
1217/// `hir_refs` over *every* project file before calling it, so salsa recorded
1218/// a read-edge on every file's `lowered_query` regardless, and a body edit
1219/// in any file re-ran every signature memo. Filtering `project.files(db)`
1220/// down to the one matching `SourceFile` before calling `lowered_query`
1221/// means the only per-file dependency recorded is the declaring file's own —
1222/// a body edit elsewhere in the project no longer invalidates this memo.
1223///
1224/// **Manifest dependency (T1d-2b, issue #774, docs/t1d-spec.md §3).** Also
1225/// reads `project.analysis_options(db).host_manifest` so `Handle<K>`
1226/// annotations resolve to `Ty::Handle(K)` here — the registered manifest is
1227/// project-wide, host-set config, not derived from any file's edits, so
1228/// reading it is the same coarse dependency shape `per_file_diagnostics_query`
1229/// already reads `host_manifest` at, not a reintroduction of whole-project
1230/// per-file churn.
1231///
1232/// `lru = 16384`: per-def runaway-guard ceiling (issue #647, decision log
1233/// "FG-5 memory bounding" — #537's data showed this family scales with
1234/// live project defs, never with session length, so the ceiling is sized
1235/// far above realistic project scale and never evicts in steady state).
1236/// `heap_size = heap_size::signature_heap_size`: one of the five #538
1237/// estimators — #537 named `signature` the widest-fanout per-def memo.
1238#[salsa::tracked(lru = 16384, heap_size = heap_size::signature_heap_size)]
1239pub(crate) fn signature_query<'db>(
1240    db: &'db dyn salsa::Database,
1241    project: ProjectInput,
1242    def: DefKey<'db>,
1243) -> Option<Arc<Sig>> {
1244    let index = resolution_index_query(db, project);
1245    let def_id = def.def(db);
1246    let declaring_file = index.symbols.get(&def_id)?.file;
1247    let hir_refs: Vec<(FileId, &HirFile)> = project
1248        .files(db)
1249        .iter()
1250        .filter(|f| f.file_id(db) == declaring_file)
1251        .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1252        .collect();
1253    let opts = project.analysis_options(db);
1254    brink_analyzer::signature(def_id, index, &hir_refs, opts.host_manifest.as_ref())
1255}
1256
1257/// The per-file locals path [`signature_query`] itself cannot take (issue
1258/// #530): [`resolution_index_query`] drops `Param`/`Temp` locals entirely
1259/// (issue #517), so `signature_query(def)` short-circuits to `None` for
1260/// any local `DefinitionId` — a silent "hover shows nothing" trap for
1261/// whoever wires hover/signature to a local next. A local's `DefinitionId`
1262/// carries no file component (content hash of `(scope, name, kind)` alone —
1263/// `brink_analyzer::local_signature`'s doc), so unlike [`signature_query`]
1264/// it cannot recover its declaring file from the project-wide index without
1265/// either a whole-project scan (reintroducing exactly the invalidation
1266/// #517's cutoff exists to kill) or a caller-supplied file. This query
1267/// takes `file` explicitly instead — the same per-file-only shape
1268/// [`resolve_query`] already uses for local lookups (a local's body lives
1269/// in exactly one file, issue #517) — so a body edit in a *different* file
1270/// leaves this memo untouched.
1271///
1272/// Per #531 (converge `symbol_index_query` to decls-only): this is
1273/// deliberately a *separate* query, not a widening of `signature_query`'s
1274/// own index read — it serves locals without merging the decls-only and
1275/// full indexes back together.
1276///
1277/// `lru = 4096`: per-(file, def) runaway-guard ceiling (issue #647,
1278/// decision log "FG-5 memory bounding"), matching the other per-file
1279/// families' ceiling — a `Sig` is small and this query reads only its own
1280/// file's `manifest.locals`, so it carries none of `signature_query`'s
1281/// wider per-def fanout. `heap_size = heap_size::signature_heap_size`
1282/// (issue #538/#530): the output is the identical `Option<Arc<Sig>>` shape
1283/// `signature_query` already estimates, so the same walk is reused rather
1284/// than duplicated — see `heap_size.rs`'s module doc.
1285#[salsa::tracked(lru = 4096, heap_size = heap_size::signature_heap_size)]
1286pub(crate) fn local_signature_query<'db>(
1287    db: &'db dyn salsa::Database,
1288    project: ProjectInput,
1289    file: SourceFile,
1290    def: DefKey<'db>,
1291) -> Option<Arc<Sig>> {
1292    let index = resolution_index_query(db, project);
1293    let manifest = &lowered_query(db, project, file).manifest;
1294    let opts = project.analysis_options(db);
1295    brink_analyzer::local_signature(def.def(db), manifest, index, opts.host_manifest.as_ref())
1296}
1297
1298// ─── Layer 2/3: type inference (TM-1, advisory-only) ──────────────────
1299//
1300// The checker substrate (typed-mode-spec §2/§9 step 1): `signature`/
1301// `infer_body`/`type_diagnostics`. **Advisory-only** — nothing here changes
1302// compiler output; `type_inference_query` is not read by `lir_query` or
1303// `story_data_query`, so it is lazy by construction (computed only when a
1304// consumer calls `infer_body`/`type_diagnostics`, which today is nobody —
1305// see the PR's warm/cold benchmark report). Whole-project, like
1306// `analysis_query`/`lir_query` in this same slice (scripting-substrate spec
1307// §7 defers per-container splitting to slice C); `infer_body_query` and
1308// `type_diagnostics_query` are thin per-def/per-file views over the one
1309// project-wide memo, mirroring `signature_query`'s and `diagnostics_query`'s
1310// own shape.
1311
1312/// The cutoff projection of the symbol index feeding whole-project type
1313/// inference (issue #630 / FG-1 §3): every symbol — declarations *and*
1314/// locals (`Param`/`Temp`) — with ranges zeroed.
1315///
1316/// Unlike [`resolution_index_query`] (name *resolution*'s projection, which
1317/// drops locals entirely — issue #517, because a local's identity, not just
1318/// its range, changes when a `~ temp` is added/removed elsewhere), inference
1319/// reads `index.symbols.get(def)` only for already-*resolved* ids
1320/// (`brink_analyzer::infer::body::ty_of_def`/`observe`/`infer_list_literal`)
1321/// to recover a local's `kind`/`name` — it never resolves a name against
1322/// this index, so dropping locals here would silently make every local
1323/// reference type as `Unknown`. Neither this query nor
1324/// [`brink_analyzer::signature`] (called for globals via `infer/mod.rs`'s
1325/// `collect_globals`) ever reads a symbol's range, so zeroing it is safe and
1326/// backdates this projection across any edit that adds/removes no
1327/// declaration or local.
1328#[salsa::tracked(returns(ref))]
1329pub(crate) fn inference_index_query(
1330    db: &dyn salsa::Database,
1331    project: ProjectInput,
1332) -> Arc<SymbolIndex> {
1333    let (index, _diags) = symbol_index_query(db, project);
1334    let mut stripped: SymbolIndex = (**index).clone();
1335    for info in stripped.symbols.values_mut() {
1336        info.range = rowan::TextRange::default();
1337    }
1338    Arc::new(stripped)
1339}
1340
1341/// A strongly-connected component's stable identifier (FG-2, issue #631):
1342/// the component's minimum-valued `DefinitionId` member, exactly
1343/// [`brink_analyzer::scc_graph`]'s own sort/dedup key. A plain alias, not a
1344/// fresh newtype — it *is* a real member `DefinitionId`, reused as the
1345/// component's name, so the existing [`DefKey`] interning already covers it:
1346/// [`solve_scc_query`]'s key is `DefKey::new(db, scc_id)`, the same
1347/// interning [`signature_query`]/[`infer_body_query`] use for a definition's
1348/// own id.
1349pub(crate) type SccId = DefinitionId;
1350
1351/// The project's inferable (knot/stitch) def ids, sourced from the index
1352/// alone (FG-2.1, issue #638, Ruling 2b — `inferable_defs_query`, the
1353/// `inference_index_query` precedent applied to the "which defs have a
1354/// body" question). No HIR read: `call_graph_query`'s per-def loop and
1355/// [`call_edges_query`]/[`referenced_globals_query`]'s `inferable`
1356/// membership check both read this instead of walking every project file's
1357/// HIR just to enumerate ids — a body edit that adds/removes no
1358/// knot/stitch declaration leaves this memo's *dependency edge* untouched
1359/// (it only reads `inference_index_query`, never `lowered_query`).
1360#[salsa::tracked(returns(ref))]
1361pub(crate) fn inferable_defs_query(
1362    db: &dyn salsa::Database,
1363    project: ProjectInput,
1364) -> BTreeSet<DefinitionId> {
1365    let index = inference_index_query(db, project);
1366    brink_analyzer::inferable_defs_from_index(index)
1367}
1368
1369/// One inferable def's own params + body, read from its declaring file's
1370/// HIR alone (FG-2.1, issue #638, Ruling 2b — `def_body_query(def)`, the
1371/// per-def HIR projection `solve_scc_query` reads instead of every
1372/// project file's `lowered_query`). `Arc<plain>`, `Eq`-derived so an
1373/// edit to a *different* def in the same declaring file — which still
1374/// changes that file's `lowered_query` output — backdates here as long as
1375/// this specific def's own params/body come out byte-identical.
1376#[derive(Debug, Clone, PartialEq, Eq)]
1377pub(crate) struct DefBody {
1378    pub file: FileId,
1379    pub params: Vec<brink_ir::Param>,
1380    /// The knot's `): type ===` return annotation, if any (T1c — feeds the
1381    /// annotation-firewall overlay in `infer_def_body`).
1382    pub return_annotation: Option<brink_ir::TypeExpr>,
1383    pub body: brink_ir::Block,
1384    /// Which frontend produced the declaring file (`HirFile::native`, issue
1385    /// #1862). Projected per def because this narrowed projection is all
1386    /// `solve_scc_query` holds — it never sees the whole `HirFile` — and
1387    /// `brink_analyzer::Def::native` needs it for the native bare-name
1388    /// fn-value typing rule (issue #1876).
1389    pub native: bool,
1390}
1391
1392/// `lru = 16384`: per-def runaway-guard ceiling (issue #647). `heap_size =
1393/// heap_size::def_body_heap_size`: one of the five #538 estimators — #537
1394/// named `def_body` (holds a full HIR `Block` clone per def) one of the
1395/// two dominant Arc-hidden-payload families.
1396#[salsa::tracked(lru = 16384, heap_size = heap_size::def_body_heap_size)]
1397pub(crate) fn def_body_query<'db>(
1398    db: &'db dyn salsa::Database,
1399    project: ProjectInput,
1400    def: DefKey<'db>,
1401) -> Option<Arc<DefBody>> {
1402    let index = inference_index_query(db, project);
1403    let def_id = def.def(db);
1404    let declaring_file = index.symbols.get(&def_id)?.file;
1405    let file = project
1406        .files(db)
1407        .iter()
1408        .find(|f| f.file_id(db) == declaring_file)?;
1409    let hir = &lowered_query(db, project, *file).hir;
1410    let (params, return_annotation, body) =
1411        brink_analyzer::def_body(def_id, &[(declaring_file, hir)], index)?;
1412    Some(Arc::new(DefBody {
1413        file: declaring_file,
1414        params,
1415        return_annotation,
1416        body,
1417        native: hir.native,
1418    }))
1419}
1420
1421/// The VAR/CONST global ids one def's body references (FG-2.1, issue #638,
1422/// Ruling 1 — `referenced_globals_query(def)`, the pre-scan `solve_scc_query`
1423/// resolves into a narrow `BodyCtx.globals` map via `signature_query`,
1424/// exactly the same declaring-file-only dependency edge
1425/// [`def_body_query`] uses). Also the per-def global *read set* a future T2
1426/// effect row needs — see `brink_analyzer::referenced_globals`'s docs.
1427///
1428/// Passes `None` for `brink_analyzer::referenced_globals`'s manifest
1429/// parameter (T1d-2b, issue #774) deliberately: this pass discards every
1430/// computed type (only the referenced-def-id *set* survives), so a
1431/// registered manifest can never change its output — reading
1432/// `project.analysis_options(db)` here would only add a needless
1433/// project-wide invalidation edge to the FG-2.1 narrow per-def dependency
1434/// this query exists to keep narrow, for zero behavioral benefit.
1435///
1436/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1437#[salsa::tracked(lru = 16384)]
1438pub(crate) fn referenced_globals_query<'db>(
1439    db: &'db dyn salsa::Database,
1440    project: ProjectInput,
1441    def: DefKey<'db>,
1442) -> Arc<BTreeSet<DefinitionId>> {
1443    let index = inference_index_query(db, project);
1444    let def_id = def.def(db);
1445    let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1446        return Arc::new(BTreeSet::new());
1447    };
1448    let Some(file) = project
1449        .files(db)
1450        .iter()
1451        .find(|f| f.file_id(db) == declaring_file)
1452    else {
1453        return Arc::new(BTreeSet::new());
1454    };
1455    let hir = &lowered_query(db, project, *file).hir;
1456    let (resolutions, _diags) = resolve_query(db, project, *file);
1457    Arc::new(brink_analyzer::referenced_globals(
1458        def_id,
1459        &[(declaring_file, hir)],
1460        index,
1461        resolutions,
1462        None,
1463    ))
1464}
1465
1466/// Pass 1, per-def (FG-2, issue #631 — `call_edges(def)`). Thin salsa
1467/// wrapper over [`brink_analyzer::call_edges`]; the per-def key gives Eq
1468/// cutoff on this def's own edge set (`BTreeSet<DefinitionId>`, no ranges,
1469/// derived `Eq`) — see the design doc §2 table's explicit allowance to keep
1470/// reusing `infer_def_body` and discard types, as `infer_project` already
1471/// did, for this pass's computation.
1472///
1473/// **Narrowed inputs (FG-2.1, issue #638, Ruling 2a).** Reads only `def`'s
1474/// own declaring file's `lowered_query`/`resolve_query` — never every
1475/// project file's — plus the index-sourced [`inferable_defs_query`]. No
1476/// globals map at all (pass 1 discards every computed type; see
1477/// `brink_analyzer::call_edges`'s docs).
1478///
1479/// Passes `None` for `brink_analyzer::call_edges`'s manifest parameter
1480/// (T1d-2b, issue #774) — same rationale as [`referenced_globals_query`]:
1481/// pass 1 discards every computed type, so the manifest can never change
1482/// this query's output, and reading `project.analysis_options(db)` here
1483/// would only widen this per-def query's dependency edge for no benefit.
1484///
1485/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1486#[salsa::tracked(lru = 16384)]
1487pub(crate) fn call_edges_query<'db>(
1488    db: &'db dyn salsa::Database,
1489    project: ProjectInput,
1490    def: DefKey<'db>,
1491) -> Arc<BTreeSet<DefinitionId>> {
1492    let index = inference_index_query(db, project);
1493    let def_id = def.def(db);
1494    let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1495        return Arc::new(BTreeSet::new());
1496    };
1497    let Some(file) = project
1498        .files(db)
1499        .iter()
1500        .find(|f| f.file_id(db) == declaring_file)
1501    else {
1502        return Arc::new(BTreeSet::new());
1503    };
1504    let hir = &lowered_query(db, project, *file).hir;
1505    let (resolutions, _diags) = resolve_query(db, project, *file);
1506    let inferable = inferable_defs_query(db, project);
1507    Arc::new(brink_analyzer::call_edges(
1508        def_id,
1509        &[(declaring_file, hir)],
1510        index,
1511        resolutions,
1512        inferable,
1513        None,
1514    ))
1515}
1516
1517/// The whole-project call graph, merged from every inferable def's
1518/// [`call_edges_query`] (FG-2, issue #631 — the derived `call_graph()` the
1519/// design doc's §2 table names). [`CallGraph`]'s `Eq` (added for this slice)
1520/// is the cutoff [`scc_membership_query`] backdates on.
1521///
1522/// Inherently project-wide (FG-2.1, issue #638, Ruling 2c: `call_graph_query`
1523/// is one of the two queries that "genuinely need project shape") — it must
1524/// enumerate every inferable def to build the graph. What FG-2.1 narrows is
1525/// each *individual* read inside the loop: [`inferable_defs_query`] is
1526/// index-only (no HIR), and each [`call_edges_query`] call is validated
1527/// without re-executing unless *that specific def's* declaring file changed
1528/// — so an edit in file X only pays for X's own defs, even though this
1529/// query's own closure still walks the whole project's def list every time
1530/// it *does* run.
1531#[salsa::tracked(returns(ref))]
1532pub(crate) fn call_graph_query(db: &dyn salsa::Database, project: ProjectInput) -> CallGraph {
1533    let defs = inferable_defs_query(db, project);
1534    let mut graph = CallGraph::new();
1535    for &def in defs {
1536        graph.add_node(def);
1537        let edges = call_edges_query(db, project, DefKey::new(db, def));
1538        for &callee in edges.iter() {
1539            graph.add_edge(def, callee);
1540        }
1541    }
1542    graph
1543}
1544
1545/// SCC partition + condensation DAG over the whole project's call graph
1546/// (FG-2, issue #631 — `scc_membership()` (+ topo order)). Thin salsa
1547/// wrapper over [`brink_analyzer::scc_graph`]. The other query Ruling 2c
1548/// keeps project-wide — SCC membership is inherently a global graph
1549/// property, not narrowable per-def.
1550#[salsa::tracked(returns(ref))]
1551pub(crate) fn scc_membership_query(db: &dyn salsa::Database, project: ProjectInput) -> SccGraph {
1552    let graph = call_graph_query(db, project);
1553    brink_analyzer::scc_graph(graph)
1554}
1555
1556/// One SCC's finalized inference result: signatures for the SCC's own
1557/// members plus their full body-type pictures. `Arc<plain>`, `Eq`-derived —
1558/// the per-SCC cutoff [`inferred_signature_query`]/[`infer_body_query`]
1559/// backdate on (Arc<plain> ruling, design doc §2 Fork 2).
1560///
1561/// **Does not carry `EXTERNAL` signatures (issue #1921).** `batch` never
1562/// contains an `EXTERNAL` (see [`brink_analyzer::solve_scc`]'s own doc), so
1563/// `signatures` here is scoped to the SCC's own knot/stitch members, same as
1564/// before #1921. [`type_inference_query`] is where every `EXTERNAL`'s
1565/// declaration-derived signature is merged in — once, at the aggregation,
1566/// not once per SCC — so it agrees with the pure whole-project
1567/// `infer_project` path without every `solve_scc_query` memo paying to
1568/// clone the project's whole external-signature map (that per-SCC
1569/// duplication would also make every memo's *value* depend on the host
1570/// manifest, which would cost `solve_scc_query`/`inferred_signature_query`/
1571/// `infer_body_query` the FG-2 cutoff `fg1_dependency_edges.rs` pins even
1572/// for an SCC that never calls an external).
1573#[derive(Debug, Clone, Default, PartialEq, Eq)]
1574pub(crate) struct SolvedScc {
1575    pub signatures: BTreeMap<DefinitionId, brink_analyzer::InferredSig>,
1576    pub bodies: BTreeMap<DefinitionId, brink_analyzer::BodyTypes>,
1577}
1578
1579/// Pass 2, per-SCC (FG-2, issue #631 — `solve_scc(SccId)`). Reads
1580/// `solve_scc_query` for every condensation predecessor first (recursion is
1581/// acyclic by construction — the condensation is a DAG, Fork 1 ruling — so
1582/// salsa never sees a cycle here), merges their finalized signatures into
1583/// `known_sigs`, then runs [`brink_analyzer::solve_scc`]'s bounded fixpoint
1584/// (plain Rust inside this one query execution) for exactly this
1585/// component's own members. Returns an empty [`SolvedScc`] for an id that
1586/// isn't any component's minimum member (defensive — never panics on a
1587/// stale/unknown key).
1588///
1589/// **Full narrowing (FG-2.1, issue #638, Ruling 2b + Ruling 1).** HIR:
1590/// [`def_body_query`] per member — only this batch's own declaring files,
1591/// never every project file's. Resolutions: only those same declaring
1592/// files' `resolve_query` results (sufficient to resolve every `Path` range
1593/// inside a member's own body, cross-file targets included — see
1594/// `signature_query`'s docs on why resolution reads the *source* file's map
1595/// only). Globals: the narrow map built from every member's
1596/// [`referenced_globals_query`] pre-scan, each id resolved through the
1597/// existing per-declaring-file [`signature_query`] (never a whole-project
1598/// globals scan). `inferable`: the same index-sourced
1599/// [`inferable_defs_query`] `call_edges_query` uses.
1600///
1601/// **Manifest dependency (T1d-2b, issue #774, docs/t1d-spec.md §3).** Also
1602/// reads `project.analysis_options(db).host_manifest`, threaded to
1603/// [`brink_analyzer::solve_scc`] so a `Handle<K>` param/return/temp
1604/// annotation resolves to `Ty::Handle(K)` during the per-SCC body-uses
1605/// solve — the seam that makes strict-mode handle-kind rejection reachable
1606/// end-to-end (the #767 acceptance criterion): once two locals of
1607/// different declared handle kinds are unified together, the #627 lattice
1608/// folds them to `Ty::Conflicted`, and `strict::check`'s pre-existing
1609/// `E066` classification reports it — this query is what was missing to
1610/// let a genuine `Ty::Handle` ever reach that lattice from body-usage
1611/// inference through the salsa-memoized pipeline. Same coarse project-wide
1612/// dependency shape [`signature_query`]/`per_file_diagnostics_query`
1613/// already read `host_manifest` at — unlike [`call_edges_query`]/
1614/// [`referenced_globals_query`] (whose *outputs* the manifest can never
1615/// change), this query's output genuinely depends on it.
1616///
1617/// **`inline_docs` dependency (issue #805).** Also reads [`inline_docs_query`]
1618/// — the same project-wide merged `///` doc-comment memo [`external_meta_query`]
1619/// already reads — and threads it to [`brink_analyzer::solve_scc`] so an
1620/// `EXTERNAL` documented purely inline (no matching registered
1621/// `ManifestExternal`) now seeds a `known_sigs` entry too, and so a
1622/// registered/inline param or return type naming a *scalar* semantic type
1623/// (not just a `Handle<K>` kind) resolves to its own base `Ty`. Range-free
1624/// (`DocBlock` carries no source spans), so this doesn't reintroduce the
1625/// whole-project-HIR churn FG-2/FG-2.1 eliminated — a doc-content-preserving
1626/// edit backdates through `inline_docs_query`'s own `Eq` cutoff exactly like
1627/// every other reader of that memo.
1628///
1629/// `lru = 16384`: per-def (per-SCC) runaway-guard ceiling (issue #647).
1630/// `heap_size = heap_size::solve_scc_heap_size`: one of the five #538
1631/// estimators — #537 named `solve_scc` (holds signatures+bodies per SCC)
1632/// the other dominant Arc-hidden-payload family alongside `def_body`.
1633#[salsa::tracked(lru = 16384, heap_size = heap_size::solve_scc_heap_size)]
1634pub(crate) fn solve_scc_query<'db>(
1635    db: &'db dyn salsa::Database,
1636    project: ProjectInput,
1637    scc: DefKey<'db>,
1638) -> Arc<SolvedScc> {
1639    let scc_id: SccId = scc.def(db);
1640    let membership = scc_membership_query(db, project);
1641    let Some(batch) = membership
1642        .order
1643        .iter()
1644        .find(|comp| comp.iter().next().copied() == Some(scc_id))
1645    else {
1646        return Arc::new(SolvedScc::default());
1647    };
1648
1649    let mut known_sigs: BTreeMap<DefinitionId, brink_analyzer::InferredSig> = BTreeMap::new();
1650    if let Some(deps) = membership.depends_on.get(&scc_id) {
1651        for &dep in deps {
1652            let solved = solve_scc_query(db, project, DefKey::new(db, dep));
1653            known_sigs.extend(solved.signatures.iter().map(|(k, v)| (*k, v.clone())));
1654        }
1655    }
1656
1657    let index = inference_index_query(db, project);
1658    let inferable = inferable_defs_query(db, project);
1659
1660    // Per-def HIR projection (Ruling 2b): only this batch's own members'
1661    // bodies. `member_bodies` keeps the owned `Arc<DefBody>`s alive for the
1662    // `Def` borrows built from them below.
1663    let member_bodies: BTreeMap<DefinitionId, Arc<DefBody>> = batch
1664        .iter()
1665        .filter_map(|&id| def_body_query(db, project, DefKey::new(db, id)).map(|b| (id, b)))
1666        .collect();
1667    let defs: Vec<brink_analyzer::Def<'_>> = member_bodies
1668        .iter()
1669        .map(|(&id, b)| brink_analyzer::Def {
1670            id,
1671            file: b.file,
1672            params: &b.params,
1673            body: &b.body,
1674            return_annotation: b.return_annotation.as_ref(),
1675            native: b.native,
1676        })
1677        .collect();
1678
1679    // Pre-scan + narrow map (Ruling 1): union of every member's
1680    // referenced_globals, each resolved through the existing
1681    // per-declaring-file `signature_query`.
1682    let mut global_ids: BTreeSet<DefinitionId> = BTreeSet::new();
1683    for &member in batch {
1684        global_ids.extend(referenced_globals_query(db, project, DefKey::new(db, member)).iter());
1685    }
1686    // `value_ty` carries the declaration's type at full `Ty` fidelity —
1687    // scalars, `List<L>`, and (since issue #1540) `Array`/`Map`/`Struct`/
1688    // `Fn`/`Handle` alike (`Option`/`Range` have no annotation grammar yet,
1689    // so they never reach here). Mirrors `brink_analyzer::infer::
1690    // collect_globals`'s own single read exactly, so this narrowed path
1691    // stays composed-equals-monolithic with it.
1692    let mut globals: BTreeMap<DefinitionId, brink_analyzer::Ty> = BTreeMap::new();
1693    for gid in global_ids {
1694        if let Some(sig) = signature_query(db, project, DefKey::new(db, gid))
1695            && let Some(ty) = sig.value_ty.clone()
1696        {
1697            globals.insert(gid, ty);
1698        }
1699    }
1700
1701    // Narrowed resolutions (Ruling 2b): only this batch's own declaring
1702    // files' `resolve_query` results, deduplicated by file.
1703    let mut resolutions = ResolutionMap::new();
1704    let member_files: BTreeSet<FileId> = member_bodies.values().map(|b| b.file).collect();
1705    for file_id in member_files {
1706        if let Some(file) = project.files(db).iter().find(|f| f.file_id(db) == file_id) {
1707            let (file_map, _diags) = resolve_query(db, project, *file);
1708            resolutions.extend(file_map.iter().cloned());
1709        }
1710    }
1711
1712    let opts = project.analysis_options(db);
1713    let inline_docs = inline_docs_query(db, project);
1714    let (signatures, bodies) = brink_analyzer::solve_scc(
1715        batch,
1716        &defs,
1717        index,
1718        &resolutions,
1719        &globals,
1720        inferable,
1721        known_sigs,
1722        opts.host_manifest.as_ref(),
1723        inline_docs,
1724    );
1725    Arc::new(SolvedScc { signatures, bodies })
1726}
1727
1728/// Per-def inferred signature (`inferred_signature(def)`, FG-2 issue #631 —
1729/// the missing per-def API TM-2's firewall consumer needs most). `None` for
1730/// a def with no inferable body (not a knot/stitch, or an unknown id) — same
1731/// `None` contract as [`signature_query`]/[`infer_body_query`].
1732///
1733/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1734#[salsa::tracked(lru = 16384)]
1735pub(crate) fn inferred_signature_query<'db>(
1736    db: &'db dyn salsa::Database,
1737    project: ProjectInput,
1738    def: DefKey<'db>,
1739) -> Option<Arc<brink_analyzer::InferredSig>> {
1740    let def_id = def.def(db);
1741    let membership = scc_membership_query(db, project);
1742    let scc_id = *membership.member_of.get(&def_id)?;
1743    let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
1744    solved.signatures.get(&def_id).cloned().map(Arc::new)
1745}
1746
1747/// One def's raw effect atoms (T2-1, docs/effects-spec.md §2/§4, issue #860 —
1748/// `def_effect_atoms(def)`). The per-def read/write/call-kind atom bundle the
1749/// effect-row fixpoint closes over, harvested by the exact same body walk
1750/// [`referenced_globals_query`]/[`call_edges_query`] already drive — same
1751/// declaring-file-only HIR + resolution dependency edges, same index-sourced
1752/// [`inferable_defs_query`] for classifying a call target as an edge vs. a
1753/// terminal external. Passes `None` for the manifest for the same reason
1754/// [`call_edges_query`] does: this pass discards every computed type, so the
1755/// manifest can never change the *structural* atom sets it keeps.
1756///
1757/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1758#[salsa::tracked(lru = 16384)]
1759pub(crate) fn def_effect_atoms_query<'db>(
1760    db: &'db dyn salsa::Database,
1761    project: ProjectInput,
1762    def: DefKey<'db>,
1763) -> Arc<brink_analyzer::EffectAtoms> {
1764    let index = inference_index_query(db, project);
1765    let def_id = def.def(db);
1766    let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1767        return Arc::new(brink_analyzer::EffectAtoms::default());
1768    };
1769    let Some(file) = project
1770        .files(db)
1771        .iter()
1772        .find(|f| f.file_id(db) == declaring_file)
1773    else {
1774        return Arc::new(brink_analyzer::EffectAtoms::default());
1775    };
1776    let hir = &lowered_query(db, project, *file).hir;
1777    let (resolutions, _diags) = resolve_query(db, project, *file);
1778    let inferable = inferable_defs_query(db, project);
1779    Arc::new(brink_analyzer::def_effect_atoms(
1780        def_id,
1781        &[(declaring_file, hir)],
1782        index,
1783        resolutions,
1784        inferable,
1785        None,
1786    ))
1787}
1788
1789/// One SCC's finalized effect rows (T2-1, docs/effects-spec.md §4, issue #860
1790/// — the per-SCC effect fixpoint, lifting [`solve_scc_query`]'s exact shape to
1791/// the effect lattice). Reads every condensation-predecessor SCC's
1792/// `effects_scc_query` first for `known_rows` (recursion is acyclic — the
1793/// condensation is a DAG, same Fork 1 ruling [`solve_scc_query`] relies on, so
1794/// salsa never sees a cycle), collects every member's
1795/// [`def_effect_atoms_query`], then runs [`brink_analyzer::solve_scc_effects`]
1796/// for this component's own members. Returns an empty map for an id that isn't
1797/// any component's minimum member (defensive — never panics on a stale key).
1798///
1799/// `lru = 16384`: per-def (per-SCC) runaway-guard ceiling (issue #647).
1800#[salsa::tracked(lru = 16384)]
1801pub(crate) fn effects_scc_query<'db>(
1802    db: &'db dyn salsa::Database,
1803    project: ProjectInput,
1804    scc: DefKey<'db>,
1805) -> Arc<BTreeMap<DefinitionId, brink_analyzer::EffectRow>> {
1806    let scc_id: SccId = scc.def(db);
1807    let membership = scc_membership_query(db, project);
1808    let Some(batch) = membership
1809        .order
1810        .iter()
1811        .find(|comp| comp.iter().next().copied() == Some(scc_id))
1812    else {
1813        return Arc::new(BTreeMap::new());
1814    };
1815
1816    let mut known_rows: BTreeMap<DefinitionId, brink_analyzer::EffectRow> = BTreeMap::new();
1817    if let Some(deps) = membership.depends_on.get(&scc_id) {
1818        for &dep in deps {
1819            let solved = effects_scc_query(db, project, DefKey::new(db, dep));
1820            known_rows.extend(solved.iter().map(|(k, v)| (*k, v.clone())));
1821        }
1822    }
1823
1824    let atoms: BTreeMap<DefinitionId, brink_analyzer::EffectAtoms> = batch
1825        .iter()
1826        .map(|&id| {
1827            (
1828                id,
1829                (*def_effect_atoms_query(db, project, DefKey::new(db, id))).clone(),
1830            )
1831        })
1832        .collect();
1833
1834    Arc::new(brink_analyzer::solve_scc_effects(
1835        batch,
1836        &atoms,
1837        &known_rows,
1838    ))
1839}
1840
1841/// Per-def effect row (T2-1, docs/effects-spec.md §4, issue #860 —
1842/// `effects(def)`, the advisory row query sited beside
1843/// [`inferred_signature_query`]). Routes through the def's SCC exactly as
1844/// [`inferred_signature_query`] routes through [`solve_scc_query`]. `None` for
1845/// a def with no inferable body (not a knot/stitch, or an unknown id) — same
1846/// contract as [`inferred_signature_query`]/[`infer_body_query`].
1847///
1848/// **Consumed by `story_data` since T2-3** (#862): `populate_effect_rows`
1849/// reads this for every inferable def to emit the `EffectRows` section. The
1850/// row is still additive metadata the *runtime* does not consume (the linker
1851/// never reads `effect_rows`), so the oracle stays byte-identical — but the row
1852/// now ships in the `.inkb`, so this is no longer advisory-only. `lir_product`
1853/// and `diagnostics` still do not read it.
1854///
1855/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1856#[salsa::tracked(lru = 16384)]
1857pub(crate) fn effects_query<'db>(
1858    db: &'db dyn salsa::Database,
1859    project: ProjectInput,
1860    def: DefKey<'db>,
1861) -> Option<Arc<brink_analyzer::EffectRow>> {
1862    let def_id = def.def(db);
1863    let membership = scc_membership_query(db, project);
1864    let scc_id = *membership.member_of.get(&def_id)?;
1865    let solved = effects_scc_query(db, project, DefKey::new(db, scc_id));
1866    solved.get(&def_id).cloned().map(Arc::new)
1867}
1868
1869/// Every `EXTERNAL`'s declaration-derived signature, project-wide (issue
1870/// #1921 — [`brink_analyzer::collect_external_sigs`] as its own memo).
1871/// `Arc<plain>`, `Eq`-derived, so a `host_manifest`/`inline_docs` edit that
1872/// leaves every `EXTERNAL`'s declared signature unchanged backdates this
1873/// memo exactly like [`solve_scc_query`] already backdates its own
1874/// `host_manifest`-dependent `Ty::Handle` resolution (T1d-2b, issue #774).
1875/// That backdating is *why* this is its own `#[salsa::tracked]` query and
1876/// not an inline call inside [`type_inference_query`]: reading
1877/// `project.analysis_options(db)` — a raw salsa input, never backdated on
1878/// its own — directly inside `type_inference_query` would tie
1879/// `type_inference_query`'s *own* memo to that input's revision instead of
1880/// to this query's `Eq`-cutoff output, forcing `type_inference_query` to
1881/// re-execute (a fresh `Arc::new`, breaking the pointer-identity guarantee
1882/// `fg1_dependency_edges.rs` pins) on *any* `AnalysisOptions` edit,
1883/// including a diagnostics-only one like `external_check` that never
1884/// touches an external's declared signature at all.
1885#[salsa::tracked(returns(ref))]
1886pub(crate) fn external_signatures_query(
1887    db: &dyn salsa::Database,
1888    project: ProjectInput,
1889) -> Arc<BTreeMap<DefinitionId, brink_analyzer::InferredSig>> {
1890    let index = inference_index_query(db, project);
1891    let inline_docs = inline_docs_query(db, project);
1892    let opts = project.analysis_options(db);
1893    Arc::new(brink_analyzer::collect_external_sigs(
1894        index,
1895        opts.host_manifest.as_ref(),
1896        inline_docs,
1897    ))
1898}
1899
1900/// Whole-project type inference — now an aggregation over
1901/// [`scc_membership_query`] + [`solve_scc_query`] (FG-2, issue #631; was a
1902/// single monolithic [`brink_analyzer::infer_project`] call
1903/// pre-decomposition). Still re-sourced off `inference_index`/`resolve`,
1904/// never `analysis_query` (FG-1 §3) — every query this reads
1905/// (`scc_membership_query` -> `call_graph_query` -> `call_edges_query` ->
1906/// `inference_inputs`, plus [`external_signatures_query`] below) traces
1907/// back to the same two roots (or its own `Eq`-cutoff memo), so the
1908/// pointer-identity guarantee `fg1_dependency_edges.rs` pins (a
1909/// diagnostics-only edit leaves this memo fully validated, never
1910/// re-executed) still holds after this refactor.
1911///
1912/// **Merges in every `EXTERNAL`'s signature once, here (issue #1921).**
1913/// [`solve_scc_query`]'s own `signatures` never carries one — `batch` is
1914/// never an `EXTERNAL` (see [`brink_analyzer::solve_scc`]'s own doc) — so
1915/// without this, a UFCS call into an `EXTERNAL` went argument-unchecked on
1916/// this db-backed path even though the identical call was already checked
1917/// through the pure `infer_project` path (whose `solve_batches` sibling
1918/// returns `known_sigs` wholesale, no batch filter). This re-merge reads
1919/// [`external_signatures_query`] — its own backdating memo, see its doc —
1920/// exactly once, at this single aggregation point, deliberately *not*
1921/// inside [`solve_scc_query`] itself: doing it per-SCC would (a) make every
1922/// `solve_scc_query` memo hold a full clone of the project's whole
1923/// external-signature map, multiplying `solve_scc_heap_size`'s per-memo
1924/// heap accounting by the SCC count, and (b) make every SCC's memoized
1925/// *value* depend on every `EXTERNAL`'s declared type, so a `host_manifest`
1926/// edit touching one external would invalidate every SCC's cutoff —
1927/// including SCCs that never call that external — costing
1928/// `inferred_signature_query`/`infer_body_query` the exact FG-2 per-def
1929/// cutoff `fg1_dependency_edges.rs` pins, project-wide. Merging only here
1930/// keeps that per-def cutoff intact; only this one aggregation memo
1931/// re-executes (cheaply — no fixpoint solving, just a map merge) when a
1932/// manifest edit changes an external's declared type.
1933#[salsa::tracked(returns(ref))]
1934pub(crate) fn type_inference_query(
1935    db: &dyn salsa::Database,
1936    project: ProjectInput,
1937) -> Arc<InferenceResult> {
1938    let membership = scc_membership_query(db, project);
1939    let mut signatures = BTreeMap::new();
1940    let mut bodies = BTreeMap::new();
1941    let mut seen: BTreeSet<SccId> = BTreeSet::new();
1942    for comp in &membership.order {
1943        let Some(scc_id) = comp.iter().next().copied() else {
1944            continue;
1945        };
1946        if !seen.insert(scc_id) {
1947            continue;
1948        }
1949        let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
1950        signatures.extend(solved.signatures.iter().map(|(k, v)| (*k, v.clone())));
1951        bodies.extend(solved.bodies.iter().map(|(k, v)| (*k, v.clone())));
1952    }
1953    signatures.extend(
1954        external_signatures_query(db, project)
1955            .iter()
1956            .map(|(k, v)| (*k, v.clone())),
1957    );
1958    Arc::new(InferenceResult { signatures, bodies })
1959}
1960
1961/// Per-def inferred body types (`infer_body(def)`). Re-pointed at
1962/// `solve_scc(scc_of(def))` (FG-2, issue #631) — was a view over the
1963/// whole-project `type_inference_query` memo pre-decomposition. `None` for a
1964/// def with no inferable body (not a knot/stitch, or an unknown id) — same
1965/// `None` contract as [`signature_query`].
1966///
1967/// `lru = 16384`: per-def runaway-guard ceiling (issue #647). `heap_size =
1968/// heap_size::infer_body_heap_size`: one of the five #538 estimators.
1969#[salsa::tracked(lru = 16384, heap_size = heap_size::infer_body_heap_size)]
1970pub(crate) fn infer_body_query<'db>(
1971    db: &'db dyn salsa::Database,
1972    project: ProjectInput,
1973    def: DefKey<'db>,
1974) -> Option<Arc<brink_analyzer::BodyTypes>> {
1975    let def_id = def.def(db);
1976    let membership = scc_membership_query(db, project);
1977    let scc_id = *membership.member_of.get(&def_id)?;
1978    let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
1979    solved.bodies.get(&def_id).cloned().map(Arc::new)
1980}
1981
1982/// Per-file type diagnostics (`type_diagnostics(FileId)`). **Advisory-only
1983/// in this slice**: TM-1 produces inference *results* (`infer_body`,
1984/// `signature`), not new user-facing diagnostics (typed-mode-spec §9 step 1:
1985/// "essentially no new user-facing diagnostics") — this always returns
1986/// empty today. The query exists now, correctly shaped, so TM-3 (strict
1987/// mode's `Unknown`-escape errors) only has to fill the body in rather than
1988/// threading a new query through every consumer.
1989#[salsa::tracked(returns(ref))]
1990pub(crate) fn type_diagnostics_query(
1991    db: &dyn salsa::Database,
1992    project: ProjectInput,
1993    file: SourceFile,
1994) -> Vec<Diagnostic> {
1995    let _ = (db, project, file);
1996    Vec::new()
1997}
1998
1999// ─── Layer 3: lowering / codegen (whole-project in slice B) ──────────
2000
2001/// Outcome of the pipeline through LIR lowering, mirroring
2002/// `brink-compiler`'s `compile_lir` stage sequence exactly.
2003///
2004/// `program` is `None` when errors (or a missing entry point) prevented
2005/// lowering; `errors`/`warnings` are the suppression-filtered, partitioned
2006/// diagnostics (plus LIR lowering warnings on success).
2007#[derive(Clone, Default)]
2008pub struct LirProduct {
2009    /// The lowered LIR program, if diagnostics allowed lowering to run.
2010    pub program: Option<Arc<brink_ir::lir::Program>>,
2011    /// Error-severity diagnostics (compilation failed if non-empty).
2012    pub errors: Vec<Diagnostic>,
2013    /// Warning-severity diagnostics (including LIR warnings on success).
2014    pub warnings: Vec<Diagnostic>,
2015}
2016
2017/// `lir::Program` has no `PartialEq`, so program identity (`Arc::ptr_eq`) is
2018/// the only cheap proxy. This impl exists solely to satisfy salsa's update
2019/// fallback — backdating is disabled via `no_eq` on [`lir_query`], so it is
2020/// never used to claim two independently-computed programs equal.
2021impl PartialEq for LirProduct {
2022    fn eq(&self, other: &Self) -> bool {
2023        let program_eq = match (&self.program, &other.program) {
2024            (Some(a), Some(b)) => Arc::ptr_eq(a, b),
2025            (None, None) => true,
2026            _ => false,
2027        };
2028        program_eq && self.errors == other.errors && self.warnings == other.warnings
2029    }
2030}
2031
2032/// The LIR-lowering half of [`lir_query`] split out on its own (issue #791 /
2033/// FG-4a — PR #753's seam finding #3), gated on [`has_errors_query`]'s
2034/// narrow boolean instead of the full [`analysis_diagnostics_query`] vector.
2035/// Reads only [`resolutions_index_query`], every file's [`lowered_query`]
2036/// HIR, [`include_graph_query`], and the narrow [`type_policy_query`]
2037/// projection — never the raw analysis diagnostics, and never the raw
2038/// `AnalysisOptions` input field (issue #806) — so a diagnostics edit that
2039/// changes *content* without flipping [`has_errors_query`]'s verdict, and an
2040/// options edit that doesn't change the `types` policy, both leave this memo
2041/// (and its `Arc<Program>` pointer) fully validated, not re-executed
2042/// (`fg4a_dependency_edges.rs`).
2043/// `no_eq`: `lir::Program` has no `PartialEq`, same reasoning as
2044/// [`LirProduct`]'s own impl below.
2045#[derive(Clone, Default)]
2046pub(crate) struct LirLowering {
2047    /// The lowered LIR program, if lowering succeeded with no Error-severity
2048    /// lowering diagnostic.
2049    pub program: Option<Arc<brink_ir::lir::Program>>,
2050    /// Error-severity diagnostics raised *during* LIR lowering (never from
2051    /// `analysis_diagnostics_query` — those are [`lir_query`]'s own concern).
2052    pub errors: Vec<Diagnostic>,
2053    /// Warning-severity diagnostics raised during LIR lowering.
2054    pub warnings: Vec<Diagnostic>,
2055}
2056
2057impl PartialEq for LirLowering {
2058    fn eq(&self, other: &Self) -> bool {
2059        let program_eq = match (&self.program, &other.program) {
2060            (Some(a), Some(b)) => Arc::ptr_eq(a, b),
2061            (None, None) => true,
2062            _ => false,
2063        };
2064        program_eq && self.errors == other.errors && self.warnings == other.warnings
2065    }
2066}
2067
2068// ─── FG-4d: per-container LIR chunk memos + link ─────────────────────
2069//
2070// `lir_lowering_query` below is now the **link phase**
2071// (`docs/fine-grained-salsa-proposal.md` §5 + the three-resolution-moments
2072// appendix): it reads a per-knot chunk memo per definition, assembles them
2073// (plus the whole-root chunk) into a `Program`, and gates on
2074// `has_errors_query`. The per-knot memos (`lir_knot_chunk_query`) are the
2075// FG-4d win — an edit that leaves a knot's declaring file, the project
2076// resolutions, and the struct-shape projection unchanged leaves that knot's
2077// chunk `Arc` pointer-identical (non-re-execution), and the whole-project
2078// link re-runs but backdates on the `StoryData` `Eq` firebreak
2079// (`story_data_query`). Byte-identity with the monolithic path is by
2080// construction: the chunk lowering and assembly are the *same* `brink-ir`
2081// functions `lower_to_program_with_type_mode` composes, fed the same inputs
2082// in the same interleaved walk order.
2083//
2084// Input-breadth limit (issue #830, #815): the per-knot memo depends on the
2085// whole-project `resolutions_index_query` and `struct_shape_data_query`, so
2086// non-re-execution holds for edits those two backdate across (a
2087// diagnostics-only / `AnalysisOptions` edit, and — for a knot in an
2088// *unedited* file — any edit whose resolutions/struct-shapes are unchanged).
2089// `topological_order` is now narrowed to `entry`'s transitive `INCLUDE`
2090// closure (issue #815, landed separately) rather than falling back to all
2091// project files, so `struct_shape_data_query` and the link's own inputs are
2092// scoped the same way.
2093
2094/// The cutoff-friendly struct-shape projection (FG-4d): the
2095/// `NameId`-free, `Eq`-able [`StructShapeData`] the per-knot chunk memo reads
2096/// instead of every file's HIR. Backdates when no `STRUCT` declaration (or
2097/// struct-typed global annotation) changed, so an unrelated edit leaves the
2098/// knot chunks that read it pointer-identical. Reads the same
2099/// `resolutions_index_query` index the monolithic path's `build_prelude`
2100/// does, so its ids/offsets are byte-identical.
2101#[salsa::tracked(returns(ref))]
2102pub(crate) fn struct_shape_data_query(
2103    db: &dyn salsa::Database,
2104    project: ProjectInput,
2105) -> brink_ir::lir::StructShapeData {
2106    if project.entry(db).is_none() {
2107        return brink_ir::lir::StructShapeData::default();
2108    }
2109    let files = project.files(db);
2110    let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2111    let topo = compilation_closure_files(db, project);
2112    let hir_refs: Vec<(FileId, &HirFile)> = topo
2113        .iter()
2114        .filter_map(|id| {
2115            by_id
2116                .get(id)
2117                .map(|f| (*id, &lowered_query(db, project, *f).hir))
2118        })
2119        .collect();
2120    let resolved = resolutions_index_query(db, project);
2121    brink_ir::lir::build_struct_shape_data(&hir_refs, &resolved.index, &resolved.resolutions)
2122}
2123
2124/// One file's declaration-only HIR projection (issue #839 / FG-4e):
2125/// `constants`/`variables`/`lists`/`structs`/`externals` kept, `root_content`
2126/// and every knot's body dropped to `Block::default()`/`Vec::new()`. Backed
2127/// by [`HirFile`]'s derived `PartialEq`, so a body-only edit — one that
2128/// leaves every declaration untouched — backdates this memo across the edit,
2129/// same as [`normalized_stamped_query`] backdates the file's syntax tree.
2130///
2131/// This is the per-file dependency edge [`lir_prelude_decls_query`] reads
2132/// instead of the raw, body-carrying [`lowered_query`]: `brink_ir::lir`'s
2133/// declaration-collection passes ([`collect_globals`], [`collect_lists`],
2134/// [`collect_externals`], [`build_shape_table`], [`build_global_shape_map`])
2135/// never read `root_content`/`knots` (see `PreludeDecls`'s doc in
2136/// `brink-ir`), so stripping them here is behavior-neutral for every reader
2137/// and turns "any reachable file changed" into "a reachable file's
2138/// declarations changed" as the trigger for re-interning the project name
2139/// table.
2140///
2141/// [`collect_globals`]: brink_ir::lir::build_prelude_decls
2142/// [`collect_lists`]: brink_ir::lir::build_prelude_decls
2143/// [`collect_externals`]: brink_ir::lir::build_prelude_decls
2144/// [`build_shape_table`]: brink_ir::lir::build_prelude_decls
2145/// [`build_global_shape_map`]: brink_ir::lir::build_prelude_decls
2146#[salsa::tracked(returns(ref))]
2147pub(crate) fn decl_hir_query(
2148    db: &dyn salsa::Database,
2149    project: ProjectInput,
2150    file: SourceFile,
2151) -> HirFile {
2152    let hir = &lowered_query(db, project, file).hir;
2153    HirFile {
2154        root_content: brink_ir::hir::Block::default(),
2155        knots: Vec::new(),
2156        ..hir.clone()
2157    }
2158}
2159
2160/// One file's HIR after the pre-LIR normalize + container-id stamp passes,
2161/// memoized per file (FG-4d). Without this, each of a K-knot file's per-knot
2162/// chunk memos would repeat the file's normalize+stamp, turning a cold
2163/// compile into O(K²) work per file; sharing it here keeps the per-def split
2164/// from regressing cold compile. Both passes are per-file independent, so
2165/// this is byte-identical to the file's slice of the whole-project prelude.
2166/// Reads the whole-project index only for label-container stamping (the same
2167/// index the monolithic path stamps with), and `HirFile`'s value `Eq`
2168/// backdates it across edits that leave this file's normalized shape
2169/// unchanged.
2170#[salsa::tracked(returns(ref))]
2171pub(crate) fn normalized_stamped_query(
2172    db: &dyn salsa::Database,
2173    project: ProjectInput,
2174    file: SourceFile,
2175) -> Arc<HirFile> {
2176    let resolved = resolutions_index_query(db, project);
2177    let mut hir = lowered_query(db, project, file).hir.clone();
2178    brink_ir::normalize_file(&mut hir);
2179    let mut slice = [(file.file_id(db), hir)];
2180    // #1504: the file's own path qualifies its root-content scope path, so
2181    // two files' root weaves no longer mint the same anonymous ids. Reading
2182    // `path` here adds no invalidation edge this memo did not already have —
2183    // it is an input field of the `SourceFile` it is already keyed on.
2184    //
2185    // #1696: the qualifier is the file's *root-relative* key, not its raw
2186    // registered path — `crate::modules::root_relative_key` against the
2187    // project's registered `ink_root` (`None` for every ordinary compile,
2188    // where it is a no-op), the same normalization `native_root` already
2189    // gives `.brink` module identity (issue #1572).
2190    let ink_root = project.ink_root(db).as_deref();
2191    let file_paths: LookupMap<FileId, String> = std::iter::once((
2192        file.file_id(db),
2193        crate::modules::root_relative_key(ink_root, file.path(db)).into_owned(),
2194    ))
2195    .collect();
2196    brink_ir::stamp_container_ids(&mut slice, &resolved.index, &file_paths);
2197    let [(_, stamped)] = slice;
2198    Arc::new(stamped)
2199}
2200
2201/// [`brink_ir::lir::PreludeDecls`] wrapped so [`lir_prelude_decls_query`]
2202/// (`no_eq`: the wrapped `ShapeTable`/`GlobalShapeMap` carry `NameId`s valid
2203/// only within this specific prelude's numbering, so they cannot be `Eq`
2204/// without a NameId-free relocation redesign — same reasoning as
2205/// [`LirLowering`]'s `program`) can still satisfy salsa's `Update` bound.
2206/// `Arc`-wrapped so a validated (non-re-executed) memo hands back the *same*
2207/// allocation — `Arc::ptr_eq` is the non-re-execution proof, same pattern as
2208/// [`LoweredChunk`].
2209#[derive(Clone)]
2210pub(crate) struct PreludeDeclsResult {
2211    pub decls: Arc<brink_ir::lir::PreludeDecls>,
2212}
2213
2214impl PartialEq for PreludeDeclsResult {
2215    fn eq(&self, other: &Self) -> bool {
2216        Arc::ptr_eq(&self.decls, &other.decls)
2217    }
2218}
2219
2220/// The whole-project declaration-level prelude (issue #839 / FG-4e —
2221/// `docs/fine-grained-salsa-proposal.md`'s pattern, applied past structs to
2222/// the rest of `build_prelude`): [`brink_ir::lir::build_prelude_decls`] over
2223/// every entry-reachable file's [`decl_hir_query`] projection instead of the
2224/// monolithic link's inline `build_prelude` call over raw, body-carrying
2225/// HIR. Because [`decl_hir_query`] itself backdates across a body-only edit,
2226/// a knot body edit anywhere in the project leaves *this* query's recorded
2227/// dependencies unchanged — [`lir_lowering_query`] no longer pays
2228/// `collect_globals`/`collect_lists`/`collect_externals`/`build_shape_table`/
2229/// `build_global_shape_map`'s full re-interning cost on every recompile, only
2230/// when some reachable file's actual declarations change (`fg4e_prelude_
2231/// decls.rs`).
2232///
2233/// Same input-breadth limit as [`struct_shape_data_query`] (issue #815):
2234/// scoped to `entry`'s transitive `INCLUDE` closure, not full backdating
2235/// across *any* unrelated project file's declarations (that would need a
2236/// per-file decl memo feeding the interning step directly, which the
2237/// project-wide, order-sensitive `NameTable` this produces does not allow
2238/// without the same NameId-free-projection-plus-relocation redesign
2239/// `StructShapeData` did for structs alone — out of this slice's scope, see
2240/// the PR description).
2241#[salsa::tracked(no_eq)]
2242pub(crate) fn lir_prelude_decls_query(
2243    db: &dyn salsa::Database,
2244    project: ProjectInput,
2245) -> PreludeDeclsResult {
2246    let type_mode = match type_policy_query(db, project) {
2247        TypePolicy::Strict => brink_ir::lir::TypeMode::Strict,
2248        TypePolicy::Gradual => brink_ir::lir::TypeMode::Gradual,
2249    };
2250    if project.entry(db).is_none() {
2251        return PreludeDeclsResult {
2252            decls: Arc::new(brink_ir::lir::PreludeDecls::empty(type_mode)),
2253        };
2254    }
2255    let files = project.files(db);
2256    let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2257    let topo = compilation_closure_files(db, project);
2258    let decl_refs: Vec<(FileId, &HirFile)> = topo
2259        .iter()
2260        .filter_map(|id| {
2261            by_id
2262                .get(id)
2263                .map(|f| (*id, decl_hir_query(db, project, *f)))
2264        })
2265        .collect();
2266    let resolved = resolutions_index_query(db, project);
2267    // #1774: reaches `decls::collect_globals`'s lambda-lifting path, which
2268    // qualifies a lambda-literal decl default's synthesized function by the
2269    // owning file — same #1696 root-relative convention as
2270    // `chunk_lowering_ctx_query`/`lir_lowering_query`'s own `file_paths`.
2271    // `project.files(db)` is already read just above (`by_id`), so reading
2272    // each file's `.path(db)` here adds no new dependency edge.
2273    let ink_root = project.ink_root(db).as_deref();
2274    let file_paths: LookupMap<FileId, String> = files
2275        .iter()
2276        .map(|f| {
2277            (
2278                f.file_id(db),
2279                crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2280            )
2281        })
2282        .collect();
2283    // Review finding on #1774: a decl-default lambda body is lowered through
2284    // the same `lower_lambda` machinery as any other lambda (issue #1709),
2285    // so it needs the same UFCS/`or`-coalescing verdict tables any other
2286    // lambda body gets — not the empty placeholder pair every *other*
2287    // caller of `AnalyzerTables` uses because those callers genuinely never
2288    // ran an analyzer pass. Same construction `chunk_lowering_ctx_query`
2289    // (:1970-1972) and `lir_lowering_query` (:2119-2121) already use; no new
2290    // dependency edge risk (`ufcs_resolution_query`/`coalesce_types_query`
2291    // are re-sourced off `resolutions_index_query`/`lowered_query`/
2292    // `type_inference_query`, never off this query or anything downstream of
2293    // it, so this cannot introduce a salsa cycle).
2294    let ufcs = &ufcs_resolution_query(db, project).table;
2295    let coalesce = coalesce_types_query(db, project);
2296    let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2297    let decls = brink_ir::lir::build_prelude_decls(
2298        &decl_refs,
2299        &resolved.index,
2300        &resolved.resolutions,
2301        &file_paths,
2302        type_mode,
2303        tables,
2304    );
2305    PreludeDeclsResult {
2306        decls: Arc::new(decls),
2307    }
2308}
2309
2310/// Interned key for [`lir_knot_chunk_query`]: a knot identified by its
2311/// declaring file and its index within that file's knot list. Keyed on
2312/// `(FileId, knot_index)` rather than the knot's `DefinitionId` so two knots
2313/// that would hash to the same address (e.g. same-named file-local knots)
2314/// never collapse onto one memo — the byte-identity hazard `DefinitionId`
2315/// keying would carry.
2316#[salsa::interned]
2317pub(crate) struct KnotChunkKey<'db> {
2318    pub file: FileId,
2319    pub knot_index: u32,
2320}
2321
2322/// One knot's lowered LIR chunk plus its lowering diagnostics — the value a
2323/// per-knot memo stores. `chunk` is `Arc`-wrapped so a validated (non-re-
2324/// executed) memo hands back the *same* allocation, which
2325/// `Arc::ptr_eq` detects (the non-re-execution proof — same reasoning as
2326/// `LirLowering`'s `program`). The `PartialEq` exists solely to satisfy
2327/// salsa's `Update` bound; `no_eq` disables backdating, so it is never used
2328/// to claim two independently-lowered chunks equal.
2329#[derive(Clone, Default)]
2330pub(crate) struct LoweredChunk {
2331    pub chunk: Arc<brink_ir::lir::ScopeChunk>,
2332    pub diagnostics: Vec<Diagnostic>,
2333}
2334
2335impl PartialEq for LoweredChunk {
2336    fn eq(&self, other: &Self) -> bool {
2337        Arc::ptr_eq(&self.chunk, &other.chunk) && self.diagnostics == other.diagnostics
2338    }
2339}
2340
2341/// [`brink_ir::lir::ChunkLoweringCtx`] wrapped so
2342/// [`chunk_lowering_ctx_query`] (`no_eq`: it holds the same
2343/// `ShapeTable`/`GlobalShapeMap` `PreludeDeclsResult` cannot make `Eq`) can
2344/// satisfy salsa's `Update` bound. `Arc`-wrapped so a validated memo hands
2345/// back the same allocation — same pattern as [`PreludeDeclsResult`].
2346#[derive(Clone)]
2347pub(crate) struct ChunkLoweringCtxResult {
2348    pub ctx: Arc<brink_ir::lir::ChunkLoweringCtx>,
2349}
2350
2351impl PartialEq for ChunkLoweringCtxResult {
2352    fn eq(&self, other: &Self) -> bool {
2353        Arc::ptr_eq(&self.ctx, &other.ctx)
2354    }
2355}
2356
2357/// The knot-invariant half of [`lir_knot_chunk_query`]'s lowering
2358/// environment, built once per project revision instead of once per knot
2359/// (issue #460).
2360///
2361/// Every input here is whole-project — the flattened resolution lookup, the
2362/// reconstructed struct-shape tables, the `FileId`→path map, the type mode —
2363/// so each of the project's K per-knot memos used to rebuild all of it,
2364/// making the per-knot LIR layer `O(K × project size)`. The measured cost on
2365/// `compile_bench`'s 50-file × 20-knot synthetic project was the dominant
2366/// share of cold LIR lowering; hoisting it here makes that share `O(1)` in K.
2367///
2368/// This query's dependency set is exactly the subset of
2369/// [`lir_knot_chunk_query`]'s dependencies it took over
2370/// ([`resolutions_index_query`], [`struct_shape_data_query`],
2371/// [`type_policy_query`], and the files' `path` fields), so no chunk memo
2372/// gains or loses an invalidation edge: anything that re-executes this
2373/// re-executed every chunk before.
2374#[salsa::tracked(no_eq)]
2375pub(crate) fn chunk_lowering_ctx_query(
2376    db: &dyn salsa::Database,
2377    project: ProjectInput,
2378) -> ChunkLoweringCtxResult {
2379    let resolved = resolutions_index_query(db, project);
2380    let shape_data = struct_shape_data_query(db, project);
2381    // Narrow `.types` projection (issue #806/#809) — not the raw
2382    // `AnalysisOptions` field — so an unrelated options edit doesn't
2383    // re-execute this memo (and through it, every knot chunk).
2384    let type_mode = match type_policy_query(db, project) {
2385        TypePolicy::Strict => brink_ir::lir::TypeMode::Strict,
2386        TypePolicy::Gradual => brink_ir::lir::TypeMode::Gradual,
2387    };
2388    // #1696: root-relative, not raw — see `normalized_stamped_query`'s doc.
2389    let ink_root = project.ink_root(db).as_deref();
2390    let file_paths: LookupMap<FileId, String> = project
2391        .files(db)
2392        .iter()
2393        .map(|f| {
2394            (
2395                f.file_id(db),
2396                crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2397            )
2398        })
2399        .collect();
2400    ChunkLoweringCtxResult {
2401        ctx: Arc::new(brink_ir::lir::ChunkLoweringCtx::new(
2402            &resolved.resolutions,
2403            shape_data,
2404            file_paths,
2405            type_mode,
2406        )),
2407    }
2408}
2409
2410/// Lower one knot into a self-contained LIR chunk — the per-`DefinitionId`
2411/// unit of FG-4d. Reads only the declaring file's `lowered_query` HIR
2412/// (per-file edge), the whole-project `resolutions_index_query`
2413/// (backdates across body/diagnostics edits), and
2414/// `struct_shape_data_query` (backdates unless a struct declaration
2415/// changed) — so a knot in an unedited file whose resolutions and struct
2416/// shapes are unchanged keeps its chunk `Arc` across the edit. `no_eq`:
2417/// `ScopeChunk` has no `PartialEq` (holds `lir::Container`), so this never
2418/// backdates — the link re-runs and re-anchors on `StoryData`'s `Eq`.
2419#[salsa::tracked(no_eq)]
2420pub(crate) fn lir_knot_chunk_query(
2421    db: &dyn salsa::Database,
2422    project: ProjectInput,
2423    key: KnotChunkKey<'_>,
2424) -> LoweredChunk {
2425    let file_id = key.file(db);
2426    let knot_index = key.knot_index(db) as usize;
2427    let Some(source) = project
2428        .files(db)
2429        .iter()
2430        .copied()
2431        .find(|f| f.file_id(db) == file_id)
2432    else {
2433        return LoweredChunk::default();
2434    };
2435
2436    let resolved = resolutions_index_query(db, project);
2437    // The knot-invariant half of the lowering environment (resolution
2438    // lookup, struct-shape tables, file paths, type mode), built once per
2439    // project revision rather than once per knot — issue #460.
2440    let ctx = &chunk_lowering_ctx_query(db, project).ctx;
2441
2442    // The file's normalized+stamped HIR, shared across all its knots'
2443    // memos (so a K-knot file normalizes once, not K times).
2444    let hir_file = normalized_stamped_query(db, project, source);
2445    let Some(knot) = hir_file.knots.get(knot_index) else {
2446        return LoweredChunk::default();
2447    };
2448
2449    let ufcs = &ufcs_resolution_query(db, project).table;
2450    let coalesce = coalesce_types_query(db, project);
2451    let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2452    let (chunk, diagnostics) = brink_ir::lir::lower_knot_chunk_incremental(
2453        hir_file,
2454        knot,
2455        &resolved.index,
2456        ctx,
2457        file_id,
2458        tables,
2459    );
2460    LoweredChunk {
2461        chunk: Arc::new(chunk),
2462        diagnostics,
2463    }
2464}
2465
2466/// The project's TM-3 `types` policy as its own narrow projection query
2467/// (issue #806 / PR #809, mirroring [`has_errors_query`]'s pattern): a raw
2468/// `project.analysis_options(db).types` field read inside
2469/// [`lir_lowering_query`] would register a dependency on the *whole*
2470/// `AnalysisOptions` input field, so any options edit — registering a host
2471/// manifest, toggling `semantic_type_check`, even re-setting the identical
2472/// value — would force the `no_eq` lowering memo to fully re-execute and
2473/// allocate a fresh `Arc<Program>`. `TypePolicy`'s derived `Eq` is the
2474/// cheapest possible cutoff: an options edit that doesn't change `.types`
2475/// re-executes only this trivial projection, backdates it, and leaves
2476/// [`lir_lowering_query`] (and its `Arc<Program>` pointer) fully validated —
2477/// see `fg4a_dependency_edges.rs`. Behavior-neutral by construction: the
2478/// same field, read one query-hop later.
2479///
2480/// FG-4d (issue #830) also routes the per-knot chunk memos' `.types` read
2481/// through this projection, so an `AnalysisOptions` edit that leaves `.types`
2482/// unchanged keeps every knot chunk `Arc` pointer-identical.
2483///
2484/// Since the #1127 default flip this projects the *resolved* policy
2485/// (`AnalysisOptions::type_policy()` — explicit `types` or the dialect-keyed
2486/// default), so the cutoff argument is unchanged: same narrow `TypePolicy`
2487/// value, resolved one query-hop later.
2488#[salsa::tracked]
2489pub(crate) fn type_policy_query(db: &dyn salsa::Database, project: ProjectInput) -> TypePolicy {
2490    project.analysis_options(db).type_policy()
2491}
2492
2493/// The project's resolved `[lints]` policy (issue #1160) as its own narrow
2494/// projection query — [`type_policy_query`]'s sibling, same cutoff argument:
2495/// [`lir_lowering_query`]'s severity partition needs `AnalysisOptions.lints`,
2496/// but reading it through `project.analysis_options(db)` directly would
2497/// register a dependency on the *whole* input field, so an unrelated options
2498/// edit (registering a host manifest, say) would force the `no_eq` lowering
2499/// memo to fully re-execute. `LintPolicy`'s derived `Eq` gives the same
2500/// cheap-cutoff property `TypePolicy` already has here.
2501#[salsa::tracked]
2502pub(crate) fn lint_policy_query(
2503    db: &dyn salsa::Database,
2504    project: ProjectInput,
2505) -> brink_analyzer::LintPolicy {
2506    project.analysis_options(db).lints.clone()
2507}
2508
2509/// FG-4d **link phase**: assemble the per-knot chunk memos and the whole-root
2510/// chunk into a `Program`. See the section comment above for the
2511/// byte-identity and non-re-execution arguments.
2512///
2513/// Gated on [`has_errors_in_closure_query`] (issue #1032 collapse ruling),
2514/// not the whole-project [`has_errors_query`] — this function has two
2515/// callers with different pre-conditions: [`lir_query`] already gates on the
2516/// (stronger) whole-project check before ever calling here, so for that
2517/// caller this is inert (whole-project-clean implies closure-clean, since
2518/// the closure is a subset); [`lir_in_closure_query`] gates on exactly this
2519/// (weaker) check itself, so this must use the same one to actually permit
2520/// lowering when only a file outside `entry`'s closure is broken. The
2521/// per-file lowering below was already scoped to `topological_order(entry)`
2522/// (issue #815) regardless of which gate is used here.
2523#[salsa::tracked(no_eq)]
2524pub(crate) fn lir_lowering_query(db: &dyn salsa::Database, project: ProjectInput) -> LirLowering {
2525    if project.entry(db).is_none() {
2526        return LirLowering::default();
2527    }
2528    if has_errors_in_closure_query(db, project) {
2529        return LirLowering::default();
2530    }
2531
2532    let files = project.files(db);
2533    let resolved = resolutions_index_query(db, project);
2534
2535    // LIR inputs in compile (paste-before) order, mirroring
2536    // `Driver::lir_inputs`. The order comes from [`compilation_closure_files`]:
2537    // for an ink project this is `entry`'s transitive `INCLUDE` closure
2538    // (issue #815); for a native project it is every discovered `.brink`
2539    // module (issue #1296 — native files have no `INCLUDE` edges, so the whole
2540    // discovered tree is the compilation unit). Files outside it never lower
2541    // here; their diagnostics still run independently via
2542    // `analysis_diagnostics_query`/`diagnostics_query` below and in
2543    // `super::diagnostics_query`, which iterate `project.files(db)`
2544    // directly rather than through this order.
2545    let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2546    let topo = compilation_closure_files(db, project);
2547    // #1696: root-relative, not raw — see `normalized_stamped_query`'s doc.
2548    // Feeds `lower_root_content_for_prelude`'s `IdAllocator::set_path_prefix`
2549    // call below, which must agree with `normalized_stamped_query`'s
2550    // pre-stamped HIR ids byte-for-byte, so both use the same normalization.
2551    let ink_root = project.ink_root(db).as_deref();
2552    let paths: LookupMap<FileId, String> = topo
2553        .iter()
2554        .filter_map(|id| {
2555            by_id.get(id).map(|f| {
2556                (
2557                    *id,
2558                    crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2559                )
2560            })
2561        })
2562        .collect();
2563
2564    // TM-4c (`docs/typed-mode-spec.md` §6): the project's `types` policy
2565    // gates static-offset record field ops, and also partitions lowering
2566    // diagnostics by effective severity below. Read through the narrow
2567    // [`type_policy_query`] projection, never the raw `AnalysisOptions`
2568    // input field — see its doc comment (issue #806).
2569    //
2570    // [`brink-ir`'s local `TypeMode` mirror](brink_ir::lir::TypeMode) is now
2571    // decided once, inside [`lir_prelude_decls_query`]'s own
2572    // `type_policy_query` read — this function no longer needs its own copy
2573    // (issue #839 / FG-4e removed the direct `build_prelude` call that used
2574    // to consume it here).
2575    let types = type_policy_query(db, project);
2576    // [`lint_policy_query`]'s sibling narrow projection (issue #1160) —
2577    // same cutoff rationale as `type_policy_query` above.
2578    let lints = lint_policy_query(db, project);
2579
2580    // Whole-project prelude (issue #839 / FG-4e): declarations + struct
2581    // shapes + the seeded name table come from [`lir_prelude_decls_query`]
2582    // (its own memo, cutoff-friendly across body-only edits — see its doc),
2583    // and the normalized+stamped HIR reuses the already-memoized per-file
2584    // [`normalized_stamped_query`] instead of `build_prelude`'s inline
2585    // normalize+stamp recompute. `assemble_prelude` is pure composition —
2586    // byte-identical to the monolithic `build_prelude` by construction (see
2587    // `PreludeDecls`'s doc in `brink-ir`).
2588    let prelude_decls = lir_prelude_decls_query(db, project);
2589    let normalized: Vec<(FileId, HirFile)> = topo
2590        .iter()
2591        .filter_map(|id| {
2592            by_id
2593                .get(id)
2594                .map(|f| (*id, (**normalized_stamped_query(db, project, *f)).clone()))
2595        })
2596        .collect();
2597    let prelude = brink_ir::lir::assemble_prelude((*prelude_decls.decls).clone(), normalized);
2598    let ufcs = &ufcs_resolution_query(db, project).table;
2599    let coalesce = coalesce_types_query(db, project);
2600    let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2601    let (root_chunks, root_temp_slots) = brink_ir::lir::lower_root_content_for_prelude(
2602        &prelude,
2603        &resolved.index,
2604        &resolved.resolutions,
2605        &paths,
2606        tables,
2607    );
2608
2609    // Interleave in walk order (per file: root content, then that file's
2610    // knots) — the order the assembler dedups names against. Knot chunks come
2611    // from the per-knot memos; declaration diagnostics lead, matching the
2612    // monolithic diagnostic order exactly.
2613    let mut lir_diagnostics = prelude.decl_diagnostics.clone();
2614    let mut ordered_chunks: Vec<brink_ir::lir::ScopeChunk> = Vec::new();
2615    let prelude_files = prelude.files();
2616    let mut root_iter = root_chunks.into_iter();
2617    for (file_id, hir_file) in &prelude_files {
2618        if let Some((chunk, diags)) = root_iter.next() {
2619            ordered_chunks.push(chunk);
2620            lir_diagnostics.extend(diags);
2621        }
2622        for knot_index in 0..hir_file.knots.len() {
2623            #[expect(
2624                clippy::cast_possible_truncation,
2625                reason = "a file won't declare anywhere near u32::MAX knots"
2626            )]
2627            let key = KnotChunkKey::new(db, *file_id, knot_index as u32);
2628            let lowered = lir_knot_chunk_query(db, project, key);
2629            ordered_chunks.push((*lowered.chunk).clone());
2630            lir_diagnostics.extend(lowered.diagnostics.clone());
2631        }
2632    }
2633
2634    let program =
2635        brink_ir::lir::assemble_program(&prelude, ordered_chunks, root_temp_slots, &resolved.index);
2636
2637    // LIR lowering itself is total (T1b-2: every construct lowers to a
2638    // program regardless of dialect). Error-severity lowering diagnostics
2639    // (T1b-3's E055/E056) still gate `program: None` exactly like an
2640    // analysis-phase error would.
2641    let (lir_errors, lir_warnings): (Vec<Diagnostic>, Vec<Diagnostic>) =
2642        lir_diagnostics.into_iter().partition(|d| {
2643            brink_analyzer::effective_severity(d.code, types, &lints) == Severity::Error
2644        });
2645
2646    if lir_errors.is_empty() {
2647        LirLowering {
2648            program: Some(Arc::new(program)),
2649            errors: lir_errors,
2650            warnings: lir_warnings,
2651        }
2652    } else {
2653        // A lowering-phase Error-severity diagnostic (E055/E056) blocks
2654        // compilation: surface it, never hand back a diagnostically-invalid
2655        // program.
2656        LirLowering {
2657            program: None,
2658            errors: lir_errors,
2659            warnings: lir_warnings,
2660        }
2661    }
2662}
2663
2664/// Whole-project LIR lowering (slice B: one project query; slice C splits it
2665/// per container). `no_eq`: `lir::Program` has no `PartialEq`, so this memo
2666/// never backdates — [`story_data_query`] backdates on `StoryData` instead.
2667#[salsa::tracked(returns(ref), no_eq)]
2668pub(crate) fn lir_query(db: &dyn salsa::Database, project: ProjectInput) -> LirProduct {
2669    let files = project.files(db);
2670    let Some(entry) = project.entry(db) else {
2671        return LirProduct::default();
2672    };
2673
2674    // FG-3 (issue #632): read the assembled diagnostics directly, not
2675    // through the bundled `analysis_query` — a resolutions-only change (no
2676    // diagnostic anywhere differs) leaves this half's dependency fully
2677    // validated.
2678    let diagnostics = analysis_diagnostics_query(db, project);
2679
2680    // Diagnostic gate — the same report `compile_lir` builds.
2681    let disable_all = files
2682        .iter()
2683        .find(|f| f.file_id(db) == entry)
2684        .is_some_and(|f| suppressions_query(db, *f).disable_all);
2685    // `is_source_file` (issue #2329): a non-source document's lowering
2686    // (bogus ink-parse) diagnostics never contribute to the build gate — the
2687    // exact same filter `has_errors_query` applies to the identical input
2688    // shape this query's own doc says it must match.
2689    let inputs: Vec<FileDiagnostics<'_>> = files
2690        .iter()
2691        .filter(|f| is_source_file(f.path(db)))
2692        .map(|f| FileDiagnostics {
2693            file: f.file_id(db),
2694            source: f.text(db),
2695            suppressions: suppressions_query(db, *f),
2696            lowering: &lowered_query(db, project, *f).diagnostics,
2697        })
2698        .collect();
2699    let opts = project.analysis_options(db);
2700    let types = opts.type_policy();
2701    let (mut errors, mut warnings) =
2702        partition_diagnostics(&inputs, diagnostics, disable_all, types, &opts.lints);
2703
2704    // FG-4a (issue #791, PR #753 seam finding #3): the gate deciding
2705    // whether to attempt (potentially expensive) LIR lowering reads the
2706    // narrow `has_errors_query` boolean projection instead of
2707    // `errors.is_empty()` directly. `errors`/`warnings` above are still
2708    // needed for *this* query's own return value — diagnostic content, not
2709    // just presence — but the lowering itself is fully delegated to
2710    // `lir_lowering_query`, which reads `has_errors_query` only (never the
2711    // raw diagnostics vector), so its `Arc<Program>` survives a diagnostics
2712    // edit that doesn't flip the error verdict. `has_errors_query` computes
2713    // this exact `errors.is_empty()` value from the same inputs (see its
2714    // doc comment), so this is behavior-neutral by construction.
2715    if has_errors_query(db, project) {
2716        return LirProduct {
2717            program: None,
2718            errors,
2719            warnings,
2720        };
2721    }
2722
2723    let lowering = lir_lowering_query(db, project);
2724    errors.extend(lowering.errors);
2725    warnings.extend(lowering.warnings);
2726
2727    LirProduct {
2728        program: lowering.program,
2729        errors,
2730        warnings,
2731    }
2732}
2733
2734/// The compile-scoped counterpart to [`lir_query`] (issue #1032 collapse
2735/// ruling, option (a) "both, scoped"): gates on [`has_errors_in_closure_query`]
2736/// — `entry`'s transitive `INCLUDE` closure — instead of the whole-project
2737/// [`has_errors_query`]. [`lir_query`] itself is untouched: `db.lir_product()`
2738/// and `db.has_errors()` stay whole-project-gated, exactly as FG-4a's
2739/// dependency-edge tests (`fg4a_dependency_edges.rs`) pin.
2740///
2741/// This is what [`story_data_query`] reads, so `db.story_data()` —
2742/// `compileProject`'s artifact path — no longer fails a clean entry just
2743/// because some other file loaded into the same session db (a WIP scratch
2744/// file, a second unrelated story) happens to have an error. That error
2745/// still surfaces through `diagnostics_query`/`db.diagnostics(file)`
2746/// (unchanged, whole-project) — this only narrows the *build gate*, not
2747/// what's diagnosed. For the CLI driver (`brink-compiler`), whose db is
2748/// already built from `brink-driver::discover(entry)` — entry plus its
2749/// transitive `INCLUDE`s only — `project.files(db)` and the closure coincide,
2750/// so this is behaviorally identical to the old whole-project gate there.
2751///
2752/// `errors`/`warnings` are computed the same closure-filtered way
2753/// [`has_errors_in_closure_query`] computes its verdict, so a file outside
2754/// `entry`'s closure never contributes to `compileProject`'s own error/
2755/// warning list either.
2756#[salsa::tracked(returns(ref), no_eq)]
2757pub(crate) fn lir_in_closure_query(db: &dyn salsa::Database, project: ProjectInput) -> LirProduct {
2758    let files = project.files(db);
2759    let Some(entry) = project.entry(db) else {
2760        return LirProduct::default();
2761    };
2762
2763    let closure: LookupSet<FileId> = compilation_closure_files(db, project).into_iter().collect();
2764
2765    let diagnostics: Vec<Diagnostic> = analysis_diagnostics_query(db, project)
2766        .iter()
2767        .filter(|d| closure.contains(&d.file))
2768        .cloned()
2769        .collect();
2770
2771    let disable_all = files
2772        .iter()
2773        .find(|f| f.file_id(db) == entry)
2774        .is_some_and(|f| suppressions_query(db, *f).disable_all);
2775    let inputs: Vec<FileDiagnostics<'_>> = files
2776        .iter()
2777        .filter(|f| closure.contains(&f.file_id(db)))
2778        .map(|f| FileDiagnostics {
2779            file: f.file_id(db),
2780            source: f.text(db),
2781            suppressions: suppressions_query(db, *f),
2782            lowering: &lowered_query(db, project, *f).diagnostics,
2783        })
2784        .collect();
2785    let opts = project.analysis_options(db);
2786    let types = opts.type_policy();
2787    let (mut errors, mut warnings) =
2788        partition_diagnostics(&inputs, &diagnostics, disable_all, types, &opts.lints);
2789
2790    if has_errors_in_closure_query(db, project) {
2791        return LirProduct {
2792            program: None,
2793            errors,
2794            warnings,
2795        };
2796    }
2797
2798    let lowering = lir_lowering_query(db, project);
2799    errors.extend(lowering.errors);
2800    warnings.extend(lowering.warnings);
2801
2802    LirProduct {
2803        program: lowering.program,
2804        errors,
2805        warnings,
2806    }
2807}
2808
2809/// Outcome of the full pipeline: compiled [`StoryData`] or the diagnostics
2810/// that prevented it. Batch compile = pull this one query (spec §5).
2811#[derive(Debug, Clone, Default, PartialEq)]
2812pub struct CompileProduct {
2813    /// The compiled story, if compilation succeeded.
2814    pub story: Option<Arc<StoryData>>,
2815    /// Error-severity diagnostics (compilation failed if non-empty).
2816    pub errors: Vec<Diagnostic>,
2817    /// Warning-severity diagnostics.
2818    pub warnings: Vec<Diagnostic>,
2819}
2820
2821/// Whole-project codegen: LIR → [`StoryData`] via `brink-codegen-inkb`.
2822///
2823/// `brink_codegen_inkb::emit` only fails on a `Program` that violates an
2824/// invariant an earlier, non-suppressible LIR-lowering diagnostic (E057) is
2825/// supposed to guarantee — see `CodegenError`'s doc comment and #586. That
2826/// can't happen via this query today (`lir.program` is only `Some` when
2827/// `lir.errors` is already empty, which requires E057 to not have fired),
2828/// but codegen has no way to prove that structurally, so this still handles
2829/// the `Err` case for real rather than assuming it away: surfaced as an
2830/// `E060` compile error (no meaningful source span survives into codegen
2831/// for this class of defect, so it's anchored at the project entry file
2832/// with an empty range) rather than silently downgrading to `story: None`
2833/// with an empty `errors` — which would look like "nothing to compile" to
2834/// every caller instead of "codegen refused to compile this."
2835/// Populate the T2-3 `EffectRows` table (#862, `docs/effects-spec.md` §11):
2836/// one factored row per inferable definition (every knot/stitch — the host's
2837/// resume-scheduling estimate, §12.1). Rows are read straight off the advisory
2838/// [`effects_query`] fixpoint and lowered to wire vocabulary:
2839///
2840/// - `reads`/`writes` cells ride through as [`DefinitionId`]s (already sorted —
2841///   they come from `BTreeSet`s).
2842/// - each call-kind name is interned into the story's `name_table`
2843///   (find-or-append, in the row's sorted call order for determinism) and
2844///   emitted as a [`CallAtom`] with the capability-parameter slot populated
2845///   `Any` (component-granular, the v1 value) and the reserved
2846///   handle-parameter slot left `None`.
2847/// - the per-dispatch entry list is empty in v1 (call-through-value is inferred
2848///   as opaque, folded into the direct part) — but the row structure ships the
2849///   slot so §7 narrowing is not structurally foreclosed.
2850/// - **#882 freeze semantics**: `is_entry` is `false` exactly when `def` is in
2851///   `story.private_defs` (already populated by codegen from
2852///   `Program::private_defs` — `#@private`, `docs/modules-spec.md` §4 — by
2853///   the time this runs), `true` otherwise. This is the *only* filter T2-3 was
2854///   missing: every row still ships regardless (a `#@private` knot/stitch can
2855///   still be captured as a fn-value token a *public* path holds, and the
2856///   dispatch-narrowing machinery resolves that token by `DefinitionId`, not
2857///   by name — `#@private` hides the name, not the cell). `is_entry` only
2858///   gates whether the row is a legitimate *host-lookup* target; it is never
2859///   used to drop a row from this table.
2860///
2861/// Appending call names to `name_table` is safe for inertness: existing
2862/// `NameId` indices are unchanged, and the only references to the appended
2863/// names are from `effect_rows`, which the runtime does not read.
2864#[expect(clippy::cast_possible_truncation)]
2865fn populate_effect_rows(db: &dyn salsa::Database, project: ProjectInput, story: &mut StoryData) {
2866    let inferable = inferable_defs_query(db, project);
2867    if inferable.is_empty() {
2868        return;
2869    }
2870
2871    // Owned name→id lookup so we can both read and append to `name_table`.
2872    let mut name_lookup: BTreeMap<String, u16> = story
2873        .name_table
2874        .iter()
2875        .enumerate()
2876        .map(|(i, s)| (s.clone(), i as u16))
2877        .collect();
2878
2879    // `story.private_defs` is sorted ascending by raw id (codegen hands it
2880    // straight from `Program::private_defs`, itself sorted by
2881    // `brink_ir::lir::lower::build_prelude_decls` — see that fn's doc). Cloned
2882    // once up front (small — one `u64` per `#@private` def, empty for the
2883    // all-public pre-modules world) so the loop below can freely mutate
2884    // `story.name_table` alongside without a field-borrow conflict; membership
2885    // is then a deterministic, order-independent binary search (mirrors
2886    // `brink_runtime::Program::is_private`).
2887    let private_defs = story.private_defs.clone();
2888    let is_private = |def: DefinitionId| {
2889        private_defs
2890            .binary_search_by_key(&def.to_raw(), |d| d.to_raw())
2891            .is_ok()
2892    };
2893
2894    let mut rows: Vec<EffectRowEntry> = Vec::with_capacity(inferable.len());
2895    for &def in inferable {
2896        let Some(row) = effects_query(db, project, DefKey::new(db, def)) else {
2897            continue;
2898        };
2899        let mut calls: Vec<CallAtom> = Vec::with_capacity(row.calls.len());
2900        for name in &row.calls {
2901            let id = if let Some(&id) = name_lookup.get(name) {
2902                id
2903            } else {
2904                let id = story.name_table.len() as u16;
2905                story.name_table.push(name.clone());
2906                name_lookup.insert(name.clone(), id);
2907                id
2908            };
2909            calls.push(CallAtom {
2910                name: NameId(id),
2911                capability: CapabilityParam::Any,
2912                handle_param: None,
2913            });
2914        }
2915        rows.push(EffectRowEntry {
2916            def,
2917            is_entry: !is_private(def),
2918            direct: DirectEffects {
2919                reads: row.reads.iter().copied().collect(),
2920                writes: row.writes.iter().copied().collect(),
2921                calls,
2922                // §6.1 (issue #1680): the wire's `EffectRows` section stays
2923                // one **ground** row per def, so a row still carrying a row
2924                // variable is *closed* to opaque here — the conservative
2925                // direction, and byte-identical to what shipped before holes
2926                // existed. Fork C's ruled encoding (an explicit hole slot,
2927                // filled by §7's token lookup) is the remaining wire half and
2928                // lands with runtime narrowing (#1723); the section is
2929                // section-locally versioned so it can grow without a format
2930                // bump.
2931                opaque: row.is_pessimal(),
2932                // NS-A2 (issue #1108): the three new row dimensions ship
2933                // straight from the analyzer's inferred row.
2934                emits: row.emits,
2935                tags: row.tags,
2936                faults: row.faults,
2937            },
2938            dispatches: Vec::new(),
2939        });
2940    }
2941    // `inferable` is a `BTreeSet`, so `rows` is already sorted by `def`.
2942    story.effect_rows = rows;
2943}
2944
2945/// Reads [`lir_in_closure_query`], not [`lir_query`] (issue #1032 collapse
2946/// ruling): `db.story_data()` — `compileProject`'s artifact path — gates on
2947/// `entry`'s `INCLUDE` closure only, so an error in some other file sharing
2948/// the session db no longer blocks this entry's build. `db.lir_product()`/
2949/// `db.has_errors()` stay on [`lir_query`]/[`has_errors_query`], whole-project
2950/// as before.
2951#[salsa::tracked(returns(ref))]
2952pub(crate) fn story_data_query(db: &dyn salsa::Database, project: ProjectInput) -> CompileProduct {
2953    let lir = lir_in_closure_query(db, project);
2954    let Some(program) = lir.program.as_ref() else {
2955        return CompileProduct {
2956            story: None,
2957            errors: lir.errors.clone(),
2958            warnings: lir.warnings.clone(),
2959        };
2960    };
2961    match brink_codegen_inkb::emit(program) {
2962        Ok(mut story) => {
2963            // T2-3 (#862, `docs/effects-spec.md` §11): first real emission into
2964            // the `EffectRows` section. Codegen has no analyzer access, so the
2965            // rows are attached here — this query is the one canonical codegen
2966            // site (FG-6), so there is exactly one emission point. The rows are
2967            // additive metadata the runtime does not consume yet, so episodes
2968            // stay byte-identical (the linker never reads `effect_rows`).
2969            populate_effect_rows(db, project, &mut story);
2970            CompileProduct {
2971                story: Some(Arc::new(story)),
2972                errors: lir.errors.clone(),
2973                warnings: lir.warnings.clone(),
2974            }
2975        }
2976        Err(err) => {
2977            let mut errors = lir.errors.clone();
2978            errors.push(Diagnostic {
2979                file: project.entry(db).unwrap_or(FileId(0)),
2980                range: rowan::TextRange::default(),
2981                message: format!("{}: {err}", DiagnosticCode::E060.title()),
2982                code: DiagnosticCode::E060,
2983            });
2984            CompileProduct {
2985                story: None,
2986                errors,
2987                warnings: lir.warnings.clone(),
2988            }
2989        }
2990    }
2991}
2992
2993// ─── Shared diagnostic partitioning ──────────────────────────────────
2994
2995/// Per-file inputs to [`partition_diagnostics`].
2996pub struct FileDiagnostics<'a> {
2997    /// The file these diagnostics belong to.
2998    pub file: FileId,
2999    /// The file's source text (for line-directive matching).
3000    pub source: &'a str,
3001    /// The file's parsed suppression directives.
3002    pub suppressions: &'a Suppressions,
3003    /// The file's lowering + syntax diagnostics.
3004    pub lowering: &'a [Diagnostic],
3005}
3006
3007/// Default (empty) suppressions for analysis diagnostics that reference a
3008/// file absent from the input set — mirrors the old driver's
3009/// `unwrap_or_default` behavior.
3010static NO_SUPPRESSIONS: Suppressions = Suppressions {
3011    disable_all: false,
3012    disable_file: false,
3013    line_directives: std::collections::BTreeMap::new(),
3014    allow_scopes: Vec::new(),
3015};
3016
3017/// Collect all diagnostics (lowering + analysis), apply suppressions, and
3018/// partition into `(errors, warnings)`.
3019///
3020/// This is the single implementation behind both `brink-driver`'s
3021/// `collect_diagnostics` and the [`lir_query`] gate — extracted so the query
3022/// path and the legacy driver path cannot drift. `files` must be ordered by
3023/// [`FileId`] (both callers iterate the sorted file set).
3024///
3025/// `disable_all`: whether the entry file carries `brink-disable-all`
3026/// (compiler mode skips analysis diagnostics entirely); pass `false` for LSP
3027/// mode where analysis diagnostics are always included.
3028///
3029/// `types`: the project's TM-3 `types` policy — every diagnostic is
3030/// partitioned by [`brink_analyzer::effective_severity`], not the raw
3031/// [`DiagnosticCode::severity`] default, so `E063` (annotation-vs-inference
3032/// mismatch) partitions as an error under `types = strict` and a warning
3033/// under `types = gradual` (the #640-round ruling) no matter which of this
3034/// function's two callers ([`lir_query`] or `brink-driver`'s
3035/// `collect_diagnostics`) is asking.
3036///
3037/// `lints`: the project's resolved `[lints]` policy (issue #1160), the other
3038/// input [`brink_analyzer::effective_severity`] partitions by — per-code
3039/// `deny`/`warn`/`allow`/`info`/`hint` overrides (issue #1162 added the
3040/// latter two) plus `deny-warnings`.
3041#[must_use]
3042pub fn partition_diagnostics(
3043    files: &[FileDiagnostics<'_>],
3044    analysis_diagnostics: &[Diagnostic],
3045    disable_all: bool,
3046    types: brink_analyzer::TypePolicy,
3047    lints: &brink_analyzer::LintPolicy,
3048) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
3049    let mut errors = Vec::new();
3050    let mut warnings = Vec::new();
3051
3052    let mut partition = |d: Diagnostic| {
3053        if brink_analyzer::effective_severity(d.code, types, lints) == Severity::Error {
3054            errors.push(d);
3055        } else {
3056            warnings.push(d);
3057        }
3058    };
3059
3060    // Per-file lowering diagnostics.
3061    for input in files {
3062        let filtered = apply_suppressions(
3063            input.file,
3064            input.source,
3065            input.lowering.to_vec(),
3066            input.suppressions,
3067        );
3068        for d in filtered {
3069            partition(d);
3070        }
3071    }
3072
3073    // Analysis diagnostics (unless disable_all).
3074    if !disable_all {
3075        let mut by_file: LookupMap<FileId, Vec<Diagnostic>> = LookupMap::new();
3076        for d in analysis_diagnostics {
3077            by_file.entry(d.file).or_default().push(d.clone());
3078        }
3079        // Sort by FileId for determinism.
3080        let mut file_ids: Vec<_> = by_file.keys().copied().collect();
3081        file_ids.sort_by_key(|id| id.0);
3082        for fid in file_ids {
3083            let diags = by_file.remove(&fid).unwrap_or_default();
3084            let (source, suppressions) = files
3085                .iter()
3086                .find(|input| input.file == fid)
3087                .map_or(("", &NO_SUPPRESSIONS), |input| {
3088                    (input.source, input.suppressions)
3089                });
3090            let filtered = apply_suppressions(fid, source, diags, suppressions);
3091            for d in filtered {
3092                partition(d);
3093            }
3094        }
3095    }
3096
3097    (errors, warnings)
3098}
3099
3100// ─── Per-file lowering (ported from the retired `set_file` path) ─────
3101
3102/// Lower one parsed file to HIR + manifest + diagnostics.
3103///
3104/// This is the exact composition the pre-salsa `set_file` performed
3105/// (per-knot lowering + top-level lowering + assembly + syntax errors), kept
3106/// intact so the assembled `HirFile` stays byte-identical to the previous
3107/// pipeline. The manifest is no longer assembled by merging per-knot/
3108/// top-level manifest fragments (B0.4, docs/hir-admission-contract.md
3109/// Q3(b), issue #1173): `project_manifest` derives the whole
3110/// `SymbolManifest` from the fully assembled `HirFile` in one pass, so
3111/// `lower_single_knot`/`lower_top_level` no longer need to produce a
3112/// manifest at all.
3113fn lower_file(file_id: FileId, parse: &Parse) -> LoweredFile {
3114    let tree = parse.tree();
3115
3116    // Per-knot lowering (document order).
3117    let knot_entries: Vec<_> = tree
3118        .knots()
3119        .map(|knot_ast| lower_single_knot(file_id, &knot_ast))
3120        .collect();
3121
3122    // Top-level lowering (everything outside knots).
3123    let (root_content, top_level_knots, top_diagnostics) = lower_top_level(file_id, &tree);
3124
3125    // Assemble a complete `HirFile`: use `lower()` for the declarations
3126    // (variables, constants, lists, externals, includes), then replace knots
3127    // and root content with the per-knot/top-level products above.
3128    let (mut hir, _full_manifest, _full_diag) = lower(file_id, &tree);
3129    hir.knots = knot_entries
3130        .iter()
3131        .filter_map(|(knot, _)| knot.clone())
3132        .collect();
3133    hir.knots.extend(top_level_knots);
3134    hir.root_content = root_content;
3135
3136    let manifest = project_manifest(&hir);
3137
3138    // Merge diagnostics, then surface parser/syntax errors as compile
3139    // diagnostics (`E037`) so malformed source fails the compile.
3140    let mut diagnostics = top_diagnostics;
3141    for (_, knot_diags) in &knot_entries {
3142        diagnostics.extend(knot_diags.iter().cloned());
3143    }
3144    diagnostics.extend(parse.errors().iter().map(|e| Diagnostic {
3145        file: file_id,
3146        range: e.range,
3147        message: e.message.clone(),
3148        code: DiagnosticCode::E037,
3149    }));
3150
3151    // Anonymous-container state lint (`E157`, issue #1674): off/info by
3152    // default, `Warning`-flow-shaped otherwise — folded into `diagnostics`
3153    // (never `admission`) so it flows through `apply_suppressions`/
3154    // `effective_severity` in `partition_diagnostics` exactly like `E151`
3155    // below does for native, and is configurable through `[lints]`/
3156    // `//brink-disable` like any other tier-able diagnostic.
3157    diagnostics.extend(brink_analyzer::check_anonymous_stateful(file_id, &hir));
3158
3159    // B0.3 admission validator (docs/hir-admission-contract.md §4.2, issue
3160    // #1172): a loud, non-suppressible pass wired directly at this seam so
3161    // it runs on every lowering (NF-6, always-on). Kept in its own field —
3162    // never folded into `diagnostics` above, which flows through
3163    // `apply_suppressions` in `partition_diagnostics`.
3164    let file_len = parse.syntax().text_range().end();
3165    let admission = brink_analyzer::validate_admission(file_id, &hir, &manifest, file_len);
3166
3167    LoweredFile {
3168        hir,
3169        manifest,
3170        diagnostics,
3171        admission,
3172    }
3173}
3174
3175/// Lower one native `.brink` file to HIR (B0.10a, the native compile seam,
3176/// issue #1106) — the frontend-specific sibling of [`lower_file`], producing
3177/// the *same* [`LoweredFile`] so everything downstream (analysis, LIR,
3178/// codegen) is byte-for-byte frontend-agnostic.
3179///
3180/// Unlike ink, native lowering is a single whole-file entry point
3181/// (`lower_native::lower`) — there is no per-knot / top-level split to
3182/// reassemble, and it returns its own [`project_manifest`]-derived manifest
3183/// (B0.4) already, so this composes rather than re-deriving. Error-severity
3184/// syntax errors are surfaced as the same non-suppressible `E037` compile
3185/// diagnostic `lower_file` uses; Warning-severity ones (issue #1263 — e.g.
3186/// `<-` outside a choice point) map to `E131` instead, which
3187/// `DiagnosticCode::severity` reports as `Severity::Warning` so it never
3188/// gates `has_errors_query`/`has_errors_in_closure_query`. The B0.3
3189/// admission validator runs at the same seam (NF-6, always-on).
3190///
3191/// `external` is issue #2289's cross-file claiming injection: every
3192/// `@[convention]` handler declared in the project's configured
3193/// conventions module, resolved by [`external_claim_handlers_query`] and
3194/// threaded through by [`lowered_query`] — `None` for [`raw_lowered_query`]
3195/// (project-independent lowering) and for the conventions module's own
3196/// file. See `brink_ir::hir::lower_native::lower_with_conventions`'s own
3197/// doc for what merging it in changes.
3198fn lower_native_file(
3199    file_id: FileId,
3200    parse: &NativeParse,
3201    external: Option<&[brink_ir::ClaimHandlerDecl]>,
3202) -> LoweredFile {
3203    let tree = parse.tree();
3204
3205    let (hir, manifest, mut diagnostics) =
3206        brink_ir::hir::lower_native::lower_with_conventions(file_id, &tree, external);
3207
3208    // Surface parser diagnostics as compile diagnostics, split by severity:
3209    // `Error` becomes the non-suppressible `E037` (malformed source fails
3210    // the compile, mirrors `lower_file`); `Warning` becomes `E131`, which
3211    // is advisory only and must never block compilation.
3212    diagnostics.extend(parse.errors().iter().map(|e| Diagnostic {
3213        file: file_id,
3214        range: e.range,
3215        message: e.message.clone(),
3216        code: match e.severity {
3217            brink_syntax_native::ParseSeverity::Error => DiagnosticCode::E037,
3218            brink_syntax_native::ParseSeverity::Warning => DiagnosticCode::E131,
3219        },
3220    }));
3221
3222    // Native lint: asymmetric choice-branch dead-end (`E151`, issue #1219,
3223    // decision-log 2026-07-22 "Flows end implicitly (native)" item 4) — the
3224    // relocated residual value of ink's retired "ran out of content" error.
3225    // Deliberately folded into `diagnostics`, never `admission`: unlike the
3226    // B0.9 accept-list below, this is `Severity::Warning`-base, on by
3227    // default (not opt-in — see the lint module's own doc), and
3228    // configurable/suppressible through `[lints]`/`//brink-disable` like
3229    // any other tier-able diagnostic — it must flow through
3230    // `apply_suppressions`/`effective_severity` in `partition_diagnostics`,
3231    // which only `diagnostics` does.
3232    diagnostics.extend(brink_analyzer::check_native_choice_dead_end(file_id, &hir));
3233
3234    // Anonymous-container state lint (`E157`, issue #1674) — see `lower_file`'s
3235    // identical wiring comment; the check itself is frontend-agnostic (it
3236    // only reads `Choice::is_sticky`/`label` and `Sequence::kind`/branches,
3237    // both populated the same way by ink and native lowering).
3238    diagnostics.extend(brink_analyzer::check_anonymous_stateful(file_id, &hir));
3239
3240    // B0.3 admission validator (docs/hir-admission-contract.md §4.2, issue
3241    // #1172): the same loud, non-suppressible pass `lower_file` runs, kept in
3242    // its own `LoweredFile` field so it never flows through
3243    // `apply_suppressions`.
3244    let file_len = parse.syntax().text_range().end();
3245    let mut admission = brink_analyzer::validate_admission(file_id, &hir, &manifest, file_len);
3246
3247    // B0.9 native accept-list gate (docs/hir-admission-contract.md §4.4/§5
3248    // Q6, docs/b0-sequencing.md §B0.9, issue #1179): the inverse of the ink
3249    // `dialect_gate` reject-list, and native-only — this is the seam that
3250    // keys it off the producing frontend at the pipeline level (F-I#10):
3251    // `lower_native_file` only ever runs for a `.brink` file, never an
3252    // `.ink` one, so calling this here (and nowhere in `lower_file`) is the
3253    // whole dispatch, with no tag carried on the tree itself. Appended into
3254    // the same non-suppressible `admission` field B0.3 populates above —
3255    // both are loud, always-on checks at this exact seam.
3256    admission.extend(brink_analyzer::validate_native_accept_list(file_id, &hir));
3257
3258    LoweredFile {
3259        hir,
3260        manifest,
3261        diagnostics,
3262        admission,
3263    }
3264}
3265
3266#[cfg(test)]
3267mod tests {
3268    use super::{DefKey, call_graph_query, def_effect_atoms_query, inferable_defs_query};
3269    use crate::db::ProjectDb;
3270
3271    /// Issue #1736 finding (BLOCKING): the parity tests in
3272    /// `crates/internal/brink-db/tests/query_equivalence.rs` compare the two
3273    /// call-graph constructions' *outputs* on a fixture where they provably
3274    /// cannot disagree — `resolve_pending_value_calls` re-records every
3275    /// traced `#fn`/`bind` target as a `direct_calls` edge at its call site,
3276    /// so a fixture that ever calls what it creates can't exercise a
3277    /// `creates_fn_values`-outside-`direct_calls` shape. This test instead
3278    /// asserts the *edge set* directly: for every def, `call_graph_query`'s
3279    /// outgoing edges must cover `EffectAtoms.direct_calls ∪
3280    /// EffectAtoms.creates_fn_values` — the subset property
3281    /// `docs/effects-spec.md` §6.1a documents and
3282    /// `every_fn_value_creation_target_is_also_a_call_graph_edge`
3283    /// (`brink-analyzer`'s `infer::mod` tests) pins from the atom side.
3284    /// Unlike the output-parity tests, this goes red the day #1727 (lambda
3285    /// literals) breaks that subset property, independent of whether any
3286    /// particular fixture's diagnostics happen to still agree.
3287    #[test]
3288    fn call_graph_covers_direct_calls_and_creates_fn_values() {
3289        let mut db = ProjectDb::new();
3290        db.set_file(
3291            "main.ink",
3292            "VAR total = 0\nVAR extra = 0\n\
3293             === function bar(): int ===\n~ total = total + 1\n~ return total\n\
3294             === function baz(): int ===\n~ extra = extra + 100\n~ return extra\n\
3295             === function user(cond: int): int ===\n\
3296             ~ temp f = #fn(bar)\n{cond:\n  ~ f = #fn(baz)\n}\n~ return f()\n"
3297                .to_owned(),
3298        );
3299
3300        let (salsa, project) = db.salsa_and_project();
3301        let graph = call_graph_query(salsa, project);
3302
3303        for &def in inferable_defs_query(salsa, project) {
3304            let atoms = def_effect_atoms_query(salsa, project, DefKey::new(salsa, def));
3305            let outgoing = graph.edges.get(&def).cloned().unwrap_or_default();
3306            for &callee in atoms
3307                .direct_calls
3308                .iter()
3309                .chain(atoms.creates_fn_values.iter())
3310            {
3311                assert!(
3312                    outgoing.contains(&callee),
3313                    "call_graph_query's edges for {def:?} do not cover \
3314                     direct_calls ∪ creates_fn_values: missing edge to \
3315                     {callee:?} (direct_calls={:?}, creates_fn_values={:?}, \
3316                     graph edges={outgoing:?})",
3317                    atoms.direct_calls,
3318                    atoms.creates_fn_values,
3319                );
3320            }
3321        }
3322    }
3323
3324    /// Issue #2368: [`has_recognized_source_extension`]/
3325    /// [`is_native_source_path`] became `pub` so `brink-lsp` could route its
3326    /// own file-watcher classification through them instead of carrying two
3327    /// ad-hoc, case-sensitive copies — pin the case-insensitive contract
3328    /// those callers now depend on directly.
3329    #[test]
3330    fn public_extension_predicates_are_case_insensitive() {
3331        use super::{has_recognized_source_extension, is_native_source_path};
3332
3333        assert!(has_recognized_source_extension("story.ink"));
3334        assert!(has_recognized_source_extension("story.INK"));
3335        assert!(has_recognized_source_extension("main.brink"));
3336        assert!(has_recognized_source_extension("main.BRINK"));
3337        assert!(!has_recognized_source_extension("brink.toml"));
3338        assert!(!has_recognized_source_extension("notes.txt"));
3339
3340        assert!(is_native_source_path("main.brink"));
3341        assert!(is_native_source_path("main.BRINK"));
3342        assert!(!is_native_source_path("story.ink"));
3343        assert!(!is_native_source_path("story.INK"));
3344        assert!(!is_native_source_path("no_extension"));
3345    }
3346}