Skip to main content

brink_analyzer/
lib.rs

1//! Cross-file semantic analysis for inkle's ink narrative scripting language.
2//!
3//! The analyzer merges per-file `SymbolManifest`s from `brink-ir` into a
4//! unified `SymbolIndex`, then runs validation passes (name resolution,
5//! duplicate detection, type checking). Both `brink-compiler` and `brink-lsp`
6//! consume the analysis result.
7
8mod admission;
9mod annotations;
10mod anonymous_stateful;
11mod await_purity;
12mod coalesce;
13mod comparator_contract;
14mod compat_deny;
15mod contains_domain;
16mod conventions_confinement;
17mod conversions;
18mod determinism;
19mod dialect_gate;
20mod effects_assertions;
21mod external_check;
22mod fn_values;
23mod harvest;
24mod infer;
25mod manifest;
26mod map_keys;
27mod markup_check;
28mod modules;
29mod native_admission;
30mod native_choice_dead_end;
31mod no_world_reads;
32mod option_conditions;
33mod option_rules;
34mod protocols;
35mod range_refinement;
36mod ref_projection;
37mod resolve;
38mod signature;
39mod strict;
40mod structs;
41mod temp_dominance;
42mod type_resolution;
43mod ufcs;
44mod validate;
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48
49pub use admission::validate_admission;
50pub use annotations::{
51    check as check_annotations, mismatches as annotation_mismatches, resolve as resolve_annotation,
52};
53pub use anonymous_stateful::check as check_anonymous_stateful;
54pub use await_purity::{
55    check as await_purity_diagnostics, condition_callees as await_condition_callees, hir_has_await,
56};
57pub use brink_ir::FileId;
58pub use brink_ir::ResolutionMap;
59pub use brink_project_config::ProjectConfig;
60pub use coalesce::{
61    CoalesceChain, CoalesceShape, CoalesceStep, CoalesceTable, project_has_coalesce,
62    to_lir_lookup as coalesce_lir_lookup,
63};
64pub use comparator_contract::{
65    check as comparator_contract_diagnostics, comparator_callees, hir_has_comparator_site,
66};
67pub use conventions_confinement::{
68    conventions_confinement_diagnostics, conventions_module_diagnostics,
69    conventions_pointer_unresolvable_diagnostics, conventions_unconfigured_diagnostics,
70    is_path_shaped_conventions_pointer,
71};
72pub use dialect_gate::Dialect;
73pub use effects_assertions::{
74    assertion_defs as effects_assertion_defs, check as effects_assertion_diagnostics,
75    effect_atom_name,
76};
77pub use external_check::{
78    ExternalCheckSeverity, InferredType, ResolvedParam, ResolvedType,
79    SemanticTypeDiagnosticSeverity, SymbolMeta, ValueMeta,
80};
81pub use harvest::{
82    CueHarvest, HarvestIndex, HarvestNames, HarvestSite, SpanHarvest, SpanNames, harvest,
83};
84pub use infer::{
85    BodyTypes, CallGraph, CoalesceError, Def, DirectCallArgMismatch, EffectAtoms, EffectRow,
86    FieldAssignMismatch, FnRow, InferenceResult, InferredSig, LambdaAnnotationMismatch,
87    LambdaEscapeSlot, SccGraph, Ty, TypedAssignMismatch, UfcsCallArgs, ValueCallFact,
88    ValueCallKind, assignable, call_edges, coalesce, collect_external_sigs, def_body,
89    def_effect_atoms, effects_project, erase_fn_rows, infer_project, inferable_defs,
90    inferable_defs_from_index, ref_assignable, referenced_globals, scc_graph, solve_scc,
91    solve_scc_effects, unify, unify_all,
92};
93pub use manifest::{ModuleMap, ResolvedModule};
94pub use native_admission::validate_native_accept_list;
95pub use native_choice_dead_end::check as check_native_choice_dead_end;
96pub use no_world_reads::check as no_world_reads_diagnostics;
97pub use protocols::{
98    Protocol, ProtocolImplDecl, check_protocol_impls, check_reserved_names,
99    is_reserved_protocol_name, iterate_element_ty, iterate_val_ty,
100};
101pub use resolve::ImportScope;
102pub use signature::{Sig, local_signature, signature};
103pub use strict::{
104    LintLevel, LintPolicy, TypePolicy, effective_severity, native_strict_only_error,
105    resolve_type_policy,
106};
107pub use structs::{ShapeInfo, ShapeTable, declared_shapes};
108pub use ufcs::{
109    NodeKey, SideTable, UfcsArgMismatch, UfcsTable, UfcsVerdict, project_has_ufcs_call,
110    resolve as resolve_ufcs_calls, to_lir_lookup as ufcs_lir_lookup,
111};
112
113/// Issue #2856: test-only re-export of the resolver's compiler-reserved
114/// name predicates, so `tests/proptest_resolve.rs`'s `completeness`
115/// property can filter by the REAL production name sets rather than a
116/// hand-duplicated copy that would silently drift the moment a name is
117/// added to either list. As of issue #2863, `resolve::is_builtin_function`/
118/// `is_t1b_stdlib_name` are themselves thin delegates to the single
119/// canonical list in `brink_ir::lir` — a third hand-copy in a test file was
120/// rejected for the same reason that delegation replaced the old
121/// hand-synced pair. `#[doc(hidden)]`: not part of this crate's real
122/// public API, only reachable because `mod resolve` is private and
123/// re-exporting is the one way to hand a `pub(crate)`-adjacent item to an
124/// external test crate without also exposing the whole `resolve` module.
125#[doc(hidden)]
126pub mod test_support {
127    pub use crate::resolve::{is_builtin_function, is_t1b_stdlib_name};
128}
129
130use brink_format::DefinitionId;
131use brink_ir::{
132    Diagnostic, DiagnosticCode, DocBlock, HirFile, HostManifest, ManifestExternal, SemanticTypeDef,
133    SymbolIndex, SymbolKind, SymbolManifest,
134};
135use brink_project_config::ConfigWarning;
136
137/// Tooling options for analysis: the registered host manifest and the
138/// severity policy for its external checks. Defaults to no manifest.
139///
140/// `PartialEq`/`Eq` + serde are the #1306 requirement: `AnalysisOptions` is
141/// the resolved-policy slot of the serializable, content-addressed
142/// [`Environment`](../brink_environment/struct.Environment.html) input value,
143/// so the whole `Environment` can be hashed, cached on, and diffed.
144#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
145pub struct AnalysisOptions {
146    /// The registered host-capability manifest, if any.
147    pub host_manifest: Option<HostManifest>,
148    /// Severity policy for manifest-driven external diagnostics.
149    pub external_check: ExternalCheckSeverity,
150    /// Severity policy for unknown-semantic-type diagnostics (`E040`).
151    /// Defaults to `Tolerant` (the #339/#527 default-tolerant path); raise to
152    /// `Error` to re-enable strict checking with no manifest registered
153    /// (#532).
154    pub semantic_type_check: SemanticTypeDiagnosticSeverity,
155    /// T1b compiler dialect: gates brink-extension syntax (blocks, sigil
156    /// literals, indexing). Defaults to `StrictInk` — an authoring-time/
157    /// tooling input only, mount-time (CLI flag) in T1b-1; project-file
158    /// config is out of scope (docs/t1b-surface-spec.md §1, #368 precedent).
159    pub dialect: Dialect,
160    /// TM-3 typed-mode policy (docs/typed-mode-spec.md §1). `None` means
161    /// "the project never said" — the effective policy is then keyed on the
162    /// dialect via [`resolve_type_policy`] (issue #1127, ruled 2026-07-19):
163    /// `Brink` → `Strict`, `StrictInk` → `Gradual` (forever — the oracle
164    /// corpus is anchored to it). `Some(_)` is an explicit choice (CLI flag,
165    /// `brink.toml`, editor API) and always wins. Read the effective policy
166    /// via [`AnalysisOptions::type_policy`], never this field directly.
167    ///
168    /// `Strict` requires `dialect = Brink` (a config error otherwise,
169    /// `E064`) and turns on `Unknown`/`Conflicted`-escape errors, the
170    /// boundary annotation-firewall exemption, and auto-wires `E063`
171    /// (annotation-vs-inference mismatch) into production. Authoring-time/
172    /// tooling input only — never embedded in `.inkb`, mirroring `dialect`.
173    pub types: Option<TypePolicy>,
174    /// Resolved `[lints]` policy (issue #1160): per-code severity overrides
175    /// plus `deny-warnings`. `LintPolicy::default()` (empty overrides,
176    /// `deny_warnings: false`) is a no-op — every diagnostic keeps its
177    /// [`brink_ir::DiagnosticCode::severity`] default, byte-identical to
178    /// pre-#1160 behavior. Resolved once, here, via
179    /// [`AnalysisOptions::apply_project_config`] — read through
180    /// [`effective_severity`], never this field directly.
181    pub lints: LintPolicy,
182    /// D6 (`docs/debugger-spec.md` §1.2/§2, issue #3184): emit the
183    /// `SectionKind::DebugInfo` bytecode-offset → source-range section.
184    /// Mount-time/authoring-time input only, mirroring `dialect`/`types` —
185    /// **never embedded in `.inkb` when `false`** (the default): a
186    /// release-exported story never carries this flag's effect, per the
187    /// ship-policy ruling that keeps every release artifact byte-identical
188    /// regardless of this field. `true` for a dev/studio compile or the
189    /// CLI's explicit `brink compile --debug-info` flag.
190    pub emit_debug_info: bool,
191    /// `brink.toml`'s `[project] conventions` pointer (docs/prose-dialect-spec.md
192    /// §3.4), if set: a built-in preset name or a project-relative path to
193    /// the project's conventions module. `None` means no conventions
194    /// module is configured. Consumed by the confinement check (issue
195    /// #1844, `E169`) that requires pattern-claiming `@[convention(claims =
196    /// "…", order = N)]` handlers to live in the one file this names — resolving the
197    /// pointer against real project/module identity needs `brink-db`'s
198    /// path machinery, so this crate only carries the raw string through,
199    /// the same posture [`Self::types`]/[`Self::dialect`] have toward their
200    /// own project-file-authored values. Authoring-time/tooling input
201    /// only, mirroring every other `AnalysisOptions` field — never embedded
202    /// in `.inkb`.
203    ///
204    /// Renamed from `elements` by issue #2180 (the key predates the split
205    /// of `@[element]` from `@[convention]`, docs/decision-log.md's
206    /// 2026-08-03 ruling). `brink-project-config::ProjectConfig` still
207    /// accepts the old `[project] elements` spelling as a deprecated,
208    /// warning-emitting alias — see [`ProjectConfig::conventions`]'s own
209    /// doc comment — but by the time a `ProjectConfig` reaches
210    /// [`Self::apply_project_config`] the two keys have already been
211    /// reconciled into that one field, so there is nothing alias-specific
212    /// for this crate to do.
213    ///
214    /// A preset-shaped value (issue #1874) is validated by
215    /// [`Self::apply_project_config`] against the closed built-in-preset
216    /// set *before* it lands here — an unrecognized bare name never
217    /// reaches this field (a `ConfigWarning` is returned instead), the same
218    /// "invalid entries never make it into the resolved policy" posture
219    /// `[lints]`'s [`validate_lint_code`] gate uses. A path-shaped value is
220    /// never rejected by that check (see
221    /// [`is_path_shaped_conventions_pointer`]).
222    pub conventions: Option<String>,
223}
224
225impl AnalysisOptions {
226    /// The effective `types` policy for this options set — the one
227    /// resolution seam (issue #1127): an explicit [`Self::types`] wins;
228    /// otherwise the dialect-keyed default from [`resolve_type_policy`].
229    #[must_use]
230    pub fn type_policy(&self) -> TypePolicy {
231        resolve_type_policy(self.dialect, self.types)
232    }
233
234    /// Apply a parsed `brink.toml` [`ProjectConfig`] onto these options,
235    /// honoring the #1005 precedence rule: **explicit API calls / CLI flags
236    /// override the file.** `dialect_overridden`/`types_overridden` tell this
237    /// whether the caller already has an explicit value for that field (a CLI
238    /// flag the user actually passed, an editor session's own
239    /// `set_language_dialect`/`set_type_policy` call, …) — when true, that
240    /// field is left untouched regardless of what the file says. The file only
241    /// ever supplies a *default*.
242    ///
243    /// For `dialect`/`types`, fields the file doesn't set are also left
244    /// untouched, so `self` should already carry whatever it would have
245    /// without a config file (typically [`AnalysisOptions::default()`]).
246    /// `lints` does not follow this rule — see below.
247    ///
248    /// `lints`/`deny-warnings` (issue #1160) have their own override
249    /// mechanism — [`Self::apply_lint_overrides`], the CLI-flag/editor-API
250    /// tier used by `brink compile`, `brink ide`, `brink-lsp`'s
251    /// `initializationOptions`, and the wasm `EditorSession` — but unlike
252    /// `dialect`/`types` that tier is applied as a *second, separate call*
253    /// rather than an `_overridden` parameter here, so this call always
254    /// resolves `[lints]` from `config` first: the file's `[lints]` table
255    /// is the sole source of truth for what this call sets on
256    /// [`AnalysisOptions::lints`], and it **replaces** `self.lints` wholesale
257    /// with the policy resolved from
258    /// `config` (a code missing from `config.lints`, or an absent `[lints]`
259    /// table entirely, resolves to no override for that code; a missing
260    /// `deny-warnings` resolves to `false`) rather than merging `config`'s
261    /// entries key-by-key into whatever `self.lints` already held.
262    ///
263    /// This differs from `dialect`/`types`' "unset means untouched" rule
264    /// above deliberately (issue #1397): those fields are one-shot,
265    /// CLI-flag-style choices where "unset" genuinely means "the file
266    /// doesn't have an opinion, leave whatever's already resolved alone".
267    /// `[lints]`, in contrast, is a *table* a long-lived caller (the editor
268    /// session re-applies `brink.toml` on every change; see
269    /// `brink-web`'s `EditorSession::apply_parsed_config`) re-resolves from
270    /// scratch each time it calls this — merge semantics meant a code
271    /// deleted from `brink.toml` (or an editor-supplied config) left its
272    /// previously-applied override permanently stuck, since nothing ever
273    /// removed it from [`AnalysisOptions::lints`]. Replacing wholesale is
274    /// safe for every caller: `apply_lint_overrides` (the CLI/API tier) is
275    /// always documented to run *after* this, on top of whatever it just
276    /// resolved, and no caller relies on this call preserving lint state
277    /// this one didn't itself just set — `self.lints` at call time is
278    /// always a fresh [`AnalysisOptions::default()`] (CLI, LSP, `brink ide`,
279    /// the editor session's own throwaway `AnalysisOptions`, or `bevy-brink`
280    /// via `brink-environment::resolve_options`); see the Invariant section
281    /// below for why every caller constructs fresh rather than reusing a
282    /// prior call's output.
283    ///
284    /// `brink-project-config` doesn't know the real `DiagnosticCode` set
285    /// (kept dependency-free, #1234), so it accepts any string key under
286    /// `[lints]` without validation. **This is the point that resolves a
287    /// key against the real code set** (this crate owns `DiagnosticCode`)
288    /// and decides which codes are actually overridable: a key that isn't a
289    /// real code, or names a code whose default severity IS `Error`
290    /// (never reachable through [`effective_severity`]'s hard-error
291    /// exemption anyway — see its doc comment), is *not* included in the
292    /// replaced [`AnalysisOptions::lints`] and instead earns a returned
293    /// [`ConfigWarning`], the same "warn, never silently drop" channel
294    /// unknown top-level/`[project]` keys already use. Every call site that
295    /// already loops over `brink_project_config::parse_str`'s own warnings
296    /// should loop over these the same way. (Issue #3447: `[fix]` keys get
297    /// the same code-set gate below, via `validate_fix_code` — `[fix]` has
298    /// no overridability concept of its own, so that gate only rejects
299    /// codes the compiler has never heard of.)
300    ///
301    /// Lives here rather than in `brink-project-config` so that crate needs no
302    /// workspace dependencies and can publish standalone (#1234) — it owns the
303    /// policy *types*, this crate owns applying them to its own options.
304    ///
305    /// ## Invariant: `self` must be fresh
306    ///
307    /// `[lints]` would be safe to apply onto a `self` mutated by a prior
308    /// call — full replace, not merge, is exactly what makes that safe (see
309    /// above). `dialect`/`types` are **not**: their "unset means untouched"
310    /// rule means whatever `self.dialect`/`self.types` already held before
311    /// this call would silently survive untouched if `config` (and the
312    /// `_overridden` flags) don't set them. No caller relies on that today
313    /// — there is no exception. Every production call site starts each call
314    /// from a **freshly-constructed** [`AnalysisOptions::default()`]:
315    /// `brink-cli`'s `brink ide`; `brink-lsp`'s `resolve_language_options`
316    /// (called fresh both from `initialize` *and* repeatedly from
317    /// `Backend::reload_brink_toml` on every `brink.toml` edit — the
318    /// repeat-call case this invariant is actually about); `brink-web`'s
319    /// `EditorSession::apply_parsed_config`, via its own throwaway
320    /// `AnalysisOptions::default()` (it never reuses a mutated `self` —
321    /// `dialect`/`types` are applied directly to the session elsewhere, not
322    /// through this method); and — the one every mount funnels through —
323    /// `brink-environment::resolve_options`, called fresh inside every
324    /// [`Project::load`](../brink_environment/struct.Project.html#method.load)).
325    /// `bevy-brink` never calls this method directly; it reaches it solely
326    /// through `resolve_options`. Reusing a mutated `self` would let a
327    /// later, unrelated compile silently inherit an earlier one's resolved
328    /// `dialect`/`types` whenever its own `brink.toml` doesn't set them,
329    /// breaking the determinism a caller doing repeat compiles (e.g.
330    /// `bevy-brink`'s `InkLoader` on every asset (re)load) depends on.
331    /// Nothing in this method's signature enforces starting fresh — it
332    /// takes `&mut self`, so it can't tell "fresh" apart from "reused".
333    /// This is a documented invariant rather than a compiler-checked one
334    /// because enforcing it in the type (e.g. an associated constructor
335    /// like `fn from_project_config(config, dialect_overridden,
336    /// types_overridden) -> (Self, Vec<ConfigWarning>)` that owns
337    /// construction) would mean touching all four production call sites
338    /// plus the ~15 `brink-analyzer` unit tests that call
339    /// `apply_project_config` directly on an already-constructed `options`
340    /// — not because any caller needs `&mut self` reuse; see
341    /// `resolve_options`/`repeat_compiles_do_not_leak_options_across_project_load_calls`
342    /// in `brink-environment` for where the fresh-start invariant is
343    /// actually pinned end-to-end.
344    pub fn apply_project_config(
345        &mut self,
346        config: &ProjectConfig,
347        dialect_overridden: bool,
348        types_overridden: bool,
349    ) -> Vec<ConfigWarning> {
350        if !dialect_overridden && let Some(dialect) = config.dialect {
351            self.dialect = dialect;
352        }
353        if !types_overridden && let Some(types) = config.types {
354            self.types = Some(types);
355        }
356        // `conventions` (issue #1844; renamed from `elements` by #2180)
357        // follows `dialect`/`types`' "unset means untouched" rule — no
358        // `_overridden` tier exists for it yet (no caller today sets it any
359        // way but through this file), so there is nothing for an explicit
360        // override to win over.
361        let mut warnings = Vec::new();
362        if let Some(pointer) = config.conventions.as_deref() {
363            // Issue #1874: a path-shaped pointer is never validated here —
364            // rejecting a valid project-relative custom-module path would
365            // break the exact case #1844's confinement rule (`E169`) is
366            // built around. Only a bare, preset-shaped name is checked
367            // against the closed built-in-preset set.
368            if is_path_shaped_conventions_pointer(pointer) {
369                self.conventions = Some(pointer.to_owned());
370            } else {
371                match validate_conventions_preset(pointer, BUILTIN_CONVENTION_PRESETS) {
372                    Ok(()) => {
373                        self.conventions = Some(pointer.to_owned());
374                        // Issue #1720 review finding: recognizing a preset
375                        // name in `BUILTIN_CONVENTION_PRESETS` is
376                        // validation-only — nothing downstream injects its
377                        // handlers into a project's dispatch table yet.
378                        // #2080 (landed) mounts the preset's source into
379                        // every compiled `Environment`'s manifest, but a
380                        // project's own `use` still cannot resolve into it
381                        // (needs #1582's pub marker + #2167's confinement),
382                        // and `fn conventions()` registration/comptime
383                        // (#1840) hasn't landed either.
384                        // Silently accepting the name here would leave an
385                        // author writing `conventions = "screenplay"` with
386                        // zero diagnostics and zero behavior — the exact
387                        // "validate against the real set, never silently
388                        // drop an unactionable value" failure rule 19h
389                        // targets, just inverted (a *recognized* value that
390                        // does nothing, not an unrecognized one). So a name
391                        // in `BUILTIN_CONVENTION_PRESETS` but not yet in
392                        // `INJECTABLE_CONVENTION_PRESETS` still warns, on
393                        // the same "warn, never silently drop" channel,
394                        // until #2080/#1840 land and this can become a real
395                        // no-op.
396                        if !INJECTABLE_CONVENTION_PRESETS.contains(&pointer) {
397                            warnings.push(ConfigWarning(format!(
398                                "[project] conventions = \"{pointer}\" names a recognized \
399                                 built-in preset, not injectable yet — no conventions \
400                                 applied (#2080/#1840)"
401                            )));
402                        }
403                    }
404                    Err(warning) => warnings.push(warning),
405                }
406            }
407        }
408        let mut overrides = BTreeMap::new();
409        for (code, level) in &config.lints {
410            match validate_lint_code(code) {
411                Ok(()) => {
412                    overrides.insert(code.clone(), *level);
413                }
414                Err(warning) => warnings.push(warning),
415            }
416        }
417        // `[fix]` (issue #3447): `config.fix` is a `BTreeMap`, so this walk
418        // is deterministic. There is nothing to store on `self` — no
419        // `AnalysisOptions` field consumes a fix policy (the real consumers,
420        // `brink-web`'s `EditorSession::fix_policy` and `brink-cli`'s `fix`
421        // subcommand, read `ProjectConfig::fix`/`effective_fix_policy`
422        // directly) — this loop exists purely to run every configured code
423        // through the same "resolve against the real code set, warn rather
424        // than silently drop" gate `[lints]` gets above, closing the gap
425        // `docs/autofix-spec.md` §6.1 names: an unrecognized `[fix]` code
426        // was accepted by `brink-project-config` (dependency-free of
427        // `DiagnosticCode` by design, #1234) and never validated anywhere
428        // downstream. Landing the check here — rather than inside
429        // `EditorSession::fix_policy`/`brink-cli`'s `fix` command — means
430        // both `AnalysisOptions::apply_project_config`'s callers pick it up
431        // for free: `brink_environment::resolve_options` (the compile road)
432        // and `brink-web`'s `apply_parsed_config` (the studio/db road,
433        // feeding `onProjectConfigWarnings`), the same two roads `[lints]`'s
434        // own unrecognized-code warning already reaches.
435        for code in config.fix.keys() {
436            if let Err(warning) = validate_fix_code(code) {
437                warnings.push(warning);
438            }
439        }
440        // Replace, not merge (issue #1397) — see the doc comment above for
441        // why: a code (or `deny-warnings`) omitted from `config` must
442        // resolve to its base severity, not whatever a prior call left in
443        // place.
444        self.lints.overrides = overrides;
445        self.lints.deny_warnings = config.deny_warnings.unwrap_or(false);
446        warnings
447    }
448
449    /// Apply explicit CLI/API per-code lint-level overrides on top of
450    /// whatever [`Self::apply_project_config`] already resolved (the
451    /// default, then a discovered `brink.toml`) — the top of the `CLI/API >
452    /// file > default` precedence stack (#1005), completing the "natural
453    /// follow-up" [`Self::apply_project_config`]'s own doc comment flags:
454    /// `[lints]`/`deny-warnings` previously had no override source at all
455    /// (issue #1373). Call this *after* `apply_project_config`, if the
456    /// caller applies both — an entry here replaces whatever the file set
457    /// for the same code, and `deny_warnings: Some(_)` replaces the file's
458    /// `deny-warnings` wholesale, mirroring `dialect`/`types`' own
459    /// `*_overridden` handling above.
460    ///
461    /// Runs every code through the exact same [`validate_lint_code`] gate
462    /// `apply_project_config`'s `[lints]` handling uses — a key that isn't a
463    /// real [`DiagnosticCode`], or names a code whose *default* severity
464    /// IS `Error`, is never merged into [`Self::lints`] and instead
465    /// earns a returned [`ConfigWarning`] on the same "warn, never silently
466    /// drop" channel (#1160's overridability constraint applies identically
467    /// to a CLI/API-set code as to a `brink.toml`-set one).
468    pub fn apply_lint_overrides(
469        &mut self,
470        overrides: &BTreeMap<String, LintLevel>,
471        deny_warnings: Option<bool>,
472    ) -> Vec<ConfigWarning> {
473        let mut warnings = Vec::new();
474        for (code, level) in overrides {
475            match validate_lint_code(code) {
476                Ok(()) => {
477                    self.lints.overrides.insert(code.clone(), *level);
478                }
479                Err(warning) => warnings.push(warning),
480            }
481        }
482        if let Some(deny_warnings) = deny_warnings {
483            self.lints.deny_warnings = deny_warnings;
484        }
485        warnings
486    }
487}
488
489/// Validate `code` against the real [`DiagnosticCode`] set (#1160's "resolve
490/// a key against the real code set" channel, shared by
491/// [`AnalysisOptions::apply_project_config`]'s `[lints]` handling and
492/// [`AnalysisOptions::apply_lint_overrides`] — #1373): `Ok(())` if `code` is
493/// overridable, otherwise the same-shaped [`ConfigWarning`] both call sites
494/// surface, keeping the wording byte-identical regardless of which tier the
495/// code came from.
496///
497/// Overridable is [`DiagnosticCode::is_overridable`] itself now, not a
498/// restated copy of it — originally this checked "not `Error`-by-default"
499/// directly (#1160's "conservative overridable set": a hard error can never
500/// be downgraded by `[lints]`, so it is never even looked up). Issue #1674
501/// widened the codes this accepts past `Warning`-base to `Info`/`Hint`-base
502/// too (today, only `E157`): the exemption `effective_severity` actually
503/// enforces is about `Error`, never reachable through `[lints]` regardless of
504/// what this function allows, not about `Warning` being the only overridable
505/// base. Issue #3373 widens it again, past "base severity" entirely: the
506/// **compat-deny** tier keeps `severity() == Error` (inklecate rejects the
507/// program, so brink does too by default) while still being overridable —
508/// deferring to [`DiagnosticCode::is_overridable`] rather than re-deriving
509/// "not Error" here is what lets that tier's members through without this
510/// function drifting out of sync with the predicate `effective_severity`
511/// also now consults.
512fn validate_lint_code(code: &str) -> Result<(), ConfigWarning> {
513    match DiagnosticCode::from_str_code(code) {
514        Some(parsed) if parsed.is_overridable() => Ok(()),
515        Some(_) => Err(ConfigWarning(format!(
516            "[lints] `{code}` is not overridable (its default severity is `Error`); ignored"
517        ))),
518        None => Err(ConfigWarning(format!(
519            "[lints] `{code}` is not a recognized diagnostic code; ignored"
520        ))),
521    }
522}
523
524/// Validate a `[fix]` table code against the real [`DiagnosticCode`] set
525/// (issue #3447) — the `[fix]`-table sibling of [`validate_lint_code`],
526/// same "warn, never silently drop" [`ConfigWarning`] channel and same
527/// wording shape (`docs/autofix-spec.md` §6.1 names this exact function as
528/// the still-owed follow-up to #3419's *value*-only validation).
529///
530/// Unlike [`validate_lint_code`] there is no [`DiagnosticCode::is_overridable`]
531/// gate here: `[lints]` restricts *which* codes may have their severity
532/// downgraded (a hard error can't be silenced), but `[fix]` only ever
533/// changes whether an already-registered fixer is offered/batched — it
534/// never touches severity, so a code's overridability says nothing about
535/// whether it may carry a fix policy. Every real code is eligible,
536/// including an `Error`-default one: `brink_ide::fix::policy::FixPolicy`
537/// (the type this resolves into, per `[fix]`'s own #3418 consumer) is the
538/// layer that actually decides whether a fixer exists and which tier it
539/// is — this function only rejects a code the compiler has never heard of.
540fn validate_fix_code(code: &str) -> Result<(), ConfigWarning> {
541    match DiagnosticCode::from_str_code(code) {
542        Some(_) => Ok(()),
543        None => Err(ConfigWarning(format!(
544            "[fix] `{code}` is not a recognized diagnostic code; ignored"
545        ))),
546    }
547}
548
549/// The closed set of built-in `[project] conventions` preset names
550/// (docs/prose-dialect-spec.md §3.4), checked against a bare, preset-shaped
551/// `conventions` value (issue #1874, the remainder of #1844's item 5).
552///
553/// **`"screenplay"` added by issue #1720** (the built-in screenplay
554/// preset), the moment it shipped its authored source at
555/// `std/conventions/screenplay.brink` — exactly what this doc comment
556/// asked the landing PR to do. This changes ONLY the validation verdict
557/// (a project spelling `conventions = "screenplay"` no longer gets the
558/// "no built-in preset has shipped yet" warning) — it does **not** mean
559/// the pointer is consumed downstream yet. Nothing reads `self.conventions`
560/// to actually inject the preset's handlers into a project's dispatch
561/// table: `std::conventions::screenplay` has no real `use`-importable
562/// module path. #2080 (landed) mounts the preset's source into every
563/// compiled `Environment`'s manifest, but a project's own `use` still
564/// cannot resolve into it — that needs #1582's pub marker and #2167's
565/// closure-scoped confinement, neither built yet — and `fn conventions()`
566/// registration/comptime (#1840) hasn't landed either. `brink-db`'s
567/// `conventions_confinement_diagnostics_query` still explicitly skips the
568/// `E169` confinement check for a preset-shaped pointer, per its own doc,
569/// unchanged by this addition.
570///
571/// **Hardcoded, deliberately, not data-driven** — and this should change
572/// once it can. A truly data-driven registry would read the actual
573/// `std::conventions::*` module set the project's comptime evaluator
574/// produces, but that evaluator-to-project-lowering seam is exactly what
575/// issue #1863 tracks as still missing ("no project-level injection point
576/// for an evaluated conventions registry"); this crate has no comptime
577/// evaluator and no project database to ask. A hardcoded literal is the
578/// only thing buildable today without reaching past this issue's fence
579/// into #1863's. Once #1863 lands, this list should be replaced by a query
580/// against that registry rather than grown further as a literal — a
581/// hardcoded set drifting out of sync with the real module set is exactly
582/// the kind of silent staleness this closed-set check exists to prevent.
583///
584/// Renamed from `BUILTIN_ELEMENT_PRESETS` by issue #2180 alongside the
585/// `[project] elements` → `[project] conventions` key rename.
586const BUILTIN_CONVENTION_PRESETS: &[&str] = &["screenplay"];
587
588/// The subset of [`BUILTIN_CONVENTION_PRESETS`] whose handlers are actually
589/// injected into a project's dispatch table today (issue #1720 review
590/// finding). Empty: no preset injection mechanism exists yet — #2080's
591/// `std::`-namespaced module resolution and #1840's `fn conventions()`
592/// registration/comptime are both still open. A name present in
593/// [`BUILTIN_CONVENTION_PRESETS`] but absent here is recognized (a
594/// correctly spelled built-in preset name, not an unrecognized-name error)
595/// but not yet reachable — [`AnalysisOptions::apply_project_config`] warns
596/// about that gap explicitly on the same channel rather than silently
597/// no-opping, so an author never sees zero diagnostics *and* zero
598/// behavior for the same value. Move a name from here to
599/// `BUILTIN_CONVENTION_PRESETS`'s sibling list into this one the moment
600/// its handlers are actually wired in.
601const INJECTABLE_CONVENTION_PRESETS: &[&str] = &[];
602
603/// Validate a preset-shaped `[project] conventions` pointer (already
604/// established by the caller, via [`is_path_shaped_conventions_pointer`],
605/// to carry no path separator and no `.brink` extension) against
606/// `presets`, the closed built-in-preset-name set — `Ok(())` if `pointer`
607/// is a recognized name, otherwise a [`ConfigWarning`] naming it, the same
608/// "warn, never silently drop" channel [`validate_lint_code`] uses for an
609/// unknown `[lints]` code.
610///
611/// Takes `presets` as a parameter, rather than reading
612/// [`BUILTIN_CONVENTION_PRESETS`] directly, so the comparison logic itself
613/// is testable against a non-empty registry without needing a real
614/// built-in preset to exist yet (see this module's tests) — production
615/// code always calls this with the real constant.
616fn validate_conventions_preset(pointer: &str, presets: &[&str]) -> Result<(), ConfigWarning> {
617    if presets.contains(&pointer) {
618        return Ok(());
619    }
620    if presets.is_empty() {
621        // No longer reachable in production since #1720 shipped
622        // `"screenplay"` into `BUILTIN_CONVENTION_PRESETS` — kept as its
623        // own arm (rather than folded into the `else` below) because the
624        // message is meaningfully different: an empty registry means
625        // *every* preset-shaped value, including a correctly-spelled one,
626        // is unrecognized because nothing has shipped, which is not a typo
627        // on the author's part. Still exercised directly by this module's
628        // own tests (`validate_conventions_preset`'s `presets` parameter,
629        // not the real constant) to pin that distinction.
630        Err(ConfigWarning(format!(
631            "[project] conventions = \"{pointer}\" names a built-in preset, but no built-in \
632             preset has shipped yet (#1720); use a project-relative path to a `.brink` \
633             conventions module instead"
634        )))
635    } else {
636        Err(ConfigWarning(format!(
637            "[project] conventions = \"{pointer}\" is not a recognized built-in preset name and \
638             is not a project-relative path to a `.brink` conventions module (no `/`, `\\`, or \
639             `.brink` extension); ignored"
640        )))
641    }
642}
643
644/// The output of cross-file semantic analysis.
645///
646/// `PartialEq` supports early-cutoff backdating when the result is produced
647/// by the salsa `analysis` query in `brink-db`.
648#[derive(Debug, Clone, PartialEq)]
649pub struct AnalysisResult {
650    /// The unified symbol index.
651    pub index: Arc<SymbolIndex>,
652    /// Resolved references: maps source range → definition id.
653    pub resolutions: ResolutionMap,
654    /// Diagnostics produced during analysis (duplicate definitions, unresolved refs, etc.).
655    pub diagnostics: Vec<Diagnostic>,
656    /// Per-symbol metadata enrichment (docs, resolved types, initializer
657    /// values), keyed by `DefinitionId`. Empty when no host manifest is
658    /// registered and no inline `///` docs are present.
659    pub symbol_meta: BTreeMap<DefinitionId, SymbolMeta>,
660}
661
662/// Build the project-wide declaration index from per-file symbol manifests.
663///
664/// Query-shaped seam for the scripting substrate (spec §4, layer 2 —
665/// `symbol_index()`): a pure function of the per-file manifests, returning
666/// the merged index plus indexing diagnostics (duplicate definitions,
667/// built-in shadowing). Declarations only — no body analysis happens here,
668/// though the index does include body-declared locals (params/temps), which
669/// hierarchical resolution needs.
670#[must_use]
671pub fn symbol_index(files: &[(FileId, &SymbolManifest)]) -> (Arc<SymbolIndex>, Vec<Diagnostic>) {
672    let (index, diagnostics) = manifest::merge_manifests(files);
673    (Arc::new(index), diagnostics)
674}
675
676/// The merged symbol index with `DefinitionId`s qualified by each file's
677/// **declared** module (M-1, docs/modules-spec.md §5).
678///
679/// Identical to [`symbol_index`] for undeclared stem-modules (the entire
680/// pre-modules corpus) — byte-identical `DefinitionId`s — and qualifies
681/// names by module only for files carrying `#@module`. `brink-db`'s
682/// `symbol_index_query` builds the [`ModuleMap`] from file stems,
683/// `#@module` declarations, and the INCLUDE graph, then calls this.
684///
685/// `dialect` gates the M-2c cross-declared-module duplicate escalation
686/// (issue #784): see [`manifest::merge_manifests_with_modules`].
687///
688/// `is_native` (issue #1562 review finding) widens that same gate past the
689/// ink-only `dialect` axis for a native `.brink` project, exactly as
690/// [`strict_diagnostics`]'s own `is_native` widens `E064`'s dialect check —
691/// see [`manifest::merge_manifests_with_modules`]'s doc. Callers with no
692/// `Language` classification of their own pass `false`, unchanged from
693/// before this parameter existed.
694#[must_use]
695pub fn symbol_index_with_modules(
696    files: &[(FileId, &SymbolManifest)],
697    modules: &ModuleMap,
698    dialect: Dialect,
699    is_native: bool,
700) -> (Arc<SymbolIndex>, Vec<Diagnostic>) {
701    let (index, diagnostics) =
702        manifest::merge_manifests_with_modules(files, modules, dialect, is_native);
703    (Arc::new(index), diagnostics)
704}
705
706/// Resolve one file's references against the project-wide symbol index.
707///
708/// Query-shaped seam for the scripting substrate (spec §4, layer 2 —
709/// `resolve(FileId)`): a pure function of the symbol index and this file's
710/// own manifest. It never reads another file's content, so a body edit in
711/// file B can only affect file A's resolutions by way of the shared index.
712#[must_use]
713pub fn resolve(
714    file: FileId,
715    manifest: &SymbolManifest,
716    index: &SymbolIndex,
717    scope: &ImportScope,
718) -> (Arc<ResolutionMap>, Vec<Diagnostic>) {
719    let (map, diagnostics) = resolve::resolve_file(index, scope, file, manifest);
720    (Arc::new(map), diagnostics)
721}
722
723/// Run cross-file semantic analysis with default options (no host manifest).
724///
725/// **Test-fixture surface** (option A total, ruled 2026-08-24): production
726/// analysis is `brink-db`'s salsa composition (`analysis_query` /
727/// `subset_analysis_query`) — every IDE/LSP/editor/compile path routes
728/// there. This wrapper exists for unit tests that lower a fixture by hand
729/// and want the analyzer's own composition over it, module-blind and on
730/// the ink arm; it deliberately has NO `ModuleMap`/`is_native` parameters
731/// — the two degrees of freedom the retired `analyze_with_modules`
732/// monolith let callers hold wrong (the #1347/#1526/#1553/#1358
733/// divergence class) no longer exist to be misheld.
734pub fn analyze(files: &[(FileId, &HirFile, &SymbolManifest)]) -> AnalysisResult {
735    analyze_with_options(files, &AnalysisOptions::default())
736}
737
738/// [`analyze`] with explicit tooling options — same test-fixture status and
739/// same deliberately absent module/nativeness parameters (see [`analyze`]).
740///
741/// **Module-blind** (issue #1526): with no [`ModuleMap`] there is nothing to
742/// qualify identity by, so every symbol hashes by bare name (`module: None`)
743/// and the import scope reads each file's own declared `#@module` only.
744/// `DefinitionId`s minted here for declared-module files do not match
745/// `brink-db`'s — never use them as keys into db per-def queries.
746pub fn analyze_with_options(
747    files: &[(FileId, &HirFile, &SymbolManifest)],
748    opts: &AnalysisOptions,
749) -> AnalysisResult {
750    let modules = ModuleMap::new();
751    let manifest_inputs: Vec<(FileId, &SymbolManifest)> = files
752        .iter()
753        .map(|&(id, _hir, manifest)| (id, manifest))
754        .collect();
755    // Ink arm throughout (no `Language` classification exists at this layer
756    // — issues #1348/#1562); native-arm behavior is exclusively brink-db's.
757    let (index, mut diagnostics) =
758        symbol_index_with_modules(&manifest_inputs, &modules, opts.dialect, false);
759    let mut resolutions = ResolutionMap::new();
760    let mut scopes: BTreeMap<FileId, ImportScope> = BTreeMap::new();
761    for &(file_id, hir, manifest) in files {
762        let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
763        let (file_map, file_diags) = resolve(file_id, manifest, &index, &scope);
764        resolutions.extend(Arc::unwrap_or_clone(file_map));
765        diagnostics.extend(file_diags);
766        scopes.insert(file_id, scope);
767    }
768    let hir_files: Vec<(FileId, &HirFile)> = files.iter().map(|&(id, hir, _)| (id, hir)).collect();
769    diagnostics.extend(conventions_confinement_diagnostics(
770        &hir_files,
771        &modules,
772        opts.conventions.as_deref(),
773    ));
774    finish_analysis(
775        files,
776        index,
777        resolutions,
778        diagnostics,
779        opts,
780        false,
781        None,
782        &scopes,
783    )
784}
785
786/// Per-file diagnostic contributors (issue #632 / FG-3,
787/// `docs/fine-grained-salsa-proposal.md` §1 item 4): structural validation,
788/// the dialect gate, and (brink dialect only) annotation-*content* checks —
789/// the three passes `finish_analysis` used to run as whole-project loops
790/// (`validate::validate`/`dialect_gate::check`/`annotations::check`, each
791/// internally iterating every file) even though none of them actually reads
792/// another file's state:
793///
794/// - [`validate::validate`] never reads cross-file state at all.
795/// - [`dialect_gate::check`]'s only cross-file-shaped input, the resolution
796///   map, is queried only for `(this file, range)` pairs — a reference's
797///   resolution record always carries the file the reference itself lives
798///   in, never another file's — so `file_resolutions` need only be this
799///   file's own slice.
800/// - [`annotations::check`]'s cross-file inputs are the project's declared
801///   `LIST`/`STRUCT` names (derivable from a range-free index projection —
802///   `declared_list_names`/`declared_struct_names` read no symbol's range)
803///   and, for `Handle<K>` (T1d-2, docs/t1d-spec.md §3), the registered host
804///   manifest — project-wide, host-set config, not file-edit-derived, so
805///   reading it here is the same coarse dependency shape `dialect` already
806///   is, not a reintroduction of whole-project churn.
807///
808/// This is the query-shaped seam `brink-db`'s `per_file_diagnostics_query`
809/// wraps: a body edit in file Y leaves file X's per-file contributor memo
810/// untouched (pinned by `fg3_dependency_edges.rs`).
811///
812/// `is_native`: the T1b dialect gate (`dialect_gate::check`, issue #1348) is
813/// an ink-only axis — a native `.brink` file has no "dialect" concept at all
814/// (its own grammar *is* the superset grammar the gate exists to police, so
815/// every construct it recognizes is ordinary native syntax, never "brink
816/// extension" syntax to reject). `true` skips the gate entirely, regardless
817/// of `dialect`'s value; every other per-file contributor is unaffected —
818/// this caller-supplied flag never widens what `per_file_diagnostics` itself
819/// needs to know (it stays as agnostic to `Language` as `dialect` already
820/// was), it only tells this one contributor whether it applies. Callers with
821/// no `Language` classification of their own (the pure `analyze_with_options`
822/// path, via [`finish_analysis`]) always pass `false`, unchanged from before
823/// this parameter existed.
824///
825/// `scope` (issue #2272): this file's own **declared-module** [`ImportScope`]
826/// — the exact scope [`resolve`] already resolves this file's references
827/// against (every caller here builds it the identical way: `analyze_with_modules`'s
828/// per-file loop / `brink-db`'s `resolve_query`, never re-derived from
829/// `hir.module` in isolation — that field carries a deliberately empty name
830/// for a native file, see `analyze_with_modules`'s own comment). Threaded
831/// through to [`annotations::check`], whose referrer-scoped struct-name
832/// lookup must agree with [`crate::resolve::resolve_type_ref`]'s own
833/// `RefKind::Type` resolution on exactly the same scope, or the two silently
834/// diverge (issue #2272's own root cause: a per-file-local re-derivation
835/// disagreed with the real declared-module identity for a std/native file).
836#[must_use]
837#[expect(
838    clippy::too_many_arguments,
839    reason = "each parameter is an independently-necessary per-file input (issue #2272 added \
840              `scope`, the file's own declared-module ImportScope) — bundling them would just \
841              move the count into a struct with no consumer of its own"
842)]
843pub fn per_file_diagnostics(
844    file: FileId,
845    hir: &HirFile,
846    file_resolutions: &ResolutionMap,
847    index: &SymbolIndex,
848    dialect: Dialect,
849    is_native: bool,
850    host_manifest: Option<&HostManifest>,
851    scope: &ImportScope,
852) -> Vec<Diagnostic> {
853    let files = [(file, hir)];
854    let mut out = validate::validate(&files);
855    if !is_native {
856        out.extend(dialect_gate::check(&files, file_resolutions, dialect));
857    }
858    // NS-A1 E107 (bare-`none`-needs-context, docs/stdlib-spec.md §1.4) —
859    // dialect-INDEPENDENT, unlike the brink-only block below: the rule is
860    // part of the Option package itself, and under `strict-ink` (where
861    // `VAR`/`CONST` initializers aren't in the gate's block-tree walk) it
862    // is also what keeps `VAR x = none` an error at all. Same per-file
863    // argument as `dialect_gate`: the resolution records consulted always
864    // carry this file's own id.
865    out.extend(option_rules::check(&files, file_resolutions));
866    // E193 (#3354, RULED 2026-09-01 option C): a classic `~ temp` read on a
867    // path its declaration does not dominate. Dialect- and
868    // surface-independent — the mistake is a property of the weave, and
869    // both frontends produce the same block tree for it. Per-file by
870    // construction: a temp lives in one knot's call frame, and a knot's
871    // body lives in exactly one file.
872    out.extend(temp_dominance::check(file, hir, is_native));
873    // E194 (#3373, RULED 2026-09-01): the compat-deny tier's first member —
874    // a knot's `~ temp` read from one of its stitches. Split out of
875    // `E193`'s former shape 4; same dialect/surface independence and
876    // per-file locality argument as above.
877    out.extend(compat_deny::knot_temp_from_stitch::check(
878        file, hir, is_native,
879    ));
880    // Annotation *content* checks (E061) run only under the brink
881    // dialect: under `strict-ink` the annotation is already rejected whole
882    // by `dialect_gate` (E051), and critiquing the inside of rejected
883    // syntax is noise (maintainer ruling 2026-07-13).
884    if dialect == Dialect::Brink {
885        out.extend(annotations::check(file, hir, index, host_manifest, scope));
886        // T1c `#fn` creation-site checks (E079/E080/E081) follow the same
887        // brink-only rule: under `strict-ink` the literal is already
888        // rejected whole (E051). Per-file by the same argument as
889        // `dialect_gate`: the resolution records consulted always carry
890        // this file's own id.
891        out.extend(fn_values::check(&files, file_resolutions, index));
892        // T1e-1 `ref lvalue-path` creation-site checks (E080 durable root,
893        // E097 standalone position, docs/t1e-spec.md §2/§6, issue #831) —
894        // same brink-only rule, same per-file argument as `fn_values`'s own
895        // comment just above (a reference's resolution record always
896        // carries the file the reference itself lives in).
897        out.extend(ref_projection::check(&files, file_resolutions, index));
898        // NS-A3 protocol-registry name reservation (E113, F6 ruled
899        // 2026-07-19, docs/stdlib-spec.md §9.6): `display`/`compare`/`next`
900        // are reserved method names under the brink dialect — an author
901        // declaration is a hard error, not an E035 warning. Brink-only:
902        // under strict-ink there is no protocol registry and vanilla ink
903        // identifiers stay untouched (the oracle corpus is out of reach by
904        // construction).
905        out.extend(protocols::check_reserved_names(&files));
906    }
907    // The three construction-literal checks below are wired WIDER than the
908    // brink-only block above on purpose (B5, issue #1464, #1103 cascade
909    // ruling (A), docs/stdlib-spec.md §9.6): `TypeName { … }` construction
910    // reaches `StructLiteral`/`MapLiteral` through the native surface
911    // (`Map { k: v }`, `Point { x: 1 }`) regardless of the (ink-only)
912    // `dialect` axis a native project happens to carry — a `.brink` file
913    // compiled under the default `strict-ink` dialect must still get these
914    // errors. Under `strict-ink` *ink* the literal sigils (`#{…}`) are
915    // already rejected whole by `dialect_gate` (E051), so nothing new fires
916    // there.
917    if dialect == Dialect::Brink || is_native {
918        // Struct construction-literal duplicate-field check (E084, issue
919        // #675) — unlike `structs::check`'s missing/extra/mistyped trio
920        // this runs under *both* `types` policies (see `structs`' module
921        // doc): a repeated field name is a structural mistake detectable
922        // from the literal alone, with no shape resolution or
923        // whole-project inference needed.
924        out.extend(structs::check_duplicates(&files));
925        // Declared-STRUCT-name-collides-with-a-reserved-type-name warning
926        // (E188, issue #1865) — same policy-independence argument as
927        // `check_duplicates` just above: a struct's own name colliding
928        // with a builtin/tower type name is a structural fact about the
929        // declaration itself, detectable with no shape resolution or
930        // whole-project inference, so it needs no `types` policy gate
931        // either. Wired at the same `dialect == Brink || is_native` gate
932        // as every other STRUCT-declaration-shaped check in this block —
933        // `STRUCT` is unreachable under `strict-ink` in the first place
934        // (already `E051`-rejected whole by `dialect_gate`), so a second
935        // diagnostic there would be the same "critiquing rejected syntax"
936        // noise the TM-2 annotation-content precedent already rules out.
937        out.extend(annotations::check_reserved_type_names(&files));
938        // Map-literal key-domain warning (E106, issue #598,
939        // docs/t1b-surface-spec.md §3) — same policy-independence
940        // `structs::check_duplicates` documents: a statically-visible
941        // non-key-domain literal key is a structural authoring mistake
942        // detectable from the literal alone, no shape resolution or
943        // whole-project inference needed.
944        out.extend(map_keys::check(&files));
945        // Map-literal duplicate-key error (E138, B5 issue #1464, #1103
946        // cascade ruling (A)).
947        out.extend(map_keys::check_duplicate_keys(&files));
948    }
949    // Native bare-name fn values (issue #1862): the `.brink` half of the
950    // T1c creation-site discipline. Keyed off `is_native` alone rather than
951    // the block above's `dialect == Brink || is_native`, because the rule
952    // it enforces only exists on the native surface — see
953    // [`fn_values::check_native_bare_refs`]'s own doc. (`check` above stays
954    // where it is: `#fn` is the brink-*dialect* spelling and is not
955    // reachable from `.brink` source at all.)
956    if is_native {
957        out.extend(fn_values::check_native_bare_refs(
958            &files,
959            file_resolutions,
960            index,
961        ));
962    }
963    // Inline-markup vocabulary checks (E164/E165, issue #1733,
964    // docs/prose-dialect-spec.md §4.2). Wired *outside* every dialect
965    // branch above on purpose: markup spans are a native-grammar
966    // construct, so the ink-only `dialect` axis has nothing to say about
967    // them, and the pass is inert for ink source by construction (no
968    // `ContentPart::Span` can exist there). Inert for native source too
969    // unless the host manifest actually declares a markup vocabulary —
970    // freeform is the default (§4.2), and `markup_check::check` returns
971    // before touching the HIR when nothing is declared.
972    out.extend(markup_check::check(&[(file, hir)], host_manifest));
973    out
974}
975
976/// Collect inline `///` docs across all files, keyed by `(kind, declared
977/// name)` — the project-wide doc merge feeding the external/callable/value
978/// enrichment passes. Exposed as its own seam (issue #750 / FG-3
979/// completion) so `brink-db` can memoize it behind an `Eq`-cutoff query:
980/// [`DocBlock`] carries no ranges, so any edit that leaves every `///`
981/// block's parsed content intact backdates the memo even though the pass
982/// reads every file's manifest.
983#[must_use]
984pub fn project_inline_docs(
985    files: &[(FileId, &SymbolManifest)],
986) -> BTreeMap<(SymbolKind, String), DocBlock> {
987    collect_inline_docs(files)
988}
989
990/// The index-driven half of the external-check family (issue #750 / FG-3
991/// completion): host-manifest enrichment + checks for `EXTERNAL`s
992/// ([`external_check::analyze_externals`] — arity `E039`, unknown semantic
993/// types `E040`) followed by knot/stitch doc enrichment
994/// ([`external_check::enrich_callables`], same `E040` vocabulary), in
995/// exactly that order for both the diagnostics and the `symbol_meta`
996/// merge. Reads the index and the merged inline docs only — never any
997/// file's HIR — which is what lets `brink-db` memoize it separately from
998/// the per-file HIR walks ([`file_value_meta`] /
999/// [`file_call_site_diagnostics`]).
1000#[must_use]
1001pub fn external_meta_diagnostics(
1002    index: &SymbolIndex,
1003    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1004    opts: &AnalysisOptions,
1005) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
1006    let (types, registered) = manifest_maps(opts.host_manifest.as_ref());
1007    let has_manifest = opts.host_manifest.is_some();
1008    // Unknown-semantic-type checking (`E040`) is on when a manifest is
1009    // registered, or when the severity lever is explicitly raised to `Error`
1010    // (#532) — a host can opt back into strict checking with no manifest.
1011    let check_unknown_types =
1012        has_manifest || opts.semantic_type_check == SemanticTypeDiagnosticSeverity::Error;
1013    let (mut symbol_meta, mut diagnostics) = external_check::analyze_externals(
1014        index,
1015        inline_docs,
1016        &types,
1017        &registered,
1018        opts.external_check,
1019        check_unknown_types,
1020    );
1021
1022    // Knot/stitch doc enrichment (presentational; shares the semantic-type
1023    // vocabulary, so unknown types still diagnose — but only once a manifest
1024    // is registered, or the severity lever is raised (#339/#532); see
1025    // `resolve_type`).
1026    let (callable_meta, callable_diags) = external_check::enrich_callables(
1027        index,
1028        inline_docs,
1029        &types,
1030        opts.external_check,
1031        check_unknown_types,
1032    );
1033    diagnostics.extend(callable_diags);
1034    symbol_meta.extend(callable_meta);
1035
1036    (symbol_meta, diagnostics)
1037}
1038
1039/// Project the external-kind entries of an enrichment map to a name-keyed
1040/// map for the call-site checks (issue #750 / FG-3 completion). Range-free
1041/// by construction ([`SymbolMeta`] carries no spans), so `brink-db` can put
1042/// an `Eq`-cutoff memo between the (often-invalidated, full-ranged-index-
1043/// reading) enrichment pass and every file's call-site walk — the
1044/// `resolution_index` playbook.
1045///
1046/// Fed [`external_meta_diagnostics`]'s output, this is identical to the
1047/// pre-split filter over the *fully merged* `symbol_meta`: the callable
1048/// ([`external_check::enrich_callables`]) and value
1049/// ([`external_check::infer_value_meta`]) passes only ever key
1050/// `Knot`/`Stitch` and `Variable`/`Constant`/`List` ids respectively, so no
1051/// entry they add can pass the `SymbolKind::External` filter here.
1052/// Same-name duplicates resolve identically too: iteration is in
1053/// `DefinitionId` order in both shapes, later entries overwriting.
1054#[must_use]
1055pub fn call_site_metas(
1056    index: &SymbolIndex,
1057    metas: &BTreeMap<DefinitionId, SymbolMeta>,
1058) -> BTreeMap<String, SymbolMeta> {
1059    metas
1060        .iter()
1061        .filter_map(|(id, meta)| {
1062            index.symbols.get(id).and_then(|s| {
1063                (s.kind == SymbolKind::External).then(|| (s.name.clone(), meta.clone()))
1064            })
1065        })
1066        .collect()
1067}
1068
1069/// One file's VAR/CONST/LIST initializer/doc enrichment (issue #750 / FG-3
1070/// completion — the per-file slice of [`external_check::infer_value_meta`],
1071/// which `whole_project_diagnostics` used to run as one loop over every
1072/// file's HIR). Purely presentational — never produces diagnostics. A
1073/// declaration's initializer lives in exactly one file, so the per-file
1074/// split is behavior-neutral: the whole-project result is the file-order
1075/// merge of the per-file maps (later files overwrite on the — deliberately
1076/// deterministic — duplicate-name id collision, exactly as the single loop
1077/// did). Reads no symbol ranges from `index` (only `by_name` + `kind`), so
1078/// a range-zeroed index projection serves it.
1079#[must_use]
1080pub fn file_value_meta(
1081    file: FileId,
1082    hir: &HirFile,
1083    index: &SymbolIndex,
1084    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1085) -> BTreeMap<DefinitionId, SymbolMeta> {
1086    external_check::infer_value_meta(&[(file, hir)], index, inline_docs)
1087}
1088
1089/// One file's external call-site literal checks (`E041` type mismatch,
1090/// `E042` closed domain) — the per-file slice of
1091/// [`external_check::check_call_sites`] (issue #750 / FG-3 completion).
1092/// The checker only ever reads the file it is visiting plus the name-keyed
1093/// external metas, so the per-file split is behavior-neutral; the caller
1094/// owns both the [`ExternalCheckSeverity`] gate and the file-order
1095/// concatenation the single whole-project walk produced.
1096#[must_use]
1097pub fn file_call_site_diagnostics(
1098    file: FileId,
1099    hir: &HirFile,
1100    metas: &BTreeMap<String, SymbolMeta>,
1101) -> Vec<Diagnostic> {
1102    let name_to_meta: BTreeMap<&str, &SymbolMeta> = metas
1103        .iter()
1104        .map(|(name, meta)| (name.as_str(), meta))
1105        .collect();
1106    external_check::check_call_sites(&[(file, hir)], &name_to_meta)
1107}
1108
1109/// The M-2 module import + visibility checks (docs/modules-spec.md
1110/// §2/§4/§7): import well-formedness and cross-module `#@private`
1111/// reference enforcement. Purely additive — every trigger needs an
1112/// `IMPORT`/`#@private`/`#@public` construct absent from the pre-modules
1113/// world, so the oracle/tier1 corpus is untouched. Genuinely whole-project
1114/// (reads every file's HIR plus the project-wide resolutions to walk
1115/// cross-module references), so it stays a whole-project pass in
1116/// `brink-db`'s decomposed `whole_project_diagnostics_query` rather than
1117/// gaining a per-file split here (issue #750 / FG-3 completion rebase note;
1118/// a per-file slice is possible FG-4-era work if module churn is ever hot).
1119#[must_use]
1120pub fn module_diagnostics(
1121    files: &[(FileId, &HirFile)],
1122    index: &SymbolIndex,
1123    resolutions: &ResolutionMap,
1124) -> Vec<Diagnostic> {
1125    modules::check(files, index, resolutions)
1126}
1127
1128/// The strict typed-mode pass (docs/typed-mode-spec.md §1/§9-step-3),
1129/// extracted from `whole_project_diagnostics`'s body (issue #750 / FG-3
1130/// completion) so `brink-db` can run it without also paying for the
1131/// external-check family's inputs. Returns empty under `types = gradual` —
1132/// byte-identical, forever.
1133///
1134/// `types = strict` requires `dialect = brink` — a config error (`E064`)
1135/// otherwise, reported alone (nothing else strict-specific runs against a
1136/// project whose dialect already rejects the annotation syntax strict mode
1137/// needs). Under `dialect = brink`, runs inference (reusing
1138/// `strict_inference` when the caller already computed one — see
1139/// [`whole_project_diagnostics`]'s doc) and wires in Unknown/Conflicted-
1140/// escape (`E065`/`E066`) plus `E063` mismatches.
1141///
1142/// `is_native` (issue #1348): `E064` is [`strict::config_error`]'s dialect
1143/// check, and `dialect` is an ink-only axis — a native `.brink` project has
1144/// no dialect to be wrong about, so `true` skips the `config_error` call
1145/// entirely and always proceeds straight to the inference-driven checks
1146/// below (never a config error for native, regardless of `opts.dialect`).
1147/// Same "caller-supplied, never widens this function's own knowledge" shape
1148/// as [`per_file_diagnostics`]'s own `is_native` — the pure path (via
1149/// [`whole_project_diagnostics`]) always passes `false`.
1150///
1151/// `inline_docs` (issue #805): forwarded to [`infer::infer_project`]'s own
1152/// `EXTERNAL`-signature seeding when `strict_inference` isn't already
1153/// supplied — the pure/self-contained fallback path only; `brink-db`'s
1154/// production seam always supplies `strict_inference` (its FG-narrowed
1155/// `type_inference_query`, which reads `inline_docs_query` itself through
1156/// `solve_scc_query`), so this parameter is inert there. Kept required
1157/// (rather than defaulted away) so the pure path stays composed-equals-
1158/// monolithic with the salsa one for every caller, not just the memoized
1159/// production one.
1160#[must_use]
1161pub fn strict_diagnostics(
1162    files: &[(FileId, &HirFile)],
1163    index: &SymbolIndex,
1164    resolutions: &ResolutionMap,
1165    opts: &AnalysisOptions,
1166    is_native: bool,
1167    strict_inference: Option<&InferenceResult>,
1168    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1169) -> Vec<Diagnostic> {
1170    let mut diagnostics = Vec::new();
1171    if opts.type_policy() == TypePolicy::Strict {
1172        let config_err = if is_native {
1173            None
1174        } else {
1175            strict::config_error(opts.dialect, files.first().map(|&(f, _)| f))
1176        };
1177        if let Some(diag) = config_err {
1178            diagnostics.push(diag);
1179        } else {
1180            let owned_inference;
1181            let inference = if let Some(inf) = strict_inference {
1182                inf
1183            } else {
1184                owned_inference = infer::infer_project(
1185                    files,
1186                    index,
1187                    resolutions,
1188                    opts.host_manifest.as_ref(),
1189                    inline_docs,
1190                );
1191                &owned_inference
1192            };
1193            diagnostics.extend(strict::check(
1194                files,
1195                index,
1196                inference,
1197                resolutions,
1198                opts.host_manifest.as_ref(),
1199            ));
1200            // Issue #1004: escape-check each registered `EXTERNAL`
1201            // declaration's own param types against the manifest/inline-doc
1202            // signatures. `strict::check` above only walks `hir.knots`, so a
1203            // manifest-typed external param would otherwise never be verified
1204            // — resolved types stay clean, an unresolvable `ManifestParam.ty`
1205            // reports `E065` at the external's own declaration span. Seeded
1206            // from the same `collect_external_sigs` resolution that feeds
1207            // call-site argument checking; runs on this shared
1208            // `strict_diagnostics` seam so the pure `analyze_with_options`
1209            // path and `brink-db`'s query path get byte-identical output.
1210            let external_sigs =
1211                infer::collect_external_sigs(index, opts.host_manifest.as_ref(), inline_docs);
1212            diagnostics.extend(strict::check_external_escapes(index, &external_sigs));
1213        }
1214    }
1215    diagnostics
1216}
1217
1218/// Whole-project diagnostic contributors that genuinely need cross-file
1219/// state (issue #632 / FG-3 design doc §1), now composed of the same
1220/// per-pass seams `brink-db`'s decomposed queries wrap (issue #750 / FG-3
1221/// completion) — [`module_diagnostics`], [`strict_diagnostics`],
1222/// [`external_meta_diagnostics`], per-file [`file_value_meta`], and per-file
1223/// [`file_call_site_diagnostics`] behind [`call_site_metas`] — in exactly
1224/// the pre-split order, so the query-composed result is identical to this
1225/// monolithic one by construction (pinned by `query_equivalence.rs`).
1226///
1227/// `strict_inference`: TM-3's strict pass needs a whole-project
1228/// [`InferenceResult`] (docs/typed-mode-spec.md §9-step-3 — E063 auto-wiring
1229/// "must run inference anyway"). Pass `None` to have this function compute
1230/// its own via [`infer_project`] (the self-contained default —
1231/// [`analyze_with_options`]'s pure, non-salsa path). Pass `Some` to reuse an
1232/// already-computed result instead — `brink-db` supplies its FG-narrowed,
1233/// per-SCC-memoized `type_inference_query` here so strict mode's
1234/// warm-reanalyze cost is the incremental one the FG spine exists for, not a
1235/// from-scratch whole-project solve on every keystroke. Ignored entirely
1236/// under `types = gradual` or when the dialect makes strict mode a config
1237/// error. The `types = strict` + wrong-dialect config error (`E064`) is
1238/// computed exactly once, inside [`strict_diagnostics`] (issue #632's
1239/// TM-3-interaction fence).
1240///
1241/// `is_native` (issue #1358): every file is native (`.brink`) source, so the
1242/// ink-only `E064` config error is skipped — forwarded verbatim to
1243/// [`strict_diagnostics`], whose own `is_native` doc has the reasoning
1244/// (issue #1348). `brink-db`'s `whole_project_diagnostics_query` passes its
1245/// own `project_is_native` answer at the same seam, but that answer is
1246/// entry-anchored — it reads `false` whenever the db has no entry set — so
1247/// it does not automatically agree with a caller-computed `is_native` for a
1248/// db that never calls `set_entry` (e.g. `IdeSession`'s editor/LSP analysis
1249/// path, as opposed to `IdeSession::compile`). Callers of this function are
1250/// responsible for supplying an `is_native` that actually matches their file
1251/// set.
1252#[must_use]
1253pub fn whole_project_diagnostics(
1254    files: &[(FileId, &HirFile, &SymbolManifest)],
1255    index: &SymbolIndex,
1256    resolutions: &ResolutionMap,
1257    opts: &AnalysisOptions,
1258    is_native: bool,
1259    strict_inference: Option<&InferenceResult>,
1260) -> (Vec<Diagnostic>, BTreeMap<DefinitionId, SymbolMeta>) {
1261    let manifest_inputs: Vec<(FileId, &SymbolManifest)> = files
1262        .iter()
1263        .map(|&(id, _hir, manifest)| (id, manifest))
1264        .collect();
1265    let hir_inputs: Vec<(FileId, &HirFile)> = files.iter().map(|&(id, hir, _)| (id, hir)).collect();
1266
1267    // Computed once, up front (moved ahead of `strict_diagnostics`, issue
1268    // #805): both the TM-3 strict pass's `EXTERNAL`-signature seeding and
1269    // the host-manifest enrichment pass below need the project-wide merged
1270    // `///` doc map.
1271    let inline_docs = collect_inline_docs(&manifest_inputs);
1272
1273    // M-2 module import + visibility checks (docs/modules-spec.md
1274    // §2/§4/§7), first in diagnostic order.
1275    let mut diagnostics = module_diagnostics(&hir_inputs, index, resolutions);
1276
1277    // TM-3 strict typed-mode policy. Gradual mode returns empty here —
1278    // byte-identical, forever.
1279    diagnostics.extend(strict_diagnostics(
1280        &hir_inputs,
1281        index,
1282        resolutions,
1283        opts,
1284        is_native,
1285        strict_inference,
1286        &inline_docs,
1287    ));
1288
1289    // Host-manifest enrichment + checks (tooling/author-time only) — the
1290    // index-driven half: externals (E039/E040), then callables.
1291    let (mut symbol_meta, ext_diags) = external_meta_diagnostics(index, &inline_docs, opts);
1292    diagnostics.extend(ext_diags);
1293
1294    // Name-keyed external metas for the call-site checks — built before the
1295    // value-meta merge, which is identical to the pre-split post-merge
1296    // filter (see `call_site_metas`'s doc for the argument).
1297    let cs_metas = call_site_metas(index, &symbol_meta);
1298
1299    // VAR/CONST initializer info + LIST docs (presentational, no
1300    // diagnostics), merged in file order.
1301    for &(file_id, hir, _) in files {
1302        symbol_meta.extend(file_value_meta(file_id, hir, index, &inline_docs));
1303    }
1304
1305    // Call-site literal checks (type mismatch, closed domain) over the HIR,
1306    // in file order. Externals only — knot/stitch metadata is
1307    // presentational, not binding.
1308    if opts.external_check != ExternalCheckSeverity::Off {
1309        for &(file_id, hir, _) in files {
1310            diagnostics.extend(file_call_site_diagnostics(file_id, hir, &cs_metas));
1311        }
1312    }
1313
1314    // T2-2 `#@effects(…)` exceedance check (docs/effects-spec.md §10, issue
1315    // #861) — brink-only, same TM-2 "content checks skip strict-ink"
1316    // precedent `per_file_diagnostics` documents (the directive is already
1317    // rejected whole by `dialect_gate`'s `E051` under strict-ink). Only pays
1318    // for `effects_project`'s whole-project inference when at least one
1319    // assertion actually exists anywhere in the project — an unannotated
1320    // project stays effects-inference-free, matching T2-1's advisory-only
1321    // posture.
1322    //
1323    // The FS-2 `await`-condition purity gate (E105,
1324    // docs/flow-suspension-spec.md §3/§5, issue #928) rides the same
1325    // whole-project effect table and the same brink-only + laziness posture:
1326    // it needs `effects_project`'s rows to judge a condition's transitive
1327    // effect, so both passes share one inference when *either* an `#@effects`
1328    // assertion or an `await` appears anywhere in the project.
1329    // The NS-A4 comparator-contract gate (E119, docs/stdlib-spec.md §4b,
1330    // issue #1110 — extended to the fn-value verb trio `map`/`filter`/
1331    // `fold` by issue #1679, §4) rides the same whole-project effect table
1332    // with the same brink-only + laziness posture: a project with no
1333    // `sort_by`/`sorted_by`/`map`/`filter`/`fold` site whose callback is an
1334    // inline `#fn` literal or (issue #1887) a native bare-name reference
1335    // never triggers effect inference for it.
1336    let needs_effects = hir_inputs.iter().any(|&(_, hir)| {
1337        hir_has_effects_assertion(hir)
1338            || await_purity::hir_has_await(hir)
1339            || comparator_contract::hir_has_comparator_site(hir)
1340    });
1341    if opts.dialect == Dialect::Brink && needs_effects {
1342        let rows =
1343            infer::effects_project(&hir_inputs, index, resolutions, opts.host_manifest.as_ref());
1344        for &(file_id, hir) in &hir_inputs {
1345            // Import-scoped resolution (issue #881, the T2 follow-up to
1346            // M-2d/#790): the assertion's own `reads`/`writes`/`calls` clause
1347            // names must resolve through this file's own declared module +
1348            // imports, exactly like every other reference does — see
1349            // `effects_assertions::check`'s doc.
1350            let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
1351            diagnostics.extend(effects_assertions::check(
1352                file_id, hir, index, &scope, &rows,
1353            ));
1354            // The `await` purity gate resolves each condition's calls through
1355            // this file's own resolution records (`resolutions` carries file
1356            // provenance, filtered inside `await_purity::check`).
1357            diagnostics.extend(await_purity::check(file_id, hir, index, resolutions, &rows));
1358            // The NS-A4 comparator-contract gate (E119) — same resolution
1359            // discipline, judging the comparators of `sort_by`/`sorted_by`
1360            // and the fn-value verb trio's callbacks (`map`/`filter`/
1361            // `fold`, issue #1679) — named by an inline `#fn(target)`
1362            // literal or, since issue #1887, a native bare-name reference —
1363            // against their target's row.
1364            diagnostics.extend(comparator_contract::check(
1365                file_id,
1366                hir,
1367                index,
1368                resolutions,
1369                &rows,
1370            ));
1371        }
1372    }
1373
1374    // #2179 the `@[convention]` no-world-reads fence (`E182`,
1375    // docs/decision-log.md 2026-08-06 "No-world-reads fence: analyzer
1376    // effect-row check; unclassified externals are diagnosed"). Lazy on
1377    // the same shape every other pass here uses: a file with no declared
1378    // claim handler (`hir.claim_handlers.is_empty()`) is skipped inside
1379    // `no_world_reads::check` itself, so a project with no `@[convention]`
1380    // handler anywhere pays nothing. `symbol_meta` is already the fully
1381    // merged externals table by this point (value metas merged above, but
1382    // `no_world_reads` only reads externals' `kind`, which
1383    // `external_meta_diagnostics` alone already populated).
1384    for &(file_id, hir, _) in files {
1385        diagnostics.extend(no_world_reads::check(
1386            file_id,
1387            hir,
1388            &hir_inputs,
1389            index,
1390            resolutions,
1391            &symbol_meta,
1392        ));
1393    }
1394
1395    // B3a UFCS resolution (issue #1482, D1–D5 RULED 2026-07-26). Only the
1396    // diagnostics land here; the verdict side table itself is served to LIR
1397    // lowering and the IDE through [`ufcs_resolution`], which runs the same
1398    // pass over the same inputs.
1399    //
1400    // Dialect-independent, for the reason `ufcs`' module doc gives: a
1401    // multi-segment `Expr::Call` path can only originate in the native
1402    // frontend, so the gate is structural rather than policy-driven — the
1403    // ink corpus never reaches this pass. Lazy on the same argument as
1404    // `needs_effects` above: a project with no dotted-callee call anywhere
1405    // pays nothing.
1406    if hir_inputs
1407        .iter()
1408        .any(|&(_, hir)| ufcs::project_has_ufcs_call(hir))
1409    {
1410        let owned_inference;
1411        let inference = if let Some(inf) = strict_inference {
1412            inf
1413        } else {
1414            owned_inference = infer::infer_project(
1415                &hir_inputs,
1416                index,
1417                resolutions,
1418                opts.host_manifest.as_ref(),
1419                &inline_docs,
1420            );
1421            &owned_inference
1422        };
1423        let (_table, ufcs_diags) = ufcs::resolve(&hir_inputs, index, resolutions, inference);
1424        diagnostics.extend(ufcs_diags);
1425    }
1426
1427    (diagnostics, symbol_meta)
1428}
1429
1430/// The B3a UFCS verdict side table for a project (issue #1482, D2): the
1431/// `node → resolved target` channel LIR lowering reads to choose between
1432/// emitting a call through a field's value and emitting the desugared free
1433/// call `name(recv, args)`, and that IDE hover/go-to-def reads to name the
1434/// real target of a method-call-shaped site.
1435///
1436/// Split out from [`whole_project_diagnostics`] — which keeps the same
1437/// pass's *diagnostics* — because the two consumers want opposite halves of
1438/// one result and neither should pay for the other's.
1439#[must_use]
1440pub fn ufcs_resolution(
1441    files: &[(FileId, &HirFile)],
1442    index: &SymbolIndex,
1443    resolutions: &ResolutionMap,
1444    inference: &InferenceResult,
1445) -> (UfcsTable, Vec<Diagnostic>) {
1446    ufcs::resolve(files, index, resolutions, inference)
1447}
1448
1449/// The B1 `or`-coalescing typing side table for a project (issue #1492):
1450/// the `chain root → per-step operand/result types` channel LIR lowering
1451/// reads to choose a chain's code shape — "inner stays `Option`" vs
1452/// "unwrap at the end" — instead of re-deriving the answer from syntax it
1453/// cannot see through (a call's return type, a `VAR`'s declared type).
1454///
1455/// Keyed by [`brink_ir::hir::expr_span`] of the chain root, the derivation
1456/// both sides share — since issue #1517, the root `Expr::Infix`'s own
1457/// `Provenance` range, so every chain root in a file is separately
1458/// addressable. See [`CoalesceChain`] for the step order and `coalesce`'s
1459/// module doc for why absence (an ill-typed chain the pass abandoned) is
1460/// always safe — the consumer falls back to the runtime check, which is
1461/// what gradual mode does regardless.
1462///
1463/// Split out from [`whole_project_diagnostics`] — which keeps the same
1464/// pass's `E066` *diagnostics* — exactly as [`ufcs_resolution`] is, and for
1465/// the same reason: the two consumers want opposite halves of one result.
1466///
1467/// Unlike [`ufcs_resolution`] (whose diagnostics run unconditionally inside
1468/// [`whole_project_diagnostics`]), the `E066` diagnostics this function
1469/// also returns are **strict-mode-only by convention, not by construction**:
1470/// production code reaches them only from `strict::check`, after
1471/// `strict::config_error` has confirmed `types = strict` + `dialect =
1472/// brink` (see `coalesce::resolve`'s own doc for that entry condition), but
1473/// this function itself performs no such gate — it walks every file
1474/// unconditionally. A caller that surfaces its `Vec<Diagnostic>` without
1475/// re-checking `type_policy`/`dialect` itself would emit strict-only
1476/// `E066` under `types = gradual`.
1477#[must_use]
1478pub fn coalesce_types(
1479    files: &[(FileId, &HirFile)],
1480    index: &SymbolIndex,
1481    inference: &InferenceResult,
1482    resolutions: &ResolutionMap,
1483) -> (CoalesceTable, Vec<Diagnostic>) {
1484    coalesce::resolve(files, index, inference, resolutions)
1485}
1486
1487/// Owned form of [`brink_ir::lir::AnalyzerTables`] (issue #1527) — every
1488/// analyzer side-table LIR lowering reads, held by value instead of by the
1489/// borrowed references `AnalyzerTables` itself carries. A caller builds one
1490/// of these (via [`assemble_analyzer_tables`]) and then borrows its fields
1491/// into an `AnalyzerTables` at the lowering call site, exactly as
1492/// `brink-db`'s two salsa queries already borrow their own owned
1493/// `UfcsLookup`/`CoalesceLookup` locals.
1494#[derive(Debug, Clone, Default)]
1495pub struct AnalyzerTablesOwned {
1496    /// B3a UFCS (issue #1506) — see [`brink_ir::lir::UfcsLookup`]'s own doc.
1497    pub ufcs: brink_ir::lir::UfcsLookup,
1498    /// B1 `or`-coalescing (issue #1492) — see [`brink_ir::lir::CoalesceLookup`]'s own doc.
1499    pub coalesce: brink_ir::lir::CoalesceLookup,
1500}
1501
1502impl AnalyzerTablesOwned {
1503    /// Borrow this owned bundle into the [`brink_ir::lir::AnalyzerTables`]
1504    /// lowering actually takes — the one place that borrow is assembled
1505    /// (issue #1528's review finding). Field-by-field construction at each
1506    /// call site meant a third `AnalyzerTables` field would compile-error at
1507    /// the call site instead of here, and the cheapest silencer there is a
1508    /// throwaway default value rather than actually wiring the new table —
1509    /// exactly the silent-empty-table failure this whole function exists to
1510    /// prevent. Keeping the borrow here means a new field's compile error
1511    /// lands next to this assembly instead.
1512    #[must_use]
1513    pub fn as_tables(&self) -> brink_ir::lir::AnalyzerTables<'_> {
1514        brink_ir::lir::AnalyzerTables {
1515            ufcs: &self.ufcs,
1516            coalesce: &self.coalesce,
1517        }
1518    }
1519}
1520
1521/// Assemble every analyzer side-table LIR lowering needs, from scratch, in
1522/// one whole-project pass — **the one path a caller with no salsa layer of
1523/// its own must use** (issue #1528).
1524///
1525/// Before this function existed, `brink-test-harness`'s `corpus.rs` hand-
1526/// rolled this assembly itself: one `if project_has_*` block per table,
1527/// each independently re-running [`infer_project`] — a *third* parallel
1528/// implementation of the same gate-then-translate pattern `brink-db`'s two
1529/// salsa queries (`ufcs_resolution_query`, `coalesce_types_query`) already
1530/// each implement for their own table. That meant a future side-table (the
1531/// v6/Step work) had to be *remembered* in three places at once — miss the
1532/// harness's copy and lowering there silently got an empty table for it: a
1533/// compiling, green-tested, wrong-coverage bug, the same silent-drop class
1534/// this repo always treats as a bug. Extending *this* function is the fix
1535/// for every salsa-free caller: it is the one place such a caller's
1536/// gate+translate needs adding, mirroring how
1537/// [`brink_ir::lir::AnalyzerTables`] (issue #1527) is the one place a
1538/// future table needs adding to lowering's own signature. `brink-db`'s two
1539/// queries stay separate `#[salsa::tracked]` functions on purpose — each
1540/// needs its own independent memoization/backdating cutoff, which a single
1541/// bundled query would collapse — but both continue to call the exact same
1542/// translation primitives this function composes
1543/// ([`ufcs_resolution`]/[`coalesce_types`]/[`ufcs_lir_lookup`]/
1544/// [`coalesce_lir_lookup`]), so the two paths can't drift on *how* a table
1545/// is computed, only on *when* (memoized vs. every call).
1546///
1547/// Lazy exactly like each table already was individually: [`infer_project`]
1548/// runs at most once — shared across every table that needs it, unlike the
1549/// old per-table harness blocks which each ran their own copy — and only if
1550/// some table's structural gate ([`project_has_ufcs_call`] or
1551/// [`project_has_coalesce`]) found something to resolve. A project using
1552/// neither feature (every ink-dialect project, by construction — both
1553/// features are native-frontend-only) pays nothing and returns the
1554/// all-empty default.
1555#[must_use]
1556pub fn assemble_analyzer_tables(
1557    files: &[(FileId, &HirFile)],
1558    index: &SymbolIndex,
1559    resolutions: &ResolutionMap,
1560    host_manifest: Option<&HostManifest>,
1561    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1562) -> AnalyzerTablesOwned {
1563    let needs_ufcs = files
1564        .iter()
1565        .any(|&(_, hir)| ufcs::project_has_ufcs_call(hir));
1566    let needs_coalesce = files
1567        .iter()
1568        .any(|&(_, hir)| coalesce::project_has_coalesce(hir));
1569
1570    let inference = if needs_ufcs || needs_coalesce {
1571        Some(infer::infer_project(
1572            files,
1573            index,
1574            resolutions,
1575            host_manifest,
1576            inline_docs,
1577        ))
1578    } else {
1579        None
1580    };
1581
1582    let ufcs = match (&inference, needs_ufcs) {
1583        (Some(inference), true) => {
1584            let (table, _ufcs_diagnostics) = ufcs_resolution(files, index, resolutions, inference);
1585            ufcs_lir_lookup(&table)
1586        }
1587        _ => brink_ir::lir::UfcsLookup::new(),
1588    };
1589
1590    let coalesce = match (&inference, needs_coalesce) {
1591        (Some(inference), true) => {
1592            let (table, _e066_diagnostics) = coalesce_types(files, index, inference, resolutions);
1593            coalesce_lir_lookup(&table)
1594        }
1595        _ => brink_ir::lir::CoalesceLookup::new(),
1596    };
1597
1598    AnalyzerTablesOwned { ufcs, coalesce }
1599}
1600
1601/// Cheap structural scan: does any knot/stitch in `hir` carry a
1602/// `#@effects(…)` assertion? The laziness gate for
1603/// [`whole_project_diagnostics`]'s exceedance pass — avoids running
1604/// [`infer::effects_project`] at all for a project that never uses the
1605/// directive.
1606fn hir_has_effects_assertion(hir: &HirFile) -> bool {
1607    hir.knots.iter().any(|k| {
1608        k.effects_assertion.is_some() || k.stitches.iter().any(|s| s.effects_assertion.is_some())
1609    })
1610}
1611
1612/// Assemble the final [`AnalysisResult`] from the already-computed layer-2
1613/// pieces (index + per-file resolutions), running the remaining passes:
1614/// per-file diagnostic contributors ([`per_file_diagnostics`]) for every
1615/// file, then the whole-project contributors
1616/// ([`whole_project_diagnostics`]).
1617///
1618/// Query-shaped seam for the scripting substrate: `brink-db`'s salsa
1619/// `analysis_query` composes [`symbol_index`] and per-file [`resolve`]
1620/// queries, then the decomposed per-file/whole-project queries this
1621/// function's two halves wrap (issue #632 / FG-3) — the same sequence this
1622/// function runs, in the same order, so the query-composed result is
1623/// identical to the monolithic one by construction (pinned by
1624/// `query_equivalence.rs`).
1625///
1626/// `is_native`: every file in `files` is native (`.brink`) source — see
1627/// [`analyze_with_modules`]'s own `is_native` doc for the full list of arms
1628/// it selects (issue #1358). Forwarded to [`per_file_diagnostics`] and
1629/// [`whole_project_diagnostics`], and it is what makes the B0.9 strict-only
1630/// gate ([`native_strict_only_error`], `E137`) reachable from this path at
1631/// all. The analyzer has no file paths of its own, so this is a caller-
1632/// supplied classification: a caller with a `ProjectDb` reads it from there,
1633/// and one without passes `false` (the ink arm, byte-identical to this
1634/// function before the parameter existed).
1635///
1636/// `strict_inference`: see [`whole_project_diagnostics`]'s doc — forwarded
1637/// unchanged.
1638///
1639/// `scopes` (issue #2272): the same per-file declared-module [`ImportScope`]
1640/// map [`analyze_with_modules`]'s own resolution loop just built while
1641/// calling [`resolve`] — reused here rather than re-derived, so
1642/// `per_file_diagnostics`'s referrer-scoped checks agree with `resolve`'s
1643/// own resolution on identical scope. A file missing from the map (not
1644/// possible from [`analyze_with_modules`]'s only call site, which inserts
1645/// one entry per file in `files`) falls back to [`ImportScope::default`] —
1646/// the pre-#2272, map-free behavior — rather than panicking.
1647#[expect(
1648    clippy::too_many_arguments,
1649    reason = "each parameter is an independently-necessary layer-2 input this function \
1650              assembles into the final AnalysisResult (issue #2272 added `scopes`, mirroring \
1651              `per_file_diagnostics`'s own new `scope` parameter) — bundling them would just \
1652              move the count into a struct with no consumer of its own"
1653)]
1654pub fn finish_analysis(
1655    files: &[(FileId, &HirFile, &SymbolManifest)],
1656    index: Arc<SymbolIndex>,
1657    resolutions: ResolutionMap,
1658    mut diagnostics: Vec<Diagnostic>,
1659    opts: &AnalysisOptions,
1660    is_native: bool,
1661    strict_inference: Option<&infer::InferenceResult>,
1662    scopes: &BTreeMap<FileId, ImportScope>,
1663) -> AnalysisResult {
1664    let default_scope = ImportScope::default();
1665    for &(file_id, hir, _manifest) in files {
1666        let file_resolutions: ResolutionMap = resolutions
1667            .iter()
1668            .filter(|r| r.file == file_id)
1669            .cloned()
1670            .collect();
1671        let scope = scopes.get(&file_id).unwrap_or(&default_scope);
1672        diagnostics.extend(per_file_diagnostics(
1673            file_id,
1674            hir,
1675            &file_resolutions,
1676            &index,
1677            opts.dialect,
1678            is_native,
1679            opts.host_manifest.as_ref(),
1680            scope,
1681        ));
1682        if is_native {
1683            // The B0.9 native strict-only gate, in the same per-file
1684            // position `brink-db`'s `per_file_diagnostics_query` runs it
1685            // (right after the per-file contributors for that file), so the
1686            // composed and monolithic paths stay order-identical.
1687            diagnostics.extend(native_strict_only_error(file_id, opts.types));
1688        }
1689    }
1690
1691    let (whole_diagnostics, symbol_meta) = whole_project_diagnostics(
1692        files,
1693        &index,
1694        &resolutions,
1695        opts,
1696        is_native,
1697        strict_inference,
1698    );
1699    diagnostics.extend(whole_diagnostics);
1700
1701    AnalysisResult {
1702        index,
1703        resolutions,
1704        diagnostics,
1705        symbol_meta,
1706    }
1707}
1708
1709/// Collect inline `///` docs across all files, keyed by `(kind, declared name)`.
1710fn collect_inline_docs(
1711    files: &[(FileId, &SymbolManifest)],
1712) -> BTreeMap<(SymbolKind, String), DocBlock> {
1713    let mut out = BTreeMap::new();
1714    for &(_id, manifest) in files {
1715        for (key, doc) in &manifest.docs {
1716            out.insert(key.clone(), doc.clone());
1717        }
1718    }
1719    out
1720}
1721
1722/// Build lookup maps from the registered manifest: semantic types by name and
1723/// registered externals by name.
1724fn manifest_maps(
1725    manifest: Option<&HostManifest>,
1726) -> (
1727    BTreeMap<String, SemanticTypeDef>,
1728    BTreeMap<String, &ManifestExternal>,
1729) {
1730    let mut types = BTreeMap::new();
1731    let mut registered = BTreeMap::new();
1732    if let Some(manifest) = manifest {
1733        for ty in &manifest.types {
1734            types.insert(ty.name.clone(), ty.clone());
1735        }
1736        for ext in &manifest.externals {
1737            registered.insert(ext.name.clone(), ext);
1738        }
1739    }
1740    (types, registered)
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    //! End-to-end coverage for #339: host semantic types (`///` `@param`
1746    //! tags referencing host vocabulary, e.g. `actor_id`) must not block
1747    //! compilation when no `HostManifest` is registered, while a registered
1748    //! manifest keeps full checking (a genuinely unknown type still errors).
1749
1750    use std::collections::BTreeMap;
1751
1752    use brink_ir::{BaseType, HostManifest, SemanticTypeDef};
1753
1754    use super::{
1755        AnalysisOptions, Dialect, FileId, ImportScope, LintLevel, LintPolicy, ModuleMap,
1756        ProjectConfig, SemanticTypeDiagnosticSeverity, TypePolicy, analyze, analyze_with_options,
1757        per_file_diagnostics, resolve, symbol_index, validate_conventions_preset,
1758    };
1759
1760    /// The piece composition with an explicit `is_native` flag — the #1358
1761    /// pins below used to exercise the `analyze_with_modules` monolith's
1762    /// flag threading; the monolith retired with option A total
1763    /// (2026-08-24), and the flag now lives only on the pieces, so the pins
1764    /// hold the composed pieces to the same contract.
1765    fn analyze_composed(
1766        files: &[(FileId, &super::HirFile, &super::SymbolManifest)],
1767        modules: &ModuleMap,
1768        opts: &AnalysisOptions,
1769        is_native: bool,
1770    ) -> super::AnalysisResult {
1771        let manifest_inputs: Vec<_> = files.iter().map(|&(id, _hir, m)| (id, m)).collect();
1772        let (index, mut diagnostics) =
1773            super::symbol_index_with_modules(&manifest_inputs, modules, opts.dialect, is_native);
1774        let mut resolutions = brink_ir::ResolutionMap::new();
1775        let mut scopes = std::collections::BTreeMap::new();
1776        for &(file_id, hir, manifest) in files {
1777            let declared_module = match modules.get(&file_id) {
1778                Some(resolved) => resolved.declared.then(|| resolved.name.clone()),
1779                None => hir.module.as_ref().map(|m| m.name.clone()),
1780            };
1781            let scope = ImportScope::new(declared_module, &hir.imports);
1782            let (file_map, file_diags) = resolve(file_id, manifest, &index, &scope);
1783            resolutions.extend(std::sync::Arc::unwrap_or_clone(file_map));
1784            diagnostics.extend(file_diags);
1785            scopes.insert(file_id, scope);
1786        }
1787        super::finish_analysis(
1788            files,
1789            index,
1790            resolutions,
1791            diagnostics,
1792            opts,
1793            is_native,
1794            None,
1795            &scopes,
1796        )
1797    }
1798
1799    /// ink with an `EXTERNAL` whose param is typed with a host semantic type
1800    /// (`actor_id`) — exactly the `host.ink`-generated shape from the issue.
1801    const SRC: &str = "\
1802/// @param who {actor_id}
1803EXTERNAL add_state(who)
1804";
1805
1806    fn lower(src: &str) -> (brink_ir::hir::HirFile, brink_ir::SymbolManifest) {
1807        let parsed = brink_syntax::parse(src);
1808        let tree = parsed.tree();
1809        let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
1810        assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1811        (hir, manifest)
1812    }
1813
1814    #[test]
1815    fn host_semantic_type_compiles_host_free_with_no_manifest() {
1816        let (hir, manifest) = lower(SRC);
1817        // `analyze()` uses `AnalysisOptions::default()` — no host manifest —
1818        // matching the real "no HostManifest registered" consumer path
1819        // (`compileProject()` with no `setHostManifest` call).
1820        let result = analyze(&[(FileId(0), &hir, &manifest)]);
1821        assert!(
1822            result.diagnostics.is_empty(),
1823            "host-free compile must not error on unknown semantic types: {:?}",
1824            result.diagnostics
1825        );
1826    }
1827
1828    #[test]
1829    fn host_semantic_type_still_checked_once_manifest_registered() {
1830        let (hir, manifest) = lower(SRC);
1831        let host_manifest = HostManifest {
1832            markup: Vec::new(),
1833            externals: Vec::new(),
1834            types: vec![SemanticTypeDef {
1835                name: "actor_id".to_string(),
1836                base: BaseType::String,
1837                constraint: None,
1838                values: None,
1839                widget: None,
1840            }],
1841        };
1842        let opts = AnalysisOptions {
1843            host_manifest: Some(host_manifest),
1844            ..AnalysisOptions::default()
1845        };
1846        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1847        assert!(
1848            result.diagnostics.is_empty(),
1849            "known semantic type resolves cleanly: {:?}",
1850            result.diagnostics
1851        );
1852    }
1853
1854    #[test]
1855    fn genuinely_unknown_type_still_errors_when_manifest_registered() {
1856        // Same shape, but the registered manifest does NOT define `actor_id`
1857        // — a manifest being present makes checking fully binding again.
1858        let (hir, manifest) = lower(SRC);
1859        let opts = AnalysisOptions {
1860            host_manifest: Some(HostManifest::default()),
1861            ..AnalysisOptions::default()
1862        };
1863        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1864        assert_eq!(
1865            result
1866                .diagnostics
1867                .iter()
1868                .filter(|d| d.code == brink_ir::DiagnosticCode::E040)
1869                .count(),
1870            1,
1871            "manifest registered but type unknown: E040 still fires: {:?}",
1872            result.diagnostics
1873        );
1874    }
1875
1876    /// #532: `semantic_type_check` defaults to `Tolerant`, matching the
1877    /// #339/#527 default-tolerant behavior — an explicit `Tolerant` opt-in
1878    /// behaves identically to the unset default.
1879    #[test]
1880    fn semantic_type_check_default_is_tolerant() {
1881        let (hir, manifest) = lower(SRC);
1882        let opts = AnalysisOptions {
1883            semantic_type_check: SemanticTypeDiagnosticSeverity::Tolerant,
1884            ..AnalysisOptions::default()
1885        };
1886        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1887        assert!(
1888            result.diagnostics.is_empty(),
1889            "Tolerant (default) with no manifest: no E040: {:?}",
1890            result.diagnostics
1891        );
1892    }
1893
1894    /// #532: raising `semantic_type_check` to `Error` re-enables strict
1895    /// checking even with no manifest registered — a host can catch typo'd
1896    /// semantic-type tags before wiring up a full manifest.
1897    #[test]
1898    fn semantic_type_check_error_diagnoses_with_no_manifest() {
1899        let (hir, manifest) = lower(SRC);
1900        let opts = AnalysisOptions {
1901            semantic_type_check: SemanticTypeDiagnosticSeverity::Error,
1902            ..AnalysisOptions::default()
1903        };
1904        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1905        assert_eq!(
1906            result
1907                .diagnostics
1908                .iter()
1909                .filter(|d| d.code == brink_ir::DiagnosticCode::E040)
1910                .count(),
1911            1,
1912            "Error with no manifest: E040 still fires: {:?}",
1913            result.diagnostics
1914        );
1915    }
1916
1917    /// #532: the lever composes with a registered manifest that defines the
1918    /// type — a known type never diagnoses regardless of severity.
1919    #[test]
1920    fn semantic_type_check_error_with_known_type_in_manifest_is_clean() {
1921        let (hir, manifest) = lower(SRC);
1922        let host_manifest = HostManifest {
1923            markup: Vec::new(),
1924            externals: Vec::new(),
1925            types: vec![SemanticTypeDef {
1926                name: "actor_id".to_string(),
1927                base: BaseType::String,
1928                constraint: None,
1929                values: None,
1930                widget: None,
1931            }],
1932        };
1933        let opts = AnalysisOptions {
1934            host_manifest: Some(host_manifest),
1935            semantic_type_check: SemanticTypeDiagnosticSeverity::Error,
1936            ..AnalysisOptions::default()
1937        };
1938        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1939        assert!(
1940            result.diagnostics.is_empty(),
1941            "known type resolves cleanly regardless of severity: {:?}",
1942            result.diagnostics
1943        );
1944    }
1945
1946    // ── TM-3 (#619): strict policy end-to-end through analyze_with_options ──
1947
1948    fn lower_one(src: &str) -> (brink_ir::hir::HirFile, brink_ir::SymbolManifest) {
1949        let parsed = brink_syntax::parse(src);
1950        let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &parsed.tree());
1951        assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1952        (hir, manifest)
1953    }
1954
1955    /// The dialect-keyed default (issue #1127, ruled 2026-07-19). Under
1956    /// `strict-ink`, an unset `types` resolves gradual FOREVER — the same
1957    /// source, `types` never set, must produce results identical to a build
1958    /// that predates TM-3 entirely: no `E064`/`E065`/`E066`, and `E063`
1959    /// stays un-auto-invoked (the #618/PR#640 ruling, untouched — the
1960    /// oracle corpus is anchored to this). Under `brink`, the same unset
1961    /// `types` now resolves strict, so the Unknown-escape check fires;
1962    /// explicit `Gradual` remains the opt-out knob and restores silence.
1963    #[test]
1964    fn types_default_is_dialect_keyed() {
1965        let src = "=== noop(x) ===\nHello.\n-> DONE\n";
1966        let (hir, manifest) = lower_one(src);
1967
1968        // strict-ink + unset types: gradual, byte-identical forever.
1969        let opts = AnalysisOptions {
1970            dialect: Dialect::StrictInk,
1971            ..AnalysisOptions::default()
1972        };
1973        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1974        assert!(
1975            result.diagnostics.is_empty(),
1976            "strict-ink (default types = gradual) must stay silent: {:?}",
1977            result.diagnostics
1978        );
1979
1980        // brink + unset types: strict — the Unknown-escape check fires.
1981        let opts = AnalysisOptions {
1982            dialect: Dialect::Brink,
1983            ..AnalysisOptions::default()
1984        };
1985        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1986        assert!(
1987            result
1988                .diagnostics
1989                .iter()
1990                .any(|d| d.code == brink_ir::DiagnosticCode::E065),
1991            "brink (default types = strict) must flag the Unknown escape: {:?}",
1992            result.diagnostics
1993        );
1994
1995        // brink + explicit gradual: the opt-out knob restores silence.
1996        let opts = AnalysisOptions {
1997            dialect: Dialect::Brink,
1998            types: Some(TypePolicy::Gradual),
1999            ..AnalysisOptions::default()
2000        };
2001        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
2002        assert!(
2003            result.diagnostics.is_empty(),
2004            "brink + explicit gradual opt-out must stay silent: {:?}",
2005            result.diagnostics
2006        );
2007    }
2008
2009    #[test]
2010    fn strict_with_strict_ink_dialect_is_a_config_error_and_nothing_else_runs() {
2011        let src = "=== noop(x) ===\nHello.\n-> DONE\n";
2012        let (hir, manifest) = lower_one(src);
2013        let opts = AnalysisOptions {
2014            dialect: Dialect::StrictInk,
2015            types: Some(TypePolicy::Strict),
2016            ..AnalysisOptions::default()
2017        };
2018        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
2019        let strict_diags: Vec<_> = result
2020            .diagnostics
2021            .iter()
2022            .filter(|d| {
2023                matches!(
2024                    d.code,
2025                    brink_ir::DiagnosticCode::E064
2026                        | brink_ir::DiagnosticCode::E065
2027                        | brink_ir::DiagnosticCode::E066
2028                )
2029            })
2030            .collect();
2031        assert_eq!(
2032            strict_diags.len(),
2033            1,
2034            "exactly the one config error, nothing else: {:?}",
2035            result.diagnostics
2036        );
2037        assert_eq!(strict_diags[0].code, brink_ir::DiagnosticCode::E064);
2038    }
2039
2040    #[test]
2041    fn strict_with_brink_dialect_surfaces_unknown_escape_as_a_compile_error() {
2042        let src = "=== noop(x) ===\nHello.\n-> DONE\n";
2043        let (hir, manifest) = lower_one(src);
2044        let opts = AnalysisOptions {
2045            dialect: Dialect::Brink,
2046            types: Some(TypePolicy::Strict),
2047            ..AnalysisOptions::default()
2048        };
2049        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
2050        assert!(
2051            result
2052                .diagnostics
2053                .iter()
2054                .any(|d| d.code == brink_ir::DiagnosticCode::E065),
2055            "{:?}",
2056            result.diagnostics
2057        );
2058        assert_eq!(
2059            result
2060                .diagnostics
2061                .iter()
2062                .find(|d| d.code == brink_ir::DiagnosticCode::E065)
2063                .expect("checked above")
2064                .code
2065                .severity(),
2066            brink_ir::Severity::Error,
2067            "Unknown-escape is a compile error under strict, not a warning"
2068        );
2069    }
2070
2071    #[test]
2072    fn strict_clean_project_compiles_with_no_diagnostics() {
2073        let src =
2074            "=== function heal(hp: int): int ===\n~ temp bonus: int = 5\n~ return hp + bonus\n";
2075        let (hir, manifest) = lower_one(src);
2076        let opts = AnalysisOptions {
2077            dialect: Dialect::Brink,
2078            types: Some(TypePolicy::Strict),
2079            ..AnalysisOptions::default()
2080        };
2081        let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
2082        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2083    }
2084
2085    // ── per_file_diagnostics: is_native decouples the T1b dialect gate
2086    //    (issue #1348) ────────────────────────────────────────────────
2087
2088    #[test]
2089    fn per_file_diagnostics_is_native_true_skips_the_dialect_gate() {
2090        // Postfix indexing is ordinary syntax in the native grammar, but a
2091        // brink-extension construct the T1b gate flags (`E051`) under ink's
2092        // default `StrictInk` dialect. Under `is_native = true` the gate must
2093        // never run, regardless of `dialect`.
2094        let (hir, manifest) = lower_one("~ x = a[0]\n");
2095        let (index, _diags) = symbol_index(&[(FileId(0), &manifest)]);
2096        let (resolutions, _diags) = resolve(FileId(0), &manifest, &index, &ImportScope::default());
2097        let diags = per_file_diagnostics(
2098            FileId(0),
2099            &hir,
2100            &resolutions,
2101            &index,
2102            Dialect::StrictInk,
2103            true,
2104            None,
2105            &ImportScope::default(),
2106        );
2107        assert!(
2108            !diags
2109                .iter()
2110                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2111            "native must never see the ink-only dialect gate: {diags:?}"
2112        );
2113    }
2114
2115    #[test]
2116    fn per_file_diagnostics_is_native_false_unaffected_still_flags_extension_syntax() {
2117        // The `is_native = false` (ink) path is byte-identical to before
2118        // this parameter existed — same source, same `StrictInk` default,
2119        // still an `E051` extension-syntax diagnostic.
2120        let (hir, manifest) = lower_one("~ x = a[0]\n");
2121        let (index, _diags) = symbol_index(&[(FileId(0), &manifest)]);
2122        let (resolutions, _diags) = resolve(FileId(0), &manifest, &index, &ImportScope::default());
2123        let diags = per_file_diagnostics(
2124            FileId(0),
2125            &hir,
2126            &resolutions,
2127            &index,
2128            Dialect::StrictInk,
2129            false,
2130            None,
2131            &ImportScope::default(),
2132        );
2133        assert!(
2134            diags
2135                .iter()
2136                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2137            "ink must still see the dialect gate: {diags:?}"
2138        );
2139    }
2140
2141    // ── analyze_with_modules: is_native reaches the per-file and
2142    //    whole-project arms too (issue #1358) ──────────────────────────
2143
2144    /// The composed pure path — not just the `per_file_diagnostics` seam
2145    /// directly — must skip the ink-only T1b gate for native source.
2146    /// Before #1358 `analyze_with_modules`'s `is_native` reached only the
2147    /// symbol index, so this `E051` leaked into every editor surface that
2148    /// analyzes off-db.
2149    #[test]
2150    fn composed_is_native_true_skips_the_dialect_gate() {
2151        let (hir, manifest) = lower_one("~ x = a[0]\n");
2152        let result = analyze_composed(
2153            &[(FileId(0), &hir, &manifest)],
2154            &ModuleMap::new(),
2155            &AnalysisOptions::default(),
2156            true,
2157        );
2158        assert!(
2159            !result
2160                .diagnostics
2161                .iter()
2162                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2163            "native must never see the ink-only dialect gate: {:?}",
2164            result.diagnostics
2165        );
2166    }
2167
2168    #[test]
2169    fn composed_is_native_false_unaffected_still_flags_extension_syntax() {
2170        let (hir, manifest) = lower_one("~ x = a[0]\n");
2171        let result = analyze_composed(
2172            &[(FileId(0), &hir, &manifest)],
2173            &ModuleMap::new(),
2174            &AnalysisOptions::default(),
2175            false,
2176        );
2177        assert!(
2178            result
2179                .diagnostics
2180                .iter()
2181                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2182            "ink must still see the dialect gate: {:?}",
2183            result.diagnostics
2184        );
2185    }
2186
2187    /// `E064` rejects `types = strict` under a non-`brink` **dialect** — an
2188    /// ink-only axis. A native project carries `StrictInk` by default (it
2189    /// has no dialect opinion), so before #1358 dialing `types = strict` on
2190    /// the pure path produced this spurious project-level error.
2191    #[test]
2192    fn composed_is_native_true_skips_the_ink_only_config_error() {
2193        let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2194        let opts = AnalysisOptions {
2195            types: Some(TypePolicy::Strict),
2196            ..AnalysisOptions::default()
2197        };
2198        let result = analyze_composed(
2199            &[(FileId(0), &hir, &manifest)],
2200            &ModuleMap::new(),
2201            &opts,
2202            true,
2203        );
2204        assert!(
2205            !result
2206                .diagnostics
2207                .iter()
2208                .any(|d| d.code == brink_ir::DiagnosticCode::E064),
2209            "native has no dialect to be wrong about: {:?}",
2210            result.diagnostics
2211        );
2212    }
2213
2214    #[test]
2215    fn composed_is_native_false_unaffected_still_fires_config_error() {
2216        let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2217        let opts = AnalysisOptions {
2218            types: Some(TypePolicy::Strict),
2219            ..AnalysisOptions::default()
2220        };
2221        let result = analyze_composed(
2222            &[(FileId(0), &hir, &manifest)],
2223            &ModuleMap::new(),
2224            &opts,
2225            false,
2226        );
2227        assert!(
2228            result
2229                .diagnostics
2230                .iter()
2231                .any(|d| d.code == brink_ir::DiagnosticCode::E064),
2232            "ink must still get the config error: {:?}",
2233            result.diagnostics
2234        );
2235    }
2236
2237    /// The B0.9 strict-only gate (`E137`): explicit `types = gradual` is not
2238    /// a policy native source can be compiled under. `brink-db`'s
2239    /// `per_file_diagnostics_query` has always run it; the pure path could
2240    /// not express it at all before #1358.
2241    #[test]
2242    fn composed_is_native_true_reports_the_native_strict_only_error() {
2243        let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2244        let opts = AnalysisOptions {
2245            types: Some(TypePolicy::Gradual),
2246            ..AnalysisOptions::default()
2247        };
2248        let result = analyze_composed(
2249            &[(FileId(0), &hir, &manifest)],
2250            &ModuleMap::new(),
2251            &opts,
2252            true,
2253        );
2254        assert!(
2255            result
2256                .diagnostics
2257                .iter()
2258                .any(|d| d.code == brink_ir::DiagnosticCode::E137),
2259            "explicit `types = gradual` is a native config error: {:?}",
2260            result.diagnostics
2261        );
2262    }
2263
2264    #[test]
2265    fn composed_is_native_false_never_reports_the_native_strict_only_error() {
2266        let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2267        let opts = AnalysisOptions {
2268            types: Some(TypePolicy::Gradual),
2269            ..AnalysisOptions::default()
2270        };
2271        let result = analyze_composed(
2272            &[(FileId(0), &hir, &manifest)],
2273            &ModuleMap::new(),
2274            &opts,
2275            false,
2276        );
2277        assert!(
2278            !result
2279                .diagnostics
2280                .iter()
2281                .any(|d| d.code == brink_ir::DiagnosticCode::E137),
2282            "`E137` is native-only: {:?}",
2283            result.diagnostics
2284        );
2285    }
2286
2287    /// The module-blind convenience wrapper stays the ink path, byte for
2288    /// byte — it has no `Language` classification to offer.
2289    #[test]
2290    fn analyze_with_options_stays_the_ink_arm() {
2291        let (hir, manifest) = lower_one("~ x = a[0]\n");
2292        let result =
2293            analyze_with_options(&[(FileId(0), &hir, &manifest)], &AnalysisOptions::default());
2294        assert!(
2295            result
2296                .diagnostics
2297                .iter()
2298                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2299            "{:?}",
2300            result.diagnostics
2301        );
2302    }
2303
2304    // ── AnalysisOptions::apply_project_config (moved from
2305    // brink-project-config with the #1234 dependency inversion) ──────
2306
2307    #[test]
2308    fn apply_sets_unset_fields_from_config() {
2309        let mut options = AnalysisOptions::default();
2310        let config = ProjectConfig {
2311            dialect: Some(Dialect::Brink),
2312            types: Some(TypePolicy::Strict),
2313            ..ProjectConfig::default()
2314        };
2315        options.apply_project_config(&config, false, false);
2316        assert_eq!(options.dialect, Dialect::Brink);
2317        assert_eq!(options.types, Some(TypePolicy::Strict));
2318    }
2319
2320    #[test]
2321    fn apply_leaves_overridden_fields_alone() {
2322        let mut options = AnalysisOptions {
2323            dialect: Dialect::StrictInk,
2324            types: Some(TypePolicy::Gradual),
2325            ..AnalysisOptions::default()
2326        };
2327        let config = ProjectConfig {
2328            dialect: Some(Dialect::Brink),
2329            types: Some(TypePolicy::Strict),
2330            ..ProjectConfig::default()
2331        };
2332        // Both overridden: explicit calls win, file is ignored entirely.
2333        options.apply_project_config(&config, true, true);
2334        assert_eq!(options.dialect, Dialect::StrictInk);
2335        assert_eq!(options.types, Some(TypePolicy::Gradual));
2336    }
2337
2338    #[test]
2339    fn apply_mixed_override_only_touches_non_overridden_field() {
2340        let mut options = AnalysisOptions {
2341            dialect: Dialect::StrictInk,
2342            types: Some(TypePolicy::Gradual),
2343            ..AnalysisOptions::default()
2344        };
2345        let config = ProjectConfig {
2346            dialect: Some(Dialect::Brink),
2347            types: Some(TypePolicy::Strict),
2348            ..ProjectConfig::default()
2349        };
2350        // dialect explicitly overridden (stays StrictInk); types is not
2351        // (file wins, becomes Strict).
2352        options.apply_project_config(&config, true, false);
2353        assert_eq!(options.dialect, Dialect::StrictInk);
2354        assert_eq!(options.types, Some(TypePolicy::Strict));
2355    }
2356
2357    #[test]
2358    fn apply_with_no_config_values_leaves_options_untouched() {
2359        let mut options = AnalysisOptions {
2360            dialect: Dialect::Brink,
2361            types: Some(TypePolicy::Strict),
2362            ..AnalysisOptions::default()
2363        };
2364        options.apply_project_config(&ProjectConfig::default(), false, false);
2365        assert_eq!(options.dialect, Dialect::Brink);
2366        assert_eq!(options.types, Some(TypePolicy::Strict));
2367    }
2368
2369    // ── AnalysisOptions::apply_project_config: [lints] (issue #1160) ──
2370
2371    #[test]
2372    fn apply_project_config_applies_lint_overrides() {
2373        let mut options = AnalysisOptions::default();
2374        let mut config = ProjectConfig::default();
2375        config.lints.insert("E014".to_owned(), LintLevel::Deny);
2376        config.lints.insert("E022".to_owned(), LintLevel::Allow);
2377
2378        options.apply_project_config(&config, false, false);
2379
2380        assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2381        assert_eq!(options.lints.overrides.get("E022"), Some(&LintLevel::Allow));
2382    }
2383
2384    #[test]
2385    fn apply_project_config_sets_deny_warnings() {
2386        let mut options = AnalysisOptions::default();
2387        let config = ProjectConfig {
2388            deny_warnings: Some(true),
2389            ..ProjectConfig::default()
2390        };
2391
2392        options.apply_project_config(&config, false, false);
2393
2394        assert!(options.lints.deny_warnings);
2395    }
2396
2397    #[test]
2398    fn apply_project_config_absent_lints_clears_lint_policy() {
2399        let mut options = AnalysisOptions {
2400            lints: LintPolicy {
2401                overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
2402                deny_warnings: true,
2403            },
2404            ..AnalysisOptions::default()
2405        };
2406
2407        options.apply_project_config(&ProjectConfig::default(), false, false);
2408
2409        // Issue #1397: unlike `dialect`/`types`, `[lints]` REPLACES the
2410        // resolved policy rather than merging into it — an empty (or
2411        // absent) `[lints]` table resolves to no overrides and
2412        // `deny-warnings = false`, so a long-lived caller (the editor
2413        // session) that re-applies `brink.toml` after the table was deleted
2414        // actually reverts, instead of leaving the previous override stuck.
2415        assert!(
2416            options.lints.overrides.is_empty(),
2417            "an absent [lints] table must clear previously-resolved overrides"
2418        );
2419        assert!(!options.lints.deny_warnings);
2420    }
2421
2422    #[test]
2423    fn apply_project_config_omitted_code_reverts_to_base_severity() {
2424        // Simulates the editor session's live-reapply scenario (#1397): a
2425        // prior call already resolved E014 and E022 overrides plus
2426        // deny-warnings; the re-applied config only re-asserts E014 —
2427        // E022 and deny-warnings were deleted from `brink.toml` in between.
2428        let mut options = AnalysisOptions {
2429            lints: LintPolicy {
2430                overrides: BTreeMap::from([
2431                    ("E014".to_owned(), LintLevel::Deny),
2432                    ("E022".to_owned(), LintLevel::Allow),
2433                ]),
2434                deny_warnings: true,
2435            },
2436            ..AnalysisOptions::default()
2437        };
2438        let mut config = ProjectConfig::default();
2439        config.lints.insert("E014".to_owned(), LintLevel::Deny);
2440
2441        options.apply_project_config(&config, false, false);
2442
2443        assert_eq!(
2444            options.lints.overrides.get("E014"),
2445            Some(&LintLevel::Deny),
2446            "a code still present in the re-applied config keeps its override"
2447        );
2448        assert!(
2449            !options.lints.overrides.contains_key("E022"),
2450            "a code omitted from the re-applied config must revert to its \
2451             base severity, not stick"
2452        );
2453        assert!(
2454            !options.lints.deny_warnings,
2455            "deny-warnings omitted from the re-applied config must revert \
2456             to false, not stick"
2457        );
2458    }
2459
2460    #[test]
2461    fn apply_project_config_rejects_unknown_lint_code() {
2462        let mut options = AnalysisOptions::default();
2463        let mut config = ProjectConfig::default();
2464        // Not a real `DiagnosticCode` — never parses.
2465        config.lints.insert("E9999".to_owned(), LintLevel::Deny);
2466
2467        let warnings = options.apply_project_config(&config, false, false);
2468
2469        assert!(
2470            options.lints.overrides.is_empty(),
2471            "an unknown code must never be merged into the policy"
2472        );
2473        assert_eq!(warnings.len(), 1);
2474        assert!(warnings[0].0.contains("E9999"));
2475    }
2476
2477    #[test]
2478    fn apply_project_config_rejects_misspelled_lint_code_case() {
2479        let mut options = AnalysisOptions::default();
2480        let mut config = ProjectConfig::default();
2481        // `DiagnosticCode::from_str_code` is case-sensitive — a lowercase
2482        // spelling of a real code is not itself a real code.
2483        config.lints.insert("e014".to_owned(), LintLevel::Deny);
2484
2485        let warnings = options.apply_project_config(&config, false, false);
2486
2487        assert!(options.lints.overrides.is_empty());
2488        assert_eq!(warnings.len(), 1);
2489        assert!(warnings[0].0.contains("e014"));
2490    }
2491
2492    #[test]
2493    fn apply_project_config_rejects_non_overridable_lint_code() {
2494        let mut options = AnalysisOptions::default();
2495        let mut config = ProjectConfig::default();
2496        // E001 is a real code, but its default severity is `Error`, not
2497        // `Warning` — never reachable through `effective_severity`'s
2498        // hard-error exemption, so `[lints]` must not silently accept it.
2499        assert_eq!(
2500            brink_ir::DiagnosticCode::E001.severity(),
2501            brink_ir::Severity::Error
2502        );
2503        config.lints.insert("E001".to_owned(), LintLevel::Deny);
2504
2505        let warnings = options.apply_project_config(&config, false, false);
2506
2507        assert!(options.lints.overrides.is_empty());
2508        assert_eq!(warnings.len(), 1);
2509        assert!(warnings[0].0.contains("E001"));
2510    }
2511
2512    #[test]
2513    fn apply_project_config_reports_no_warnings_for_valid_overridable_codes() {
2514        let mut options = AnalysisOptions::default();
2515        let mut config = ProjectConfig::default();
2516        config.lints.insert("E014".to_owned(), LintLevel::Deny);
2517
2518        let warnings = options.apply_project_config(&config, false, false);
2519
2520        assert!(warnings.is_empty());
2521    }
2522
2523    /// Issue #1674: `E157`'s default severity is `Info`, not `Warning` — the
2524    /// widened `validate_lint_code` gate (anything short of `Error`) must
2525    /// still accept a `[lints] E157 = "warn"` override rather than rejecting
2526    /// it the way the pre-#1674 `Warning`-base-only gate would have.
2527    #[test]
2528    fn apply_project_config_accepts_info_base_lint_code() {
2529        let mut options = AnalysisOptions::default();
2530        let mut config = ProjectConfig::default();
2531        assert_eq!(
2532            brink_ir::DiagnosticCode::E157.severity(),
2533            brink_ir::Severity::Info
2534        );
2535        config.lints.insert("E157".to_owned(), LintLevel::Warn);
2536
2537        let warnings = options.apply_project_config(&config, false, false);
2538
2539        assert!(warnings.is_empty());
2540        assert_eq!(options.lints.overrides.get("E157"), Some(&LintLevel::Warn));
2541    }
2542
2543    // ── AnalysisOptions::apply_project_config: `[fix]` code validation
2544    // (issue #3447 — `[lints]`'s sibling gap named by
2545    // `docs/autofix-spec.md` §6.1) ──
2546
2547    #[test]
2548    fn apply_project_config_rejects_unknown_fix_code() {
2549        let mut options = AnalysisOptions::default();
2550        let mut config = ProjectConfig::default();
2551        // Not a real `DiagnosticCode` — never parses. Same fixture
2552        // `apply_project_config_rejects_unknown_lint_code` uses for `[lints]`.
2553        config
2554            .fix
2555            .insert("E9999".to_owned(), brink_project_config::FixPolicy::Auto);
2556
2557        let warnings = options.apply_project_config(&config, false, false);
2558
2559        assert_eq!(warnings.len(), 1, "{warnings:?}");
2560        assert!(warnings[0].0.contains("E9999"), "{warnings:?}");
2561        assert!(
2562            warnings[0].0.contains("[fix]"),
2563            "the warning must name the table it came from, not `[lints]`'s \
2564             wording: {warnings:?}"
2565        );
2566    }
2567
2568    #[test]
2569    fn apply_project_config_rejects_misspelled_fix_code_case() {
2570        let mut options = AnalysisOptions::default();
2571        let mut config = ProjectConfig::default();
2572        // `DiagnosticCode::from_str_code` is case-sensitive — a lowercase
2573        // spelling of a real code is not itself a real code.
2574        config
2575            .fix
2576            .insert("e014".to_owned(), brink_project_config::FixPolicy::Off);
2577
2578        let warnings = options.apply_project_config(&config, false, false);
2579
2580        assert_eq!(warnings.len(), 1, "{warnings:?}");
2581        assert!(warnings[0].0.contains("e014"), "{warnings:?}");
2582    }
2583
2584    #[test]
2585    fn apply_project_config_accepts_an_error_default_fix_code() {
2586        let mut options = AnalysisOptions::default();
2587        let mut config = ProjectConfig::default();
2588        // Unlike `[lints]`, `[fix]` has no `is_overridable` gate — a code
2589        // whose default severity is `Error` (E001) is still a real,
2590        // fix-policy-eligible code.
2591        assert_eq!(
2592            brink_ir::DiagnosticCode::E001.severity(),
2593            brink_ir::Severity::Error
2594        );
2595        config
2596            .fix
2597            .insert("E001".to_owned(), brink_project_config::FixPolicy::Auto);
2598
2599        let warnings = options.apply_project_config(&config, false, false);
2600
2601        assert!(warnings.is_empty(), "{warnings:?}");
2602    }
2603
2604    #[test]
2605    fn apply_project_config_reports_no_warnings_for_a_valid_fix_code() {
2606        let mut options = AnalysisOptions::default();
2607        let mut config = ProjectConfig::default();
2608        config
2609            .fix
2610            .insert("E014".to_owned(), brink_project_config::FixPolicy::Off);
2611
2612        let warnings = options.apply_project_config(&config, false, false);
2613
2614        assert!(warnings.is_empty(), "{warnings:?}");
2615    }
2616
2617    // ── AnalysisOptions::apply_project_config: `[project] conventions`
2618    // preset-name validation (issue #1874; key renamed from `elements` by
2619    // #2180) ──
2620
2621    #[test]
2622    fn apply_project_config_rejects_an_unrecognized_bare_preset_name() {
2623        let mut options = AnalysisOptions::default();
2624        let config = ProjectConfig {
2625            conventions: Some("screnplay".to_owned()),
2626            ..ProjectConfig::default()
2627        };
2628
2629        let warnings = options.apply_project_config(&config, false, false);
2630
2631        assert_eq!(
2632            options.conventions, None,
2633            "an unrecognized preset name must never be carried onto \
2634             `AnalysisOptions::conventions`"
2635        );
2636        assert_eq!(warnings.len(), 1);
2637        assert!(warnings[0].0.contains("screnplay"));
2638    }
2639
2640    /// #1720 (the built-in screenplay preset) shipped its authored source
2641    /// at `std/conventions/screenplay.brink` and added `"screenplay"` to
2642    /// `BUILTIN_CONVENTION_PRESETS` — this test used to pin the opposite
2643    /// (rejected-until-shipped) reality, per its own doc comment's promise
2644    /// to update alongside the registry change. Note this proves only the
2645    /// *validation* verdict flipped — `options.conventions` being
2646    /// populated does not by itself mean anything downstream consumes it
2647    /// yet (no `std::`-module resolution or `fn conventions()`
2648    /// registration exists, #2080/#1840).
2649    ///
2650    /// A #1720 review finding caught the first version of this test
2651    /// asserting `warnings.is_empty()`: recognizing the name here is
2652    /// validation-only, and silently accepting it would leave an author
2653    /// writing `conventions = "screenplay"` with zero diagnostics and zero
2654    /// behavior (rule 19h's failure mode). `options.conventions` is still
2655    /// populated (the name is not rejected as unrecognized), but a
2656    /// not-yet-injectable warning is still surfaced on the same channel.
2657    #[test]
2658    fn apply_project_config_accepts_screenplay_preset_name_now_that_it_shipped() {
2659        let mut options = AnalysisOptions::default();
2660        let config = ProjectConfig {
2661            conventions: Some("screenplay".to_owned()),
2662            ..ProjectConfig::default()
2663        };
2664
2665        let warnings = options.apply_project_config(&config, false, false);
2666
2667        assert_eq!(options.conventions.as_deref(), Some("screenplay"));
2668        assert_eq!(warnings.len(), 1, "unexpected warnings: {warnings:?}");
2669        assert!(warnings[0].0.contains("screenplay"));
2670        assert!(warnings[0].0.contains("not injectable yet"));
2671        assert!(warnings[0].0.contains("#2080"));
2672        assert!(warnings[0].0.contains("#1840"));
2673    }
2674
2675    #[test]
2676    fn apply_project_config_accepts_a_bare_path_shaped_conventions_pointer() {
2677        let mut options = AnalysisOptions::default();
2678        let config = ProjectConfig {
2679            conventions: Some("conventions.brink".to_owned()),
2680            ..ProjectConfig::default()
2681        };
2682
2683        let warnings = options.apply_project_config(&config, false, false);
2684
2685        assert!(
2686            warnings.is_empty(),
2687            "a path-shaped pointer (`.brink` extension) must never be \
2688             rejected by the preset-name closed set — that would break the \
2689             custom-conventions-module case #1844's confinement rule is \
2690             built around"
2691        );
2692        assert_eq!(options.conventions.as_deref(), Some("conventions.brink"));
2693    }
2694
2695    #[test]
2696    fn apply_project_config_accepts_a_directory_path_shaped_conventions_pointer() {
2697        let mut options = AnalysisOptions::default();
2698        let config = ProjectConfig {
2699            conventions: Some("scenes/conventions.brink".to_owned()),
2700            ..ProjectConfig::default()
2701        };
2702
2703        let warnings = options.apply_project_config(&config, false, false);
2704
2705        assert!(warnings.is_empty());
2706        assert_eq!(
2707            options.conventions.as_deref(),
2708            Some("scenes/conventions.brink")
2709        );
2710    }
2711
2712    #[test]
2713    fn apply_project_config_leaves_conventions_unset_when_absent() {
2714        let mut options = AnalysisOptions::default();
2715        let config = ProjectConfig::default();
2716
2717        let warnings = options.apply_project_config(&config, false, false);
2718
2719        assert!(warnings.is_empty());
2720        assert_eq!(options.conventions, None);
2721    }
2722
2723    /// Issue #2180: `apply_project_config` is only ever handed an already-
2724    /// reconciled `ProjectConfig` (the deprecated `elements` alias is
2725    /// resolved into `conventions` by `brink-project-config::parse_str_at`
2726    /// before this crate ever sees it) — this proves the deprecated-alias
2727    /// value flows through this layer identically to a native
2728    /// `conventions`-keyed one.
2729    #[test]
2730    fn apply_project_config_carries_a_conventions_value_reconciled_from_the_deprecated_alias() {
2731        let mut options = AnalysisOptions::default();
2732        let (config, parse_warnings) =
2733            brink_project_config::parse_str("[project]\nelements = \"conventions.brink\"\n")
2734                .expect("deprecated `elements` key must still parse");
2735        assert_eq!(parse_warnings.len(), 1, "{parse_warnings:?}");
2736
2737        let warnings = options.apply_project_config(&config, false, false);
2738
2739        assert!(warnings.is_empty(), "{warnings:?}");
2740        assert_eq!(options.conventions.as_deref(), Some("conventions.brink"));
2741    }
2742
2743    #[test]
2744    fn validate_conventions_preset_accepts_a_name_present_in_the_registry() {
2745        // Exercises the comparison logic itself against an explicit
2746        // registry literal, decoupled from `BUILTIN_CONVENTION_PRESETS`'s
2747        // own current contents — proves the check is a real membership
2748        // test, not a hardcoded "always reject a bare name". Since #1720,
2749        // production's real constant also contains `"screenplay"` (see
2750        // `apply_project_config_accepts_screenplay_preset_name_now_that_it_shipped`),
2751        // but this test's own point survives regardless of what the
2752        // constant holds.
2753        assert!(validate_conventions_preset("screenplay", &["screenplay"]).is_ok());
2754    }
2755
2756    #[test]
2757    fn validate_conventions_preset_rejects_a_name_outside_the_registry() {
2758        assert!(validate_conventions_preset("screnplay", &["screenplay"]).is_err());
2759    }
2760
2761    // ── AnalysisOptions::apply_lint_overrides: CLI/API tier (issue #1373) ──
2762
2763    #[test]
2764    fn apply_lint_overrides_merges_per_code_overrides() {
2765        let mut options = AnalysisOptions::default();
2766        let mut overrides = BTreeMap::new();
2767        overrides.insert("E014".to_owned(), LintLevel::Deny);
2768
2769        let warnings = options.apply_lint_overrides(&overrides, None);
2770
2771        assert!(warnings.is_empty());
2772        assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2773    }
2774
2775    #[test]
2776    fn apply_lint_overrides_sets_deny_warnings() {
2777        let mut options = AnalysisOptions::default();
2778
2779        let warnings = options.apply_lint_overrides(&BTreeMap::new(), Some(true));
2780
2781        assert!(warnings.is_empty());
2782        assert!(options.lints.deny_warnings);
2783    }
2784
2785    #[test]
2786    fn apply_lint_overrides_none_deny_warnings_leaves_it_untouched() {
2787        let mut options = AnalysisOptions::default();
2788        options.lints.deny_warnings = true;
2789
2790        options.apply_lint_overrides(&BTreeMap::new(), None);
2791
2792        assert!(options.lints.deny_warnings);
2793    }
2794
2795    #[test]
2796    fn apply_lint_overrides_rejects_unknown_code() {
2797        let mut options = AnalysisOptions::default();
2798        let mut overrides = BTreeMap::new();
2799        overrides.insert("E9999".to_owned(), LintLevel::Deny);
2800
2801        let warnings = options.apply_lint_overrides(&overrides, None);
2802
2803        assert!(options.lints.overrides.is_empty());
2804        assert_eq!(warnings.len(), 1);
2805        assert!(warnings[0].0.contains("E9999"));
2806    }
2807
2808    #[test]
2809    fn apply_lint_overrides_rejects_non_overridable_code() {
2810        let mut options = AnalysisOptions::default();
2811        let mut overrides = BTreeMap::new();
2812        // E001 is a real code, but its default severity is `Error`, not
2813        // `Warning` — same non-overridability rule as the file's `[lints]`
2814        // table (#1160).
2815        overrides.insert("E001".to_owned(), LintLevel::Deny);
2816
2817        let warnings = options.apply_lint_overrides(&overrides, None);
2818
2819        assert!(options.lints.overrides.is_empty());
2820        assert_eq!(warnings.len(), 1);
2821        assert!(warnings[0].0.contains("E001"));
2822    }
2823
2824    #[test]
2825    fn apply_lint_overrides_wins_over_a_prior_apply_project_config_for_the_same_code() {
2826        let mut options = AnalysisOptions::default();
2827        let mut config = ProjectConfig::default();
2828        config.lints.insert("E014".to_owned(), LintLevel::Deny);
2829        options.apply_project_config(&config, false, false);
2830        assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2831
2832        let mut overrides = BTreeMap::new();
2833        overrides.insert("E014".to_owned(), LintLevel::Allow);
2834        options.apply_lint_overrides(&overrides, None);
2835
2836        // The explicit override replaces the file's value for the same
2837        // code — #1005/#1373's `CLI/API > file` precedence.
2838        assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Allow));
2839    }
2840}
2841
2842#[cfg(test)]
2843mod overridability_agreement {
2844    use super::{AnalysisOptions, LintLevel};
2845    use brink_ir::DiagnosticCode;
2846    use std::collections::BTreeMap;
2847
2848    /// `DiagnosticCode::is_overridable` must agree with the gate that
2849    /// actually decides — `apply_lint_overrides` — for every code.
2850    ///
2851    /// This lives here rather than in `brink-ir` because only this crate can
2852    /// see both. The `brink-ir`-side test can only state the rule; this one
2853    /// runs the real thing, which is the difference that matters: the
2854    /// predicate was wrong for every `Info`-default code (`E189`, the ink
2855    /// `TODO:` note) and a test comparing it to a restated rule in the same
2856    /// crate could never have said so.
2857    #[test]
2858    fn agrees_with_the_analyzers_own_gate() {
2859        let mut disagreed = Vec::new();
2860        for code in DiagnosticCode::ALL {
2861            let mut options = AnalysisOptions::default();
2862            let overrides = BTreeMap::from([(code.as_str().to_owned(), LintLevel::Allow)]);
2863            let warnings = options.apply_lint_overrides(&overrides, None);
2864            // Accepted <=> it landed in the policy and earned no warning.
2865            let accepted =
2866                warnings.is_empty() && options.lints.overrides.contains_key(code.as_str());
2867            if accepted != code.is_overridable() {
2868                disagreed.push((code.as_str(), code.is_overridable(), accepted));
2869            }
2870        }
2871        assert!(
2872            disagreed.is_empty(),
2873            "is_overridable disagrees with apply_lint_overrides for \
2874             (code, predicate, analyzer): {disagreed:?}"
2875        );
2876    }
2877
2878    #[test]
2879    fn the_todo_note_can_be_configured() {
2880        // Ruled 2026-08-27. E189 is `Info` by default, and an author who
2881        // does not want their TODO notes reported has no other lever.
2882        let mut options = AnalysisOptions::default();
2883        let overrides = BTreeMap::from([("E189".to_owned(), LintLevel::Allow)]);
2884        let warnings = options.apply_lint_overrides(&overrides, None);
2885        assert!(warnings.is_empty(), "{warnings:?}");
2886        assert_eq!(options.lints.overrides.get("E189"), Some(&LintLevel::Allow));
2887        assert!(DiagnosticCode::E189.is_overridable());
2888    }
2889}