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