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