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/// **Test-fixture surface** (option A total, ruled 2026-08-24): production
656/// analysis is `brink-db`'s salsa composition (`analysis_query` /
657/// `subset_analysis_query`) — every IDE/LSP/editor/compile path routes
658/// there. This wrapper exists for unit tests that lower a fixture by hand
659/// and want the analyzer's own composition over it, module-blind and on
660/// the ink arm; it deliberately has NO `ModuleMap`/`is_native` parameters
661/// — the two degrees of freedom the retired `analyze_with_modules`
662/// monolith let callers hold wrong (the #1347/#1526/#1553/#1358
663/// divergence class) no longer exist to be misheld.
664pub fn analyze(files: &[(FileId, &HirFile, &SymbolManifest)]) -> AnalysisResult {
665 analyze_with_options(files, &AnalysisOptions::default())
666}
667
668/// [`analyze`] with explicit tooling options — same test-fixture status and
669/// same deliberately absent module/nativeness parameters (see [`analyze`]).
670///
671/// **Module-blind** (issue #1526): with no [`ModuleMap`] there is nothing to
672/// qualify identity by, so every symbol hashes by bare name (`module: None`)
673/// and the import scope reads each file's own declared `#@module` only.
674/// `DefinitionId`s minted here for declared-module files do not match
675/// `brink-db`'s — never use them as keys into db per-def queries.
676pub fn analyze_with_options(
677 files: &[(FileId, &HirFile, &SymbolManifest)],
678 opts: &AnalysisOptions,
679) -> AnalysisResult {
680 let modules = ModuleMap::new();
681 let manifest_inputs: Vec<(FileId, &SymbolManifest)> = files
682 .iter()
683 .map(|&(id, _hir, manifest)| (id, manifest))
684 .collect();
685 // Ink arm throughout (no `Language` classification exists at this layer
686 // — issues #1348/#1562); native-arm behavior is exclusively brink-db's.
687 let (index, mut diagnostics) =
688 symbol_index_with_modules(&manifest_inputs, &modules, opts.dialect, false);
689 let mut resolutions = ResolutionMap::new();
690 let mut scopes: BTreeMap<FileId, ImportScope> = BTreeMap::new();
691 for &(file_id, hir, manifest) in files {
692 let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
693 let (file_map, file_diags) = resolve(file_id, manifest, &index, &scope);
694 resolutions.extend(Arc::unwrap_or_clone(file_map));
695 diagnostics.extend(file_diags);
696 scopes.insert(file_id, scope);
697 }
698 let hir_files: Vec<(FileId, &HirFile)> = files.iter().map(|&(id, hir, _)| (id, hir)).collect();
699 diagnostics.extend(conventions_confinement_diagnostics(
700 &hir_files,
701 &modules,
702 opts.conventions.as_deref(),
703 ));
704 finish_analysis(
705 files,
706 index,
707 resolutions,
708 diagnostics,
709 opts,
710 false,
711 None,
712 &scopes,
713 )
714}
715
716/// Per-file diagnostic contributors (issue #632 / FG-3,
717/// `docs/fine-grained-salsa-proposal.md` §1 item 4): structural validation,
718/// the dialect gate, and (brink dialect only) annotation-*content* checks —
719/// the three passes `finish_analysis` used to run as whole-project loops
720/// (`validate::validate`/`dialect_gate::check`/`annotations::check`, each
721/// internally iterating every file) even though none of them actually reads
722/// another file's state:
723///
724/// - [`validate::validate`] never reads cross-file state at all.
725/// - [`dialect_gate::check`]'s only cross-file-shaped input, the resolution
726/// map, is queried only for `(this file, range)` pairs — a reference's
727/// resolution record always carries the file the reference itself lives
728/// in, never another file's — so `file_resolutions` need only be this
729/// file's own slice.
730/// - [`annotations::check`]'s cross-file inputs are the project's declared
731/// `LIST`/`STRUCT` names (derivable from a range-free index projection —
732/// `declared_list_names`/`declared_struct_names` read no symbol's range)
733/// and, for `Handle<K>` (T1d-2, docs/t1d-spec.md §3), the registered host
734/// manifest — project-wide, host-set config, not file-edit-derived, so
735/// reading it here is the same coarse dependency shape `dialect` already
736/// is, not a reintroduction of whole-project churn.
737///
738/// This is the query-shaped seam `brink-db`'s `per_file_diagnostics_query`
739/// wraps: a body edit in file Y leaves file X's per-file contributor memo
740/// untouched (pinned by `fg3_dependency_edges.rs`).
741///
742/// `is_native`: the T1b dialect gate (`dialect_gate::check`, issue #1348) is
743/// an ink-only axis — a native `.brink` file has no "dialect" concept at all
744/// (its own grammar *is* the superset grammar the gate exists to police, so
745/// every construct it recognizes is ordinary native syntax, never "brink
746/// extension" syntax to reject). `true` skips the gate entirely, regardless
747/// of `dialect`'s value; every other per-file contributor is unaffected —
748/// this caller-supplied flag never widens what `per_file_diagnostics` itself
749/// needs to know (it stays as agnostic to `Language` as `dialect` already
750/// was), it only tells this one contributor whether it applies. Callers with
751/// no `Language` classification of their own (the pure `analyze_with_options`
752/// path, via [`finish_analysis`]) always pass `false`, unchanged from before
753/// this parameter existed.
754///
755/// `scope` (issue #2272): this file's own **declared-module** [`ImportScope`]
756/// — the exact scope [`resolve`] already resolves this file's references
757/// against (every caller here builds it the identical way: `analyze_with_modules`'s
758/// per-file loop / `brink-db`'s `resolve_query`, never re-derived from
759/// `hir.module` in isolation — that field carries a deliberately empty name
760/// for a native file, see `analyze_with_modules`'s own comment). Threaded
761/// through to [`annotations::check`], whose referrer-scoped struct-name
762/// lookup must agree with [`crate::resolve::resolve_type_ref`]'s own
763/// `RefKind::Type` resolution on exactly the same scope, or the two silently
764/// diverge (issue #2272's own root cause: a per-file-local re-derivation
765/// disagreed with the real declared-module identity for a std/native file).
766#[must_use]
767#[expect(
768 clippy::too_many_arguments,
769 reason = "each parameter is an independently-necessary per-file input (issue #2272 added \
770 `scope`, the file's own declared-module ImportScope) — bundling them would just \
771 move the count into a struct with no consumer of its own"
772)]
773pub fn per_file_diagnostics(
774 file: FileId,
775 hir: &HirFile,
776 file_resolutions: &ResolutionMap,
777 index: &SymbolIndex,
778 dialect: Dialect,
779 is_native: bool,
780 host_manifest: Option<&HostManifest>,
781 scope: &ImportScope,
782) -> Vec<Diagnostic> {
783 let files = [(file, hir)];
784 let mut out = validate::validate(&files);
785 if !is_native {
786 out.extend(dialect_gate::check(&files, file_resolutions, dialect));
787 }
788 // NS-A1 E107 (bare-`none`-needs-context, docs/stdlib-spec.md §1.4) —
789 // dialect-INDEPENDENT, unlike the brink-only block below: the rule is
790 // part of the Option package itself, and under `strict-ink` (where
791 // `VAR`/`CONST` initializers aren't in the gate's block-tree walk) it
792 // is also what keeps `VAR x = none` an error at all. Same per-file
793 // argument as `dialect_gate`: the resolution records consulted always
794 // carry this file's own id.
795 out.extend(option_rules::check(&files, file_resolutions));
796 // Annotation *content* checks (E061) run only under the brink
797 // dialect: under `strict-ink` the annotation is already rejected whole
798 // by `dialect_gate` (E051), and critiquing the inside of rejected
799 // syntax is noise (maintainer ruling 2026-07-13).
800 if dialect == Dialect::Brink {
801 out.extend(annotations::check(file, hir, index, host_manifest, scope));
802 // T1c `#fn` creation-site checks (E079/E080/E081) follow the same
803 // brink-only rule: under `strict-ink` the literal is already
804 // rejected whole (E051). Per-file by the same argument as
805 // `dialect_gate`: the resolution records consulted always carry
806 // this file's own id.
807 out.extend(fn_values::check(&files, file_resolutions, index));
808 // T1e-1 `ref lvalue-path` creation-site checks (E080 durable root,
809 // E097 standalone position, docs/t1e-spec.md §2/§6, issue #831) —
810 // same brink-only rule, same per-file argument as `fn_values`'s own
811 // comment just above (a reference's resolution record always
812 // carries the file the reference itself lives in).
813 out.extend(ref_projection::check(&files, file_resolutions, index));
814 // NS-A3 protocol-registry name reservation (E113, F6 ruled
815 // 2026-07-19, docs/stdlib-spec.md §9.6): `display`/`compare`/`next`
816 // are reserved method names under the brink dialect — an author
817 // declaration is a hard error, not an E035 warning. Brink-only:
818 // under strict-ink there is no protocol registry and vanilla ink
819 // identifiers stay untouched (the oracle corpus is out of reach by
820 // construction).
821 out.extend(protocols::check_reserved_names(&files));
822 }
823 // The three construction-literal checks below are wired WIDER than the
824 // brink-only block above on purpose (B5, issue #1464, #1103 cascade
825 // ruling (A), docs/stdlib-spec.md §9.6): `TypeName { … }` construction
826 // reaches `StructLiteral`/`MapLiteral` through the native surface
827 // (`Map { k: v }`, `Point { x: 1 }`) regardless of the (ink-only)
828 // `dialect` axis a native project happens to carry — a `.brink` file
829 // compiled under the default `strict-ink` dialect must still get these
830 // errors. Under `strict-ink` *ink* the literal sigils (`#{…}`) are
831 // already rejected whole by `dialect_gate` (E051), so nothing new fires
832 // there.
833 if dialect == Dialect::Brink || is_native {
834 // Struct construction-literal duplicate-field check (E084, issue
835 // #675) — unlike `structs::check`'s missing/extra/mistyped trio
836 // this runs under *both* `types` policies (see `structs`' module
837 // doc): a repeated field name is a structural mistake detectable
838 // from the literal alone, with no shape resolution or
839 // whole-project inference needed.
840 out.extend(structs::check_duplicates(&files));
841 // Declared-STRUCT-name-collides-with-a-reserved-type-name warning
842 // (E188, issue #1865) — same policy-independence argument as
843 // `check_duplicates` just above: a struct's own name colliding
844 // with a builtin/tower type name is a structural fact about the
845 // declaration itself, detectable with no shape resolution or
846 // whole-project inference, so it needs no `types` policy gate
847 // either. Wired at the same `dialect == Brink || is_native` gate
848 // as every other STRUCT-declaration-shaped check in this block —
849 // `STRUCT` is unreachable under `strict-ink` in the first place
850 // (already `E051`-rejected whole by `dialect_gate`), so a second
851 // diagnostic there would be the same "critiquing rejected syntax"
852 // noise the TM-2 annotation-content precedent already rules out.
853 out.extend(annotations::check_reserved_type_names(&files));
854 // Map-literal key-domain warning (E106, issue #598,
855 // docs/t1b-surface-spec.md §3) — same policy-independence
856 // `structs::check_duplicates` documents: a statically-visible
857 // non-key-domain literal key is a structural authoring mistake
858 // detectable from the literal alone, no shape resolution or
859 // whole-project inference needed.
860 out.extend(map_keys::check(&files));
861 // Map-literal duplicate-key error (E138, B5 issue #1464, #1103
862 // cascade ruling (A)).
863 out.extend(map_keys::check_duplicate_keys(&files));
864 }
865 // Native bare-name fn values (issue #1862): the `.brink` half of the
866 // T1c creation-site discipline. Keyed off `is_native` alone rather than
867 // the block above's `dialect == Brink || is_native`, because the rule
868 // it enforces only exists on the native surface — see
869 // [`fn_values::check_native_bare_refs`]'s own doc. (`check` above stays
870 // where it is: `#fn` is the brink-*dialect* spelling and is not
871 // reachable from `.brink` source at all.)
872 if is_native {
873 out.extend(fn_values::check_native_bare_refs(
874 &files,
875 file_resolutions,
876 index,
877 ));
878 }
879 // Inline-markup vocabulary checks (E164/E165, issue #1733,
880 // docs/prose-dialect-spec.md §4.2). Wired *outside* every dialect
881 // branch above on purpose: markup spans are a native-grammar
882 // construct, so the ink-only `dialect` axis has nothing to say about
883 // them, and the pass is inert for ink source by construction (no
884 // `ContentPart::Span` can exist there). Inert for native source too
885 // unless the host manifest actually declares a markup vocabulary —
886 // freeform is the default (§4.2), and `markup_check::check` returns
887 // before touching the HIR when nothing is declared.
888 out.extend(markup_check::check(&[(file, hir)], host_manifest));
889 out
890}
891
892/// Collect inline `///` docs across all files, keyed by `(kind, declared
893/// name)` — the project-wide doc merge feeding the external/callable/value
894/// enrichment passes. Exposed as its own seam (issue #750 / FG-3
895/// completion) so `brink-db` can memoize it behind an `Eq`-cutoff query:
896/// [`DocBlock`] carries no ranges, so any edit that leaves every `///`
897/// block's parsed content intact backdates the memo even though the pass
898/// reads every file's manifest.
899#[must_use]
900pub fn project_inline_docs(
901 files: &[(FileId, &SymbolManifest)],
902) -> BTreeMap<(SymbolKind, String), DocBlock> {
903 collect_inline_docs(files)
904}
905
906/// The index-driven half of the external-check family (issue #750 / FG-3
907/// completion): host-manifest enrichment + checks for `EXTERNAL`s
908/// ([`external_check::analyze_externals`] — arity `E039`, unknown semantic
909/// types `E040`) followed by knot/stitch doc enrichment
910/// ([`external_check::enrich_callables`], same `E040` vocabulary), in
911/// exactly that order for both the diagnostics and the `symbol_meta`
912/// merge. Reads the index and the merged inline docs only — never any
913/// file's HIR — which is what lets `brink-db` memoize it separately from
914/// the per-file HIR walks ([`file_value_meta`] /
915/// [`file_call_site_diagnostics`]).
916#[must_use]
917pub fn external_meta_diagnostics(
918 index: &SymbolIndex,
919 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
920 opts: &AnalysisOptions,
921) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
922 let (types, registered) = manifest_maps(opts.host_manifest.as_ref());
923 let has_manifest = opts.host_manifest.is_some();
924 // Unknown-semantic-type checking (`E040`) is on when a manifest is
925 // registered, or when the severity lever is explicitly raised to `Error`
926 // (#532) — a host can opt back into strict checking with no manifest.
927 let check_unknown_types =
928 has_manifest || opts.semantic_type_check == SemanticTypeDiagnosticSeverity::Error;
929 let (mut symbol_meta, mut diagnostics) = external_check::analyze_externals(
930 index,
931 inline_docs,
932 &types,
933 ®istered,
934 opts.external_check,
935 check_unknown_types,
936 );
937
938 // Knot/stitch doc enrichment (presentational; shares the semantic-type
939 // vocabulary, so unknown types still diagnose — but only once a manifest
940 // is registered, or the severity lever is raised (#339/#532); see
941 // `resolve_type`).
942 let (callable_meta, callable_diags) = external_check::enrich_callables(
943 index,
944 inline_docs,
945 &types,
946 opts.external_check,
947 check_unknown_types,
948 );
949 diagnostics.extend(callable_diags);
950 symbol_meta.extend(callable_meta);
951
952 (symbol_meta, diagnostics)
953}
954
955/// Project the external-kind entries of an enrichment map to a name-keyed
956/// map for the call-site checks (issue #750 / FG-3 completion). Range-free
957/// by construction ([`SymbolMeta`] carries no spans), so `brink-db` can put
958/// an `Eq`-cutoff memo between the (often-invalidated, full-ranged-index-
959/// reading) enrichment pass and every file's call-site walk — the
960/// `resolution_index` playbook.
961///
962/// Fed [`external_meta_diagnostics`]'s output, this is identical to the
963/// pre-split filter over the *fully merged* `symbol_meta`: the callable
964/// ([`external_check::enrich_callables`]) and value
965/// ([`external_check::infer_value_meta`]) passes only ever key
966/// `Knot`/`Stitch` and `Variable`/`Constant`/`List` ids respectively, so no
967/// entry they add can pass the `SymbolKind::External` filter here.
968/// Same-name duplicates resolve identically too: iteration is in
969/// `DefinitionId` order in both shapes, later entries overwriting.
970#[must_use]
971pub fn call_site_metas(
972 index: &SymbolIndex,
973 metas: &BTreeMap<DefinitionId, SymbolMeta>,
974) -> BTreeMap<String, SymbolMeta> {
975 metas
976 .iter()
977 .filter_map(|(id, meta)| {
978 index.symbols.get(id).and_then(|s| {
979 (s.kind == SymbolKind::External).then(|| (s.name.clone(), meta.clone()))
980 })
981 })
982 .collect()
983}
984
985/// One file's VAR/CONST/LIST initializer/doc enrichment (issue #750 / FG-3
986/// completion — the per-file slice of [`external_check::infer_value_meta`],
987/// which `whole_project_diagnostics` used to run as one loop over every
988/// file's HIR). Purely presentational — never produces diagnostics. A
989/// declaration's initializer lives in exactly one file, so the per-file
990/// split is behavior-neutral: the whole-project result is the file-order
991/// merge of the per-file maps (later files overwrite on the — deliberately
992/// deterministic — duplicate-name id collision, exactly as the single loop
993/// did). Reads no symbol ranges from `index` (only `by_name` + `kind`), so
994/// a range-zeroed index projection serves it.
995#[must_use]
996pub fn file_value_meta(
997 file: FileId,
998 hir: &HirFile,
999 index: &SymbolIndex,
1000 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1001) -> BTreeMap<DefinitionId, SymbolMeta> {
1002 external_check::infer_value_meta(&[(file, hir)], index, inline_docs)
1003}
1004
1005/// One file's external call-site literal checks (`E041` type mismatch,
1006/// `E042` closed domain) — the per-file slice of
1007/// [`external_check::check_call_sites`] (issue #750 / FG-3 completion).
1008/// The checker only ever reads the file it is visiting plus the name-keyed
1009/// external metas, so the per-file split is behavior-neutral; the caller
1010/// owns both the [`ExternalCheckSeverity`] gate and the file-order
1011/// concatenation the single whole-project walk produced.
1012#[must_use]
1013pub fn file_call_site_diagnostics(
1014 file: FileId,
1015 hir: &HirFile,
1016 metas: &BTreeMap<String, SymbolMeta>,
1017) -> Vec<Diagnostic> {
1018 let name_to_meta: BTreeMap<&str, &SymbolMeta> = metas
1019 .iter()
1020 .map(|(name, meta)| (name.as_str(), meta))
1021 .collect();
1022 external_check::check_call_sites(&[(file, hir)], &name_to_meta)
1023}
1024
1025/// The M-2 module import + visibility checks (docs/modules-spec.md
1026/// §2/§4/§7): import well-formedness and cross-module `#@private`
1027/// reference enforcement. Purely additive — every trigger needs an
1028/// `IMPORT`/`#@private`/`#@public` construct absent from the pre-modules
1029/// world, so the oracle/tier1 corpus is untouched. Genuinely whole-project
1030/// (reads every file's HIR plus the project-wide resolutions to walk
1031/// cross-module references), so it stays a whole-project pass in
1032/// `brink-db`'s decomposed `whole_project_diagnostics_query` rather than
1033/// gaining a per-file split here (issue #750 / FG-3 completion rebase note;
1034/// a per-file slice is possible FG-4-era work if module churn is ever hot).
1035#[must_use]
1036pub fn module_diagnostics(
1037 files: &[(FileId, &HirFile)],
1038 index: &SymbolIndex,
1039 resolutions: &ResolutionMap,
1040) -> Vec<Diagnostic> {
1041 modules::check(files, index, resolutions)
1042}
1043
1044/// The strict typed-mode pass (docs/typed-mode-spec.md §1/§9-step-3),
1045/// extracted from `whole_project_diagnostics`'s body (issue #750 / FG-3
1046/// completion) so `brink-db` can run it without also paying for the
1047/// external-check family's inputs. Returns empty under `types = gradual` —
1048/// byte-identical, forever.
1049///
1050/// `types = strict` requires `dialect = brink` — a config error (`E064`)
1051/// otherwise, reported alone (nothing else strict-specific runs against a
1052/// project whose dialect already rejects the annotation syntax strict mode
1053/// needs). Under `dialect = brink`, runs inference (reusing
1054/// `strict_inference` when the caller already computed one — see
1055/// [`whole_project_diagnostics`]'s doc) and wires in Unknown/Conflicted-
1056/// escape (`E065`/`E066`) plus `E063` mismatches.
1057///
1058/// `is_native` (issue #1348): `E064` is [`strict::config_error`]'s dialect
1059/// check, and `dialect` is an ink-only axis — a native `.brink` project has
1060/// no dialect to be wrong about, so `true` skips the `config_error` call
1061/// entirely and always proceeds straight to the inference-driven checks
1062/// below (never a config error for native, regardless of `opts.dialect`).
1063/// Same "caller-supplied, never widens this function's own knowledge" shape
1064/// as [`per_file_diagnostics`]'s own `is_native` — the pure path (via
1065/// [`whole_project_diagnostics`]) always passes `false`.
1066///
1067/// `inline_docs` (issue #805): forwarded to [`infer::infer_project`]'s own
1068/// `EXTERNAL`-signature seeding when `strict_inference` isn't already
1069/// supplied — the pure/self-contained fallback path only; `brink-db`'s
1070/// production seam always supplies `strict_inference` (its FG-narrowed
1071/// `type_inference_query`, which reads `inline_docs_query` itself through
1072/// `solve_scc_query`), so this parameter is inert there. Kept required
1073/// (rather than defaulted away) so the pure path stays composed-equals-
1074/// monolithic with the salsa one for every caller, not just the memoized
1075/// production one.
1076#[must_use]
1077pub fn strict_diagnostics(
1078 files: &[(FileId, &HirFile)],
1079 index: &SymbolIndex,
1080 resolutions: &ResolutionMap,
1081 opts: &AnalysisOptions,
1082 is_native: bool,
1083 strict_inference: Option<&InferenceResult>,
1084 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1085) -> Vec<Diagnostic> {
1086 let mut diagnostics = Vec::new();
1087 if opts.type_policy() == TypePolicy::Strict {
1088 let config_err = if is_native {
1089 None
1090 } else {
1091 strict::config_error(opts.dialect, files.first().map(|&(f, _)| f))
1092 };
1093 if let Some(diag) = config_err {
1094 diagnostics.push(diag);
1095 } else {
1096 let owned_inference;
1097 let inference = if let Some(inf) = strict_inference {
1098 inf
1099 } else {
1100 owned_inference = infer::infer_project(
1101 files,
1102 index,
1103 resolutions,
1104 opts.host_manifest.as_ref(),
1105 inline_docs,
1106 );
1107 &owned_inference
1108 };
1109 diagnostics.extend(strict::check(
1110 files,
1111 index,
1112 inference,
1113 resolutions,
1114 opts.host_manifest.as_ref(),
1115 ));
1116 // Issue #1004: escape-check each registered `EXTERNAL`
1117 // declaration's own param types against the manifest/inline-doc
1118 // signatures. `strict::check` above only walks `hir.knots`, so a
1119 // manifest-typed external param would otherwise never be verified
1120 // — resolved types stay clean, an unresolvable `ManifestParam.ty`
1121 // reports `E065` at the external's own declaration span. Seeded
1122 // from the same `collect_external_sigs` resolution that feeds
1123 // call-site argument checking; runs on this shared
1124 // `strict_diagnostics` seam so the pure `analyze_with_options`
1125 // path and `brink-db`'s query path get byte-identical output.
1126 let external_sigs =
1127 infer::collect_external_sigs(index, opts.host_manifest.as_ref(), inline_docs);
1128 diagnostics.extend(strict::check_external_escapes(index, &external_sigs));
1129 }
1130 }
1131 diagnostics
1132}
1133
1134/// Whole-project diagnostic contributors that genuinely need cross-file
1135/// state (issue #632 / FG-3 design doc §1), now composed of the same
1136/// per-pass seams `brink-db`'s decomposed queries wrap (issue #750 / FG-3
1137/// completion) — [`module_diagnostics`], [`strict_diagnostics`],
1138/// [`external_meta_diagnostics`], per-file [`file_value_meta`], and per-file
1139/// [`file_call_site_diagnostics`] behind [`call_site_metas`] — in exactly
1140/// the pre-split order, so the query-composed result is identical to this
1141/// monolithic one by construction (pinned by `query_equivalence.rs`).
1142///
1143/// `strict_inference`: TM-3's strict pass needs a whole-project
1144/// [`InferenceResult`] (docs/typed-mode-spec.md §9-step-3 — E063 auto-wiring
1145/// "must run inference anyway"). Pass `None` to have this function compute
1146/// its own via [`infer_project`] (the self-contained default —
1147/// [`analyze_with_options`]'s pure, non-salsa path). Pass `Some` to reuse an
1148/// already-computed result instead — `brink-db` supplies its FG-narrowed,
1149/// per-SCC-memoized `type_inference_query` here so strict mode's
1150/// warm-reanalyze cost is the incremental one the FG spine exists for, not a
1151/// from-scratch whole-project solve on every keystroke. Ignored entirely
1152/// under `types = gradual` or when the dialect makes strict mode a config
1153/// error. The `types = strict` + wrong-dialect config error (`E064`) is
1154/// computed exactly once, inside [`strict_diagnostics`] (issue #632's
1155/// TM-3-interaction fence).
1156///
1157/// `is_native` (issue #1358): every file is native (`.brink`) source, so the
1158/// ink-only `E064` config error is skipped — forwarded verbatim to
1159/// [`strict_diagnostics`], whose own `is_native` doc has the reasoning
1160/// (issue #1348). `brink-db`'s `whole_project_diagnostics_query` passes its
1161/// own `project_is_native` answer at the same seam, but that answer is
1162/// entry-anchored — it reads `false` whenever the db has no entry set — so
1163/// it does not automatically agree with a caller-computed `is_native` for a
1164/// db that never calls `set_entry` (e.g. `IdeSession`'s editor/LSP analysis
1165/// path, as opposed to `IdeSession::compile`). Callers of this function are
1166/// responsible for supplying an `is_native` that actually matches their file
1167/// set.
1168#[must_use]
1169pub fn whole_project_diagnostics(
1170 files: &[(FileId, &HirFile, &SymbolManifest)],
1171 index: &SymbolIndex,
1172 resolutions: &ResolutionMap,
1173 opts: &AnalysisOptions,
1174 is_native: bool,
1175 strict_inference: Option<&InferenceResult>,
1176) -> (Vec<Diagnostic>, BTreeMap<DefinitionId, SymbolMeta>) {
1177 let manifest_inputs: Vec<(FileId, &SymbolManifest)> = files
1178 .iter()
1179 .map(|&(id, _hir, manifest)| (id, manifest))
1180 .collect();
1181 let hir_inputs: Vec<(FileId, &HirFile)> = files.iter().map(|&(id, hir, _)| (id, hir)).collect();
1182
1183 // Computed once, up front (moved ahead of `strict_diagnostics`, issue
1184 // #805): both the TM-3 strict pass's `EXTERNAL`-signature seeding and
1185 // the host-manifest enrichment pass below need the project-wide merged
1186 // `///` doc map.
1187 let inline_docs = collect_inline_docs(&manifest_inputs);
1188
1189 // M-2 module import + visibility checks (docs/modules-spec.md
1190 // §2/§4/§7), first in diagnostic order.
1191 let mut diagnostics = module_diagnostics(&hir_inputs, index, resolutions);
1192
1193 // TM-3 strict typed-mode policy. Gradual mode returns empty here —
1194 // byte-identical, forever.
1195 diagnostics.extend(strict_diagnostics(
1196 &hir_inputs,
1197 index,
1198 resolutions,
1199 opts,
1200 is_native,
1201 strict_inference,
1202 &inline_docs,
1203 ));
1204
1205 // Host-manifest enrichment + checks (tooling/author-time only) — the
1206 // index-driven half: externals (E039/E040), then callables.
1207 let (mut symbol_meta, ext_diags) = external_meta_diagnostics(index, &inline_docs, opts);
1208 diagnostics.extend(ext_diags);
1209
1210 // Name-keyed external metas for the call-site checks — built before the
1211 // value-meta merge, which is identical to the pre-split post-merge
1212 // filter (see `call_site_metas`'s doc for the argument).
1213 let cs_metas = call_site_metas(index, &symbol_meta);
1214
1215 // VAR/CONST initializer info + LIST docs (presentational, no
1216 // diagnostics), merged in file order.
1217 for &(file_id, hir, _) in files {
1218 symbol_meta.extend(file_value_meta(file_id, hir, index, &inline_docs));
1219 }
1220
1221 // Call-site literal checks (type mismatch, closed domain) over the HIR,
1222 // in file order. Externals only — knot/stitch metadata is
1223 // presentational, not binding.
1224 if opts.external_check != ExternalCheckSeverity::Off {
1225 for &(file_id, hir, _) in files {
1226 diagnostics.extend(file_call_site_diagnostics(file_id, hir, &cs_metas));
1227 }
1228 }
1229
1230 // T2-2 `#@effects(…)` exceedance check (docs/effects-spec.md §10, issue
1231 // #861) — brink-only, same TM-2 "content checks skip strict-ink"
1232 // precedent `per_file_diagnostics` documents (the directive is already
1233 // rejected whole by `dialect_gate`'s `E051` under strict-ink). Only pays
1234 // for `effects_project`'s whole-project inference when at least one
1235 // assertion actually exists anywhere in the project — an unannotated
1236 // project stays effects-inference-free, matching T2-1's advisory-only
1237 // posture.
1238 //
1239 // The FS-2 `await`-condition purity gate (E105,
1240 // docs/flow-suspension-spec.md §3/§5, issue #928) rides the same
1241 // whole-project effect table and the same brink-only + laziness posture:
1242 // it needs `effects_project`'s rows to judge a condition's transitive
1243 // effect, so both passes share one inference when *either* an `#@effects`
1244 // assertion or an `await` appears anywhere in the project.
1245 // The NS-A4 comparator-contract gate (E119, docs/stdlib-spec.md §4b,
1246 // issue #1110 — extended to the fn-value verb trio `map`/`filter`/
1247 // `fold` by issue #1679, §4) rides the same whole-project effect table
1248 // with the same brink-only + laziness posture: a project with no
1249 // `sort_by`/`sorted_by`/`map`/`filter`/`fold` site whose callback is an
1250 // inline `#fn` literal or (issue #1887) a native bare-name reference
1251 // never triggers effect inference for it.
1252 let needs_effects = hir_inputs.iter().any(|&(_, hir)| {
1253 hir_has_effects_assertion(hir)
1254 || await_purity::hir_has_await(hir)
1255 || comparator_contract::hir_has_comparator_site(hir)
1256 });
1257 if opts.dialect == Dialect::Brink && needs_effects {
1258 let rows =
1259 infer::effects_project(&hir_inputs, index, resolutions, opts.host_manifest.as_ref());
1260 for &(file_id, hir) in &hir_inputs {
1261 // Import-scoped resolution (issue #881, the T2 follow-up to
1262 // M-2d/#790): the assertion's own `reads`/`writes`/`calls` clause
1263 // names must resolve through this file's own declared module +
1264 // imports, exactly like every other reference does — see
1265 // `effects_assertions::check`'s doc.
1266 let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
1267 diagnostics.extend(effects_assertions::check(
1268 file_id, hir, index, &scope, &rows,
1269 ));
1270 // The `await` purity gate resolves each condition's calls through
1271 // this file's own resolution records (`resolutions` carries file
1272 // provenance, filtered inside `await_purity::check`).
1273 diagnostics.extend(await_purity::check(file_id, hir, index, resolutions, &rows));
1274 // The NS-A4 comparator-contract gate (E119) — same resolution
1275 // discipline, judging the comparators of `sort_by`/`sorted_by`
1276 // and the fn-value verb trio's callbacks (`map`/`filter`/
1277 // `fold`, issue #1679) — named by an inline `#fn(target)`
1278 // literal or, since issue #1887, a native bare-name reference —
1279 // against their target's row.
1280 diagnostics.extend(comparator_contract::check(
1281 file_id,
1282 hir,
1283 index,
1284 resolutions,
1285 &rows,
1286 ));
1287 }
1288 }
1289
1290 // #2179 the `@[convention]` no-world-reads fence (`E182`,
1291 // docs/decision-log.md 2026-08-06 "No-world-reads fence: analyzer
1292 // effect-row check; unclassified externals are diagnosed"). Lazy on
1293 // the same shape every other pass here uses: a file with no declared
1294 // claim handler (`hir.claim_handlers.is_empty()`) is skipped inside
1295 // `no_world_reads::check` itself, so a project with no `@[convention]`
1296 // handler anywhere pays nothing. `symbol_meta` is already the fully
1297 // merged externals table by this point (value metas merged above, but
1298 // `no_world_reads` only reads externals' `kind`, which
1299 // `external_meta_diagnostics` alone already populated).
1300 for &(file_id, hir, _) in files {
1301 diagnostics.extend(no_world_reads::check(
1302 file_id,
1303 hir,
1304 &hir_inputs,
1305 index,
1306 resolutions,
1307 &symbol_meta,
1308 ));
1309 }
1310
1311 // B3a UFCS resolution (issue #1482, D1–D5 RULED 2026-07-26). Only the
1312 // diagnostics land here; the verdict side table itself is served to LIR
1313 // lowering and the IDE through [`ufcs_resolution`], which runs the same
1314 // pass over the same inputs.
1315 //
1316 // Dialect-independent, for the reason `ufcs`' module doc gives: a
1317 // multi-segment `Expr::Call` path can only originate in the native
1318 // frontend, so the gate is structural rather than policy-driven — the
1319 // ink corpus never reaches this pass. Lazy on the same argument as
1320 // `needs_effects` above: a project with no dotted-callee call anywhere
1321 // pays nothing.
1322 if hir_inputs
1323 .iter()
1324 .any(|&(_, hir)| ufcs::project_has_ufcs_call(hir))
1325 {
1326 let owned_inference;
1327 let inference = if let Some(inf) = strict_inference {
1328 inf
1329 } else {
1330 owned_inference = infer::infer_project(
1331 &hir_inputs,
1332 index,
1333 resolutions,
1334 opts.host_manifest.as_ref(),
1335 &inline_docs,
1336 );
1337 &owned_inference
1338 };
1339 let (_table, ufcs_diags) = ufcs::resolve(&hir_inputs, index, resolutions, inference);
1340 diagnostics.extend(ufcs_diags);
1341 }
1342
1343 (diagnostics, symbol_meta)
1344}
1345
1346/// The B3a UFCS verdict side table for a project (issue #1482, D2): the
1347/// `node → resolved target` channel LIR lowering reads to choose between
1348/// emitting a call through a field's value and emitting the desugared free
1349/// call `name(recv, args)`, and that IDE hover/go-to-def reads to name the
1350/// real target of a method-call-shaped site.
1351///
1352/// Split out from [`whole_project_diagnostics`] — which keeps the same
1353/// pass's *diagnostics* — because the two consumers want opposite halves of
1354/// one result and neither should pay for the other's.
1355#[must_use]
1356pub fn ufcs_resolution(
1357 files: &[(FileId, &HirFile)],
1358 index: &SymbolIndex,
1359 resolutions: &ResolutionMap,
1360 inference: &InferenceResult,
1361) -> (UfcsTable, Vec<Diagnostic>) {
1362 ufcs::resolve(files, index, resolutions, inference)
1363}
1364
1365/// The B1 `or`-coalescing typing side table for a project (issue #1492):
1366/// the `chain root → per-step operand/result types` channel LIR lowering
1367/// reads to choose a chain's code shape — "inner stays `Option`" vs
1368/// "unwrap at the end" — instead of re-deriving the answer from syntax it
1369/// cannot see through (a call's return type, a `VAR`'s declared type).
1370///
1371/// Keyed by [`brink_ir::hir::expr_span`] of the chain root, the derivation
1372/// both sides share — since issue #1517, the root `Expr::Infix`'s own
1373/// `Provenance` range, so every chain root in a file is separately
1374/// addressable. See [`CoalesceChain`] for the step order and `coalesce`'s
1375/// module doc for why absence (an ill-typed chain the pass abandoned) is
1376/// always safe — the consumer falls back to the runtime check, which is
1377/// what gradual mode does regardless.
1378///
1379/// Split out from [`whole_project_diagnostics`] — which keeps the same
1380/// pass's `E066` *diagnostics* — exactly as [`ufcs_resolution`] is, and for
1381/// the same reason: the two consumers want opposite halves of one result.
1382///
1383/// Unlike [`ufcs_resolution`] (whose diagnostics run unconditionally inside
1384/// [`whole_project_diagnostics`]), the `E066` diagnostics this function
1385/// also returns are **strict-mode-only by convention, not by construction**:
1386/// production code reaches them only from `strict::check`, after
1387/// `strict::config_error` has confirmed `types = strict` + `dialect =
1388/// brink` (see `coalesce::resolve`'s own doc for that entry condition), but
1389/// this function itself performs no such gate — it walks every file
1390/// unconditionally. A caller that surfaces its `Vec<Diagnostic>` without
1391/// re-checking `type_policy`/`dialect` itself would emit strict-only
1392/// `E066` under `types = gradual`.
1393#[must_use]
1394pub fn coalesce_types(
1395 files: &[(FileId, &HirFile)],
1396 index: &SymbolIndex,
1397 inference: &InferenceResult,
1398 resolutions: &ResolutionMap,
1399) -> (CoalesceTable, Vec<Diagnostic>) {
1400 coalesce::resolve(files, index, inference, resolutions)
1401}
1402
1403/// Owned form of [`brink_ir::lir::AnalyzerTables`] (issue #1527) — every
1404/// analyzer side-table LIR lowering reads, held by value instead of by the
1405/// borrowed references `AnalyzerTables` itself carries. A caller builds one
1406/// of these (via [`assemble_analyzer_tables`]) and then borrows its fields
1407/// into an `AnalyzerTables` at the lowering call site, exactly as
1408/// `brink-db`'s two salsa queries already borrow their own owned
1409/// `UfcsLookup`/`CoalesceLookup` locals.
1410#[derive(Debug, Clone, Default)]
1411pub struct AnalyzerTablesOwned {
1412 /// B3a UFCS (issue #1506) — see [`brink_ir::lir::UfcsLookup`]'s own doc.
1413 pub ufcs: brink_ir::lir::UfcsLookup,
1414 /// B1 `or`-coalescing (issue #1492) — see [`brink_ir::lir::CoalesceLookup`]'s own doc.
1415 pub coalesce: brink_ir::lir::CoalesceLookup,
1416}
1417
1418impl AnalyzerTablesOwned {
1419 /// Borrow this owned bundle into the [`brink_ir::lir::AnalyzerTables`]
1420 /// lowering actually takes — the one place that borrow is assembled
1421 /// (issue #1528's review finding). Field-by-field construction at each
1422 /// call site meant a third `AnalyzerTables` field would compile-error at
1423 /// the call site instead of here, and the cheapest silencer there is a
1424 /// throwaway default value rather than actually wiring the new table —
1425 /// exactly the silent-empty-table failure this whole function exists to
1426 /// prevent. Keeping the borrow here means a new field's compile error
1427 /// lands next to this assembly instead.
1428 #[must_use]
1429 pub fn as_tables(&self) -> brink_ir::lir::AnalyzerTables<'_> {
1430 brink_ir::lir::AnalyzerTables {
1431 ufcs: &self.ufcs,
1432 coalesce: &self.coalesce,
1433 }
1434 }
1435}
1436
1437/// Assemble every analyzer side-table LIR lowering needs, from scratch, in
1438/// one whole-project pass — **the one path a caller with no salsa layer of
1439/// its own must use** (issue #1528).
1440///
1441/// Before this function existed, `brink-test-harness`'s `corpus.rs` hand-
1442/// rolled this assembly itself: one `if project_has_*` block per table,
1443/// each independently re-running [`infer_project`] — a *third* parallel
1444/// implementation of the same gate-then-translate pattern `brink-db`'s two
1445/// salsa queries (`ufcs_resolution_query`, `coalesce_types_query`) already
1446/// each implement for their own table. That meant a future side-table (the
1447/// v6/Step work) had to be *remembered* in three places at once — miss the
1448/// harness's copy and lowering there silently got an empty table for it: a
1449/// compiling, green-tested, wrong-coverage bug, the same silent-drop class
1450/// this repo always treats as a bug. Extending *this* function is the fix
1451/// for every salsa-free caller: it is the one place such a caller's
1452/// gate+translate needs adding, mirroring how
1453/// [`brink_ir::lir::AnalyzerTables`] (issue #1527) is the one place a
1454/// future table needs adding to lowering's own signature. `brink-db`'s two
1455/// queries stay separate `#[salsa::tracked]` functions on purpose — each
1456/// needs its own independent memoization/backdating cutoff, which a single
1457/// bundled query would collapse — but both continue to call the exact same
1458/// translation primitives this function composes
1459/// ([`ufcs_resolution`]/[`coalesce_types`]/[`ufcs_lir_lookup`]/
1460/// [`coalesce_lir_lookup`]), so the two paths can't drift on *how* a table
1461/// is computed, only on *when* (memoized vs. every call).
1462///
1463/// Lazy exactly like each table already was individually: [`infer_project`]
1464/// runs at most once — shared across every table that needs it, unlike the
1465/// old per-table harness blocks which each ran their own copy — and only if
1466/// some table's structural gate ([`project_has_ufcs_call`] or
1467/// [`project_has_coalesce`]) found something to resolve. A project using
1468/// neither feature (every ink-dialect project, by construction — both
1469/// features are native-frontend-only) pays nothing and returns the
1470/// all-empty default.
1471#[must_use]
1472pub fn assemble_analyzer_tables(
1473 files: &[(FileId, &HirFile)],
1474 index: &SymbolIndex,
1475 resolutions: &ResolutionMap,
1476 host_manifest: Option<&HostManifest>,
1477 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1478) -> AnalyzerTablesOwned {
1479 let needs_ufcs = files
1480 .iter()
1481 .any(|&(_, hir)| ufcs::project_has_ufcs_call(hir));
1482 let needs_coalesce = files
1483 .iter()
1484 .any(|&(_, hir)| coalesce::project_has_coalesce(hir));
1485
1486 let inference = if needs_ufcs || needs_coalesce {
1487 Some(infer::infer_project(
1488 files,
1489 index,
1490 resolutions,
1491 host_manifest,
1492 inline_docs,
1493 ))
1494 } else {
1495 None
1496 };
1497
1498 let ufcs = match (&inference, needs_ufcs) {
1499 (Some(inference), true) => {
1500 let (table, _ufcs_diagnostics) = ufcs_resolution(files, index, resolutions, inference);
1501 ufcs_lir_lookup(&table)
1502 }
1503 _ => brink_ir::lir::UfcsLookup::new(),
1504 };
1505
1506 let coalesce = match (&inference, needs_coalesce) {
1507 (Some(inference), true) => {
1508 let (table, _e066_diagnostics) = coalesce_types(files, index, inference, resolutions);
1509 coalesce_lir_lookup(&table)
1510 }
1511 _ => brink_ir::lir::CoalesceLookup::new(),
1512 };
1513
1514 AnalyzerTablesOwned { ufcs, coalesce }
1515}
1516
1517/// Cheap structural scan: does any knot/stitch in `hir` carry a
1518/// `#@effects(…)` assertion? The laziness gate for
1519/// [`whole_project_diagnostics`]'s exceedance pass — avoids running
1520/// [`infer::effects_project`] at all for a project that never uses the
1521/// directive.
1522fn hir_has_effects_assertion(hir: &HirFile) -> bool {
1523 hir.knots.iter().any(|k| {
1524 k.effects_assertion.is_some() || k.stitches.iter().any(|s| s.effects_assertion.is_some())
1525 })
1526}
1527
1528/// Assemble the final [`AnalysisResult`] from the already-computed layer-2
1529/// pieces (index + per-file resolutions), running the remaining passes:
1530/// per-file diagnostic contributors ([`per_file_diagnostics`]) for every
1531/// file, then the whole-project contributors
1532/// ([`whole_project_diagnostics`]).
1533///
1534/// Query-shaped seam for the scripting substrate: `brink-db`'s salsa
1535/// `analysis_query` composes [`symbol_index`] and per-file [`resolve`]
1536/// queries, then the decomposed per-file/whole-project queries this
1537/// function's two halves wrap (issue #632 / FG-3) — the same sequence this
1538/// function runs, in the same order, so the query-composed result is
1539/// identical to the monolithic one by construction (pinned by
1540/// `query_equivalence.rs`).
1541///
1542/// `is_native`: every file in `files` is native (`.brink`) source — see
1543/// [`analyze_with_modules`]'s own `is_native` doc for the full list of arms
1544/// it selects (issue #1358). Forwarded to [`per_file_diagnostics`] and
1545/// [`whole_project_diagnostics`], and it is what makes the B0.9 strict-only
1546/// gate ([`native_strict_only_error`], `E137`) reachable from this path at
1547/// all. The analyzer has no file paths of its own, so this is a caller-
1548/// supplied classification: a caller with a `ProjectDb` reads it from there,
1549/// and one without passes `false` (the ink arm, byte-identical to this
1550/// function before the parameter existed).
1551///
1552/// `strict_inference`: see [`whole_project_diagnostics`]'s doc — forwarded
1553/// unchanged.
1554///
1555/// `scopes` (issue #2272): the same per-file declared-module [`ImportScope`]
1556/// map [`analyze_with_modules`]'s own resolution loop just built while
1557/// calling [`resolve`] — reused here rather than re-derived, so
1558/// `per_file_diagnostics`'s referrer-scoped checks agree with `resolve`'s
1559/// own resolution on identical scope. A file missing from the map (not
1560/// possible from [`analyze_with_modules`]'s only call site, which inserts
1561/// one entry per file in `files`) falls back to [`ImportScope::default`] —
1562/// the pre-#2272, map-free behavior — rather than panicking.
1563#[expect(
1564 clippy::too_many_arguments,
1565 reason = "each parameter is an independently-necessary layer-2 input this function \
1566 assembles into the final AnalysisResult (issue #2272 added `scopes`, mirroring \
1567 `per_file_diagnostics`'s own new `scope` parameter) — bundling them would just \
1568 move the count into a struct with no consumer of its own"
1569)]
1570pub fn finish_analysis(
1571 files: &[(FileId, &HirFile, &SymbolManifest)],
1572 index: Arc<SymbolIndex>,
1573 resolutions: ResolutionMap,
1574 mut diagnostics: Vec<Diagnostic>,
1575 opts: &AnalysisOptions,
1576 is_native: bool,
1577 strict_inference: Option<&infer::InferenceResult>,
1578 scopes: &BTreeMap<FileId, ImportScope>,
1579) -> AnalysisResult {
1580 let default_scope = ImportScope::default();
1581 for &(file_id, hir, _manifest) in files {
1582 let file_resolutions: ResolutionMap = resolutions
1583 .iter()
1584 .filter(|r| r.file == file_id)
1585 .cloned()
1586 .collect();
1587 let scope = scopes.get(&file_id).unwrap_or(&default_scope);
1588 diagnostics.extend(per_file_diagnostics(
1589 file_id,
1590 hir,
1591 &file_resolutions,
1592 &index,
1593 opts.dialect,
1594 is_native,
1595 opts.host_manifest.as_ref(),
1596 scope,
1597 ));
1598 if is_native {
1599 // The B0.9 native strict-only gate, in the same per-file
1600 // position `brink-db`'s `per_file_diagnostics_query` runs it
1601 // (right after the per-file contributors for that file), so the
1602 // composed and monolithic paths stay order-identical.
1603 diagnostics.extend(native_strict_only_error(file_id, opts.types));
1604 }
1605 }
1606
1607 let (whole_diagnostics, symbol_meta) = whole_project_diagnostics(
1608 files,
1609 &index,
1610 &resolutions,
1611 opts,
1612 is_native,
1613 strict_inference,
1614 );
1615 diagnostics.extend(whole_diagnostics);
1616
1617 AnalysisResult {
1618 index,
1619 resolutions,
1620 diagnostics,
1621 symbol_meta,
1622 }
1623}
1624
1625/// Collect inline `///` docs across all files, keyed by `(kind, declared name)`.
1626fn collect_inline_docs(
1627 files: &[(FileId, &SymbolManifest)],
1628) -> BTreeMap<(SymbolKind, String), DocBlock> {
1629 let mut out = BTreeMap::new();
1630 for &(_id, manifest) in files {
1631 for (key, doc) in &manifest.docs {
1632 out.insert(key.clone(), doc.clone());
1633 }
1634 }
1635 out
1636}
1637
1638/// Build lookup maps from the registered manifest: semantic types by name and
1639/// registered externals by name.
1640fn manifest_maps(
1641 manifest: Option<&HostManifest>,
1642) -> (
1643 BTreeMap<String, SemanticTypeDef>,
1644 BTreeMap<String, &ManifestExternal>,
1645) {
1646 let mut types = BTreeMap::new();
1647 let mut registered = BTreeMap::new();
1648 if let Some(manifest) = manifest {
1649 for ty in &manifest.types {
1650 types.insert(ty.name.clone(), ty.clone());
1651 }
1652 for ext in &manifest.externals {
1653 registered.insert(ext.name.clone(), ext);
1654 }
1655 }
1656 (types, registered)
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 //! End-to-end coverage for #339: host semantic types (`///` `@param`
1662 //! tags referencing host vocabulary, e.g. `actor_id`) must not block
1663 //! compilation when no `HostManifest` is registered, while a registered
1664 //! manifest keeps full checking (a genuinely unknown type still errors).
1665
1666 use std::collections::BTreeMap;
1667
1668 use brink_ir::{BaseType, HostManifest, SemanticTypeDef};
1669
1670 use super::{
1671 AnalysisOptions, Dialect, FileId, ImportScope, LintLevel, LintPolicy, ModuleMap,
1672 ProjectConfig, SemanticTypeDiagnosticSeverity, TypePolicy, analyze, analyze_with_options,
1673 per_file_diagnostics, resolve, symbol_index, validate_conventions_preset,
1674 };
1675
1676 /// The piece composition with an explicit `is_native` flag — the #1358
1677 /// pins below used to exercise the `analyze_with_modules` monolith's
1678 /// flag threading; the monolith retired with option A total
1679 /// (2026-08-24), and the flag now lives only on the pieces, so the pins
1680 /// hold the composed pieces to the same contract.
1681 fn analyze_composed(
1682 files: &[(FileId, &super::HirFile, &super::SymbolManifest)],
1683 modules: &ModuleMap,
1684 opts: &AnalysisOptions,
1685 is_native: bool,
1686 ) -> super::AnalysisResult {
1687 let manifest_inputs: Vec<_> = files.iter().map(|&(id, _hir, m)| (id, m)).collect();
1688 let (index, mut diagnostics) =
1689 super::symbol_index_with_modules(&manifest_inputs, modules, opts.dialect, is_native);
1690 let mut resolutions = brink_ir::ResolutionMap::new();
1691 let mut scopes = std::collections::BTreeMap::new();
1692 for &(file_id, hir, manifest) in files {
1693 let declared_module = match modules.get(&file_id) {
1694 Some(resolved) => resolved.declared.then(|| resolved.name.clone()),
1695 None => hir.module.as_ref().map(|m| m.name.clone()),
1696 };
1697 let scope = ImportScope::new(declared_module, &hir.imports);
1698 let (file_map, file_diags) = resolve(file_id, manifest, &index, &scope);
1699 resolutions.extend(std::sync::Arc::unwrap_or_clone(file_map));
1700 diagnostics.extend(file_diags);
1701 scopes.insert(file_id, scope);
1702 }
1703 super::finish_analysis(
1704 files,
1705 index,
1706 resolutions,
1707 diagnostics,
1708 opts,
1709 is_native,
1710 None,
1711 &scopes,
1712 )
1713 }
1714
1715 /// ink with an `EXTERNAL` whose param is typed with a host semantic type
1716 /// (`actor_id`) — exactly the `host.ink`-generated shape from the issue.
1717 const SRC: &str = "\
1718/// @param who {actor_id}
1719EXTERNAL add_state(who)
1720";
1721
1722 fn lower(src: &str) -> (brink_ir::hir::HirFile, brink_ir::SymbolManifest) {
1723 let parsed = brink_syntax::parse(src);
1724 let tree = parsed.tree();
1725 let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
1726 assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1727 (hir, manifest)
1728 }
1729
1730 #[test]
1731 fn host_semantic_type_compiles_host_free_with_no_manifest() {
1732 let (hir, manifest) = lower(SRC);
1733 // `analyze()` uses `AnalysisOptions::default()` — no host manifest —
1734 // matching the real "no HostManifest registered" consumer path
1735 // (`compileProject()` with no `setHostManifest` call).
1736 let result = analyze(&[(FileId(0), &hir, &manifest)]);
1737 assert!(
1738 result.diagnostics.is_empty(),
1739 "host-free compile must not error on unknown semantic types: {:?}",
1740 result.diagnostics
1741 );
1742 }
1743
1744 #[test]
1745 fn host_semantic_type_still_checked_once_manifest_registered() {
1746 let (hir, manifest) = lower(SRC);
1747 let host_manifest = HostManifest {
1748 markup: Vec::new(),
1749 externals: Vec::new(),
1750 types: vec![SemanticTypeDef {
1751 name: "actor_id".to_string(),
1752 base: BaseType::String,
1753 constraint: None,
1754 values: None,
1755 widget: None,
1756 }],
1757 };
1758 let opts = AnalysisOptions {
1759 host_manifest: Some(host_manifest),
1760 ..AnalysisOptions::default()
1761 };
1762 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1763 assert!(
1764 result.diagnostics.is_empty(),
1765 "known semantic type resolves cleanly: {:?}",
1766 result.diagnostics
1767 );
1768 }
1769
1770 #[test]
1771 fn genuinely_unknown_type_still_errors_when_manifest_registered() {
1772 // Same shape, but the registered manifest does NOT define `actor_id`
1773 // — a manifest being present makes checking fully binding again.
1774 let (hir, manifest) = lower(SRC);
1775 let opts = AnalysisOptions {
1776 host_manifest: Some(HostManifest::default()),
1777 ..AnalysisOptions::default()
1778 };
1779 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1780 assert_eq!(
1781 result
1782 .diagnostics
1783 .iter()
1784 .filter(|d| d.code == brink_ir::DiagnosticCode::E040)
1785 .count(),
1786 1,
1787 "manifest registered but type unknown: E040 still fires: {:?}",
1788 result.diagnostics
1789 );
1790 }
1791
1792 /// #532: `semantic_type_check` defaults to `Tolerant`, matching the
1793 /// #339/#527 default-tolerant behavior — an explicit `Tolerant` opt-in
1794 /// behaves identically to the unset default.
1795 #[test]
1796 fn semantic_type_check_default_is_tolerant() {
1797 let (hir, manifest) = lower(SRC);
1798 let opts = AnalysisOptions {
1799 semantic_type_check: SemanticTypeDiagnosticSeverity::Tolerant,
1800 ..AnalysisOptions::default()
1801 };
1802 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1803 assert!(
1804 result.diagnostics.is_empty(),
1805 "Tolerant (default) with no manifest: no E040: {:?}",
1806 result.diagnostics
1807 );
1808 }
1809
1810 /// #532: raising `semantic_type_check` to `Error` re-enables strict
1811 /// checking even with no manifest registered — a host can catch typo'd
1812 /// semantic-type tags before wiring up a full manifest.
1813 #[test]
1814 fn semantic_type_check_error_diagnoses_with_no_manifest() {
1815 let (hir, manifest) = lower(SRC);
1816 let opts = AnalysisOptions {
1817 semantic_type_check: SemanticTypeDiagnosticSeverity::Error,
1818 ..AnalysisOptions::default()
1819 };
1820 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1821 assert_eq!(
1822 result
1823 .diagnostics
1824 .iter()
1825 .filter(|d| d.code == brink_ir::DiagnosticCode::E040)
1826 .count(),
1827 1,
1828 "Error with no manifest: E040 still fires: {:?}",
1829 result.diagnostics
1830 );
1831 }
1832
1833 /// #532: the lever composes with a registered manifest that defines the
1834 /// type — a known type never diagnoses regardless of severity.
1835 #[test]
1836 fn semantic_type_check_error_with_known_type_in_manifest_is_clean() {
1837 let (hir, manifest) = lower(SRC);
1838 let host_manifest = HostManifest {
1839 markup: Vec::new(),
1840 externals: Vec::new(),
1841 types: vec![SemanticTypeDef {
1842 name: "actor_id".to_string(),
1843 base: BaseType::String,
1844 constraint: None,
1845 values: None,
1846 widget: None,
1847 }],
1848 };
1849 let opts = AnalysisOptions {
1850 host_manifest: Some(host_manifest),
1851 semantic_type_check: SemanticTypeDiagnosticSeverity::Error,
1852 ..AnalysisOptions::default()
1853 };
1854 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1855 assert!(
1856 result.diagnostics.is_empty(),
1857 "known type resolves cleanly regardless of severity: {:?}",
1858 result.diagnostics
1859 );
1860 }
1861
1862 // ── TM-3 (#619): strict policy end-to-end through analyze_with_options ──
1863
1864 fn lower_one(src: &str) -> (brink_ir::hir::HirFile, brink_ir::SymbolManifest) {
1865 let parsed = brink_syntax::parse(src);
1866 let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &parsed.tree());
1867 assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1868 (hir, manifest)
1869 }
1870
1871 /// The dialect-keyed default (issue #1127, ruled 2026-07-19). Under
1872 /// `strict-ink`, an unset `types` resolves gradual FOREVER — the same
1873 /// source, `types` never set, must produce results identical to a build
1874 /// that predates TM-3 entirely: no `E064`/`E065`/`E066`, and `E063`
1875 /// stays un-auto-invoked (the #618/PR#640 ruling, untouched — the
1876 /// oracle corpus is anchored to this). Under `brink`, the same unset
1877 /// `types` now resolves strict, so the Unknown-escape check fires;
1878 /// explicit `Gradual` remains the opt-out knob and restores silence.
1879 #[test]
1880 fn types_default_is_dialect_keyed() {
1881 let src = "=== noop(x) ===\nHello.\n-> DONE\n";
1882 let (hir, manifest) = lower_one(src);
1883
1884 // strict-ink + unset types: gradual, byte-identical forever.
1885 let opts = AnalysisOptions {
1886 dialect: Dialect::StrictInk,
1887 ..AnalysisOptions::default()
1888 };
1889 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1890 assert!(
1891 result.diagnostics.is_empty(),
1892 "strict-ink (default types = gradual) must stay silent: {:?}",
1893 result.diagnostics
1894 );
1895
1896 // brink + unset types: strict — the Unknown-escape check fires.
1897 let opts = AnalysisOptions {
1898 dialect: Dialect::Brink,
1899 ..AnalysisOptions::default()
1900 };
1901 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1902 assert!(
1903 result
1904 .diagnostics
1905 .iter()
1906 .any(|d| d.code == brink_ir::DiagnosticCode::E065),
1907 "brink (default types = strict) must flag the Unknown escape: {:?}",
1908 result.diagnostics
1909 );
1910
1911 // brink + explicit gradual: the opt-out knob restores silence.
1912 let opts = AnalysisOptions {
1913 dialect: Dialect::Brink,
1914 types: Some(TypePolicy::Gradual),
1915 ..AnalysisOptions::default()
1916 };
1917 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1918 assert!(
1919 result.diagnostics.is_empty(),
1920 "brink + explicit gradual opt-out must stay silent: {:?}",
1921 result.diagnostics
1922 );
1923 }
1924
1925 #[test]
1926 fn strict_with_strict_ink_dialect_is_a_config_error_and_nothing_else_runs() {
1927 let src = "=== noop(x) ===\nHello.\n-> DONE\n";
1928 let (hir, manifest) = lower_one(src);
1929 let opts = AnalysisOptions {
1930 dialect: Dialect::StrictInk,
1931 types: Some(TypePolicy::Strict),
1932 ..AnalysisOptions::default()
1933 };
1934 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1935 let strict_diags: Vec<_> = result
1936 .diagnostics
1937 .iter()
1938 .filter(|d| {
1939 matches!(
1940 d.code,
1941 brink_ir::DiagnosticCode::E064
1942 | brink_ir::DiagnosticCode::E065
1943 | brink_ir::DiagnosticCode::E066
1944 )
1945 })
1946 .collect();
1947 assert_eq!(
1948 strict_diags.len(),
1949 1,
1950 "exactly the one config error, nothing else: {:?}",
1951 result.diagnostics
1952 );
1953 assert_eq!(strict_diags[0].code, brink_ir::DiagnosticCode::E064);
1954 }
1955
1956 #[test]
1957 fn strict_with_brink_dialect_surfaces_unknown_escape_as_a_compile_error() {
1958 let src = "=== noop(x) ===\nHello.\n-> DONE\n";
1959 let (hir, manifest) = lower_one(src);
1960 let opts = AnalysisOptions {
1961 dialect: Dialect::Brink,
1962 types: Some(TypePolicy::Strict),
1963 ..AnalysisOptions::default()
1964 };
1965 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1966 assert!(
1967 result
1968 .diagnostics
1969 .iter()
1970 .any(|d| d.code == brink_ir::DiagnosticCode::E065),
1971 "{:?}",
1972 result.diagnostics
1973 );
1974 assert_eq!(
1975 result
1976 .diagnostics
1977 .iter()
1978 .find(|d| d.code == brink_ir::DiagnosticCode::E065)
1979 .expect("checked above")
1980 .code
1981 .severity(),
1982 brink_ir::Severity::Error,
1983 "Unknown-escape is a compile error under strict, not a warning"
1984 );
1985 }
1986
1987 #[test]
1988 fn strict_clean_project_compiles_with_no_diagnostics() {
1989 let src =
1990 "=== function heal(hp: int): int ===\n~ temp bonus: int = 5\n~ return hp + bonus\n";
1991 let (hir, manifest) = lower_one(src);
1992 let opts = AnalysisOptions {
1993 dialect: Dialect::Brink,
1994 types: Some(TypePolicy::Strict),
1995 ..AnalysisOptions::default()
1996 };
1997 let result = analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
1998 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1999 }
2000
2001 // ── per_file_diagnostics: is_native decouples the T1b dialect gate
2002 // (issue #1348) ────────────────────────────────────────────────
2003
2004 #[test]
2005 fn per_file_diagnostics_is_native_true_skips_the_dialect_gate() {
2006 // Postfix indexing is ordinary syntax in the native grammar, but a
2007 // brink-extension construct the T1b gate flags (`E051`) under ink's
2008 // default `StrictInk` dialect. Under `is_native = true` the gate must
2009 // never run, regardless of `dialect`.
2010 let (hir, manifest) = lower_one("~ x = a[0]\n");
2011 let (index, _diags) = symbol_index(&[(FileId(0), &manifest)]);
2012 let (resolutions, _diags) = resolve(FileId(0), &manifest, &index, &ImportScope::default());
2013 let diags = per_file_diagnostics(
2014 FileId(0),
2015 &hir,
2016 &resolutions,
2017 &index,
2018 Dialect::StrictInk,
2019 true,
2020 None,
2021 &ImportScope::default(),
2022 );
2023 assert!(
2024 !diags
2025 .iter()
2026 .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2027 "native must never see the ink-only dialect gate: {diags:?}"
2028 );
2029 }
2030
2031 #[test]
2032 fn per_file_diagnostics_is_native_false_unaffected_still_flags_extension_syntax() {
2033 // The `is_native = false` (ink) path is byte-identical to before
2034 // this parameter existed — same source, same `StrictInk` default,
2035 // still an `E051` extension-syntax diagnostic.
2036 let (hir, manifest) = lower_one("~ x = a[0]\n");
2037 let (index, _diags) = symbol_index(&[(FileId(0), &manifest)]);
2038 let (resolutions, _diags) = resolve(FileId(0), &manifest, &index, &ImportScope::default());
2039 let diags = per_file_diagnostics(
2040 FileId(0),
2041 &hir,
2042 &resolutions,
2043 &index,
2044 Dialect::StrictInk,
2045 false,
2046 None,
2047 &ImportScope::default(),
2048 );
2049 assert!(
2050 diags
2051 .iter()
2052 .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2053 "ink must still see the dialect gate: {diags:?}"
2054 );
2055 }
2056
2057 // ── analyze_with_modules: is_native reaches the per-file and
2058 // whole-project arms too (issue #1358) ──────────────────────────
2059
2060 /// The composed pure path — not just the `per_file_diagnostics` seam
2061 /// directly — must skip the ink-only T1b gate for native source.
2062 /// Before #1358 `analyze_with_modules`'s `is_native` reached only the
2063 /// symbol index, so this `E051` leaked into every editor surface that
2064 /// analyzes off-db.
2065 #[test]
2066 fn composed_is_native_true_skips_the_dialect_gate() {
2067 let (hir, manifest) = lower_one("~ x = a[0]\n");
2068 let result = analyze_composed(
2069 &[(FileId(0), &hir, &manifest)],
2070 &ModuleMap::new(),
2071 &AnalysisOptions::default(),
2072 true,
2073 );
2074 assert!(
2075 !result
2076 .diagnostics
2077 .iter()
2078 .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2079 "native must never see the ink-only dialect gate: {:?}",
2080 result.diagnostics
2081 );
2082 }
2083
2084 #[test]
2085 fn composed_is_native_false_unaffected_still_flags_extension_syntax() {
2086 let (hir, manifest) = lower_one("~ x = a[0]\n");
2087 let result = analyze_composed(
2088 &[(FileId(0), &hir, &manifest)],
2089 &ModuleMap::new(),
2090 &AnalysisOptions::default(),
2091 false,
2092 );
2093 assert!(
2094 result
2095 .diagnostics
2096 .iter()
2097 .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2098 "ink must still see the dialect gate: {:?}",
2099 result.diagnostics
2100 );
2101 }
2102
2103 /// `E064` rejects `types = strict` under a non-`brink` **dialect** — an
2104 /// ink-only axis. A native project carries `StrictInk` by default (it
2105 /// has no dialect opinion), so before #1358 dialing `types = strict` on
2106 /// the pure path produced this spurious project-level error.
2107 #[test]
2108 fn composed_is_native_true_skips_the_ink_only_config_error() {
2109 let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2110 let opts = AnalysisOptions {
2111 types: Some(TypePolicy::Strict),
2112 ..AnalysisOptions::default()
2113 };
2114 let result = analyze_composed(
2115 &[(FileId(0), &hir, &manifest)],
2116 &ModuleMap::new(),
2117 &opts,
2118 true,
2119 );
2120 assert!(
2121 !result
2122 .diagnostics
2123 .iter()
2124 .any(|d| d.code == brink_ir::DiagnosticCode::E064),
2125 "native has no dialect to be wrong about: {:?}",
2126 result.diagnostics
2127 );
2128 }
2129
2130 #[test]
2131 fn composed_is_native_false_unaffected_still_fires_config_error() {
2132 let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2133 let opts = AnalysisOptions {
2134 types: Some(TypePolicy::Strict),
2135 ..AnalysisOptions::default()
2136 };
2137 let result = analyze_composed(
2138 &[(FileId(0), &hir, &manifest)],
2139 &ModuleMap::new(),
2140 &opts,
2141 false,
2142 );
2143 assert!(
2144 result
2145 .diagnostics
2146 .iter()
2147 .any(|d| d.code == brink_ir::DiagnosticCode::E064),
2148 "ink must still get the config error: {:?}",
2149 result.diagnostics
2150 );
2151 }
2152
2153 /// The B0.9 strict-only gate (`E137`): explicit `types = gradual` is not
2154 /// a policy native source can be compiled under. `brink-db`'s
2155 /// `per_file_diagnostics_query` has always run it; the pure path could
2156 /// not express it at all before #1358.
2157 #[test]
2158 fn composed_is_native_true_reports_the_native_strict_only_error() {
2159 let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2160 let opts = AnalysisOptions {
2161 types: Some(TypePolicy::Gradual),
2162 ..AnalysisOptions::default()
2163 };
2164 let result = analyze_composed(
2165 &[(FileId(0), &hir, &manifest)],
2166 &ModuleMap::new(),
2167 &opts,
2168 true,
2169 );
2170 assert!(
2171 result
2172 .diagnostics
2173 .iter()
2174 .any(|d| d.code == brink_ir::DiagnosticCode::E137),
2175 "explicit `types = gradual` is a native config error: {:?}",
2176 result.diagnostics
2177 );
2178 }
2179
2180 #[test]
2181 fn composed_is_native_false_never_reports_the_native_strict_only_error() {
2182 let (hir, manifest) = lower_one("=== start ===\nHello.\n-> DONE\n");
2183 let opts = AnalysisOptions {
2184 types: Some(TypePolicy::Gradual),
2185 ..AnalysisOptions::default()
2186 };
2187 let result = analyze_composed(
2188 &[(FileId(0), &hir, &manifest)],
2189 &ModuleMap::new(),
2190 &opts,
2191 false,
2192 );
2193 assert!(
2194 !result
2195 .diagnostics
2196 .iter()
2197 .any(|d| d.code == brink_ir::DiagnosticCode::E137),
2198 "`E137` is native-only: {:?}",
2199 result.diagnostics
2200 );
2201 }
2202
2203 /// The module-blind convenience wrapper stays the ink path, byte for
2204 /// byte — it has no `Language` classification to offer.
2205 #[test]
2206 fn analyze_with_options_stays_the_ink_arm() {
2207 let (hir, manifest) = lower_one("~ x = a[0]\n");
2208 let result =
2209 analyze_with_options(&[(FileId(0), &hir, &manifest)], &AnalysisOptions::default());
2210 assert!(
2211 result
2212 .diagnostics
2213 .iter()
2214 .any(|d| d.code == brink_ir::DiagnosticCode::E051),
2215 "{:?}",
2216 result.diagnostics
2217 );
2218 }
2219
2220 // ── AnalysisOptions::apply_project_config (moved from
2221 // brink-project-config with the #1234 dependency inversion) ──────
2222
2223 #[test]
2224 fn apply_sets_unset_fields_from_config() {
2225 let mut options = AnalysisOptions::default();
2226 let config = ProjectConfig {
2227 dialect: Some(Dialect::Brink),
2228 types: Some(TypePolicy::Strict),
2229 ..ProjectConfig::default()
2230 };
2231 options.apply_project_config(&config, false, false);
2232 assert_eq!(options.dialect, Dialect::Brink);
2233 assert_eq!(options.types, Some(TypePolicy::Strict));
2234 }
2235
2236 #[test]
2237 fn apply_leaves_overridden_fields_alone() {
2238 let mut options = AnalysisOptions {
2239 dialect: Dialect::StrictInk,
2240 types: Some(TypePolicy::Gradual),
2241 ..AnalysisOptions::default()
2242 };
2243 let config = ProjectConfig {
2244 dialect: Some(Dialect::Brink),
2245 types: Some(TypePolicy::Strict),
2246 ..ProjectConfig::default()
2247 };
2248 // Both overridden: explicit calls win, file is ignored entirely.
2249 options.apply_project_config(&config, true, true);
2250 assert_eq!(options.dialect, Dialect::StrictInk);
2251 assert_eq!(options.types, Some(TypePolicy::Gradual));
2252 }
2253
2254 #[test]
2255 fn apply_mixed_override_only_touches_non_overridden_field() {
2256 let mut options = AnalysisOptions {
2257 dialect: Dialect::StrictInk,
2258 types: Some(TypePolicy::Gradual),
2259 ..AnalysisOptions::default()
2260 };
2261 let config = ProjectConfig {
2262 dialect: Some(Dialect::Brink),
2263 types: Some(TypePolicy::Strict),
2264 ..ProjectConfig::default()
2265 };
2266 // dialect explicitly overridden (stays StrictInk); types is not
2267 // (file wins, becomes Strict).
2268 options.apply_project_config(&config, true, false);
2269 assert_eq!(options.dialect, Dialect::StrictInk);
2270 assert_eq!(options.types, Some(TypePolicy::Strict));
2271 }
2272
2273 #[test]
2274 fn apply_with_no_config_values_leaves_options_untouched() {
2275 let mut options = AnalysisOptions {
2276 dialect: Dialect::Brink,
2277 types: Some(TypePolicy::Strict),
2278 ..AnalysisOptions::default()
2279 };
2280 options.apply_project_config(&ProjectConfig::default(), false, false);
2281 assert_eq!(options.dialect, Dialect::Brink);
2282 assert_eq!(options.types, Some(TypePolicy::Strict));
2283 }
2284
2285 // ── AnalysisOptions::apply_project_config: [lints] (issue #1160) ──
2286
2287 #[test]
2288 fn apply_project_config_applies_lint_overrides() {
2289 let mut options = AnalysisOptions::default();
2290 let mut config = ProjectConfig::default();
2291 config.lints.insert("E014".to_owned(), LintLevel::Deny);
2292 config.lints.insert("E022".to_owned(), LintLevel::Allow);
2293
2294 options.apply_project_config(&config, false, false);
2295
2296 assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2297 assert_eq!(options.lints.overrides.get("E022"), Some(&LintLevel::Allow));
2298 }
2299
2300 #[test]
2301 fn apply_project_config_sets_deny_warnings() {
2302 let mut options = AnalysisOptions::default();
2303 let config = ProjectConfig {
2304 deny_warnings: Some(true),
2305 ..ProjectConfig::default()
2306 };
2307
2308 options.apply_project_config(&config, false, false);
2309
2310 assert!(options.lints.deny_warnings);
2311 }
2312
2313 #[test]
2314 fn apply_project_config_absent_lints_clears_lint_policy() {
2315 let mut options = AnalysisOptions {
2316 lints: LintPolicy {
2317 overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
2318 deny_warnings: true,
2319 },
2320 ..AnalysisOptions::default()
2321 };
2322
2323 options.apply_project_config(&ProjectConfig::default(), false, false);
2324
2325 // Issue #1397: unlike `dialect`/`types`, `[lints]` REPLACES the
2326 // resolved policy rather than merging into it — an empty (or
2327 // absent) `[lints]` table resolves to no overrides and
2328 // `deny-warnings = false`, so a long-lived caller (the editor
2329 // session) that re-applies `brink.toml` after the table was deleted
2330 // actually reverts, instead of leaving the previous override stuck.
2331 assert!(
2332 options.lints.overrides.is_empty(),
2333 "an absent [lints] table must clear previously-resolved overrides"
2334 );
2335 assert!(!options.lints.deny_warnings);
2336 }
2337
2338 #[test]
2339 fn apply_project_config_omitted_code_reverts_to_base_severity() {
2340 // Simulates the editor session's live-reapply scenario (#1397): a
2341 // prior call already resolved E014 and E022 overrides plus
2342 // deny-warnings; the re-applied config only re-asserts E014 —
2343 // E022 and deny-warnings were deleted from `brink.toml` in between.
2344 let mut options = AnalysisOptions {
2345 lints: LintPolicy {
2346 overrides: BTreeMap::from([
2347 ("E014".to_owned(), LintLevel::Deny),
2348 ("E022".to_owned(), LintLevel::Allow),
2349 ]),
2350 deny_warnings: true,
2351 },
2352 ..AnalysisOptions::default()
2353 };
2354 let mut config = ProjectConfig::default();
2355 config.lints.insert("E014".to_owned(), LintLevel::Deny);
2356
2357 options.apply_project_config(&config, false, false);
2358
2359 assert_eq!(
2360 options.lints.overrides.get("E014"),
2361 Some(&LintLevel::Deny),
2362 "a code still present in the re-applied config keeps its override"
2363 );
2364 assert!(
2365 !options.lints.overrides.contains_key("E022"),
2366 "a code omitted from the re-applied config must revert to its \
2367 base severity, not stick"
2368 );
2369 assert!(
2370 !options.lints.deny_warnings,
2371 "deny-warnings omitted from the re-applied config must revert \
2372 to false, not stick"
2373 );
2374 }
2375
2376 #[test]
2377 fn apply_project_config_rejects_unknown_lint_code() {
2378 let mut options = AnalysisOptions::default();
2379 let mut config = ProjectConfig::default();
2380 // Not a real `DiagnosticCode` — never parses.
2381 config.lints.insert("E9999".to_owned(), LintLevel::Deny);
2382
2383 let warnings = options.apply_project_config(&config, false, false);
2384
2385 assert!(
2386 options.lints.overrides.is_empty(),
2387 "an unknown code must never be merged into the policy"
2388 );
2389 assert_eq!(warnings.len(), 1);
2390 assert!(warnings[0].0.contains("E9999"));
2391 }
2392
2393 #[test]
2394 fn apply_project_config_rejects_misspelled_lint_code_case() {
2395 let mut options = AnalysisOptions::default();
2396 let mut config = ProjectConfig::default();
2397 // `DiagnosticCode::from_str_code` is case-sensitive — a lowercase
2398 // spelling of a real code is not itself a real code.
2399 config.lints.insert("e014".to_owned(), LintLevel::Deny);
2400
2401 let warnings = options.apply_project_config(&config, false, false);
2402
2403 assert!(options.lints.overrides.is_empty());
2404 assert_eq!(warnings.len(), 1);
2405 assert!(warnings[0].0.contains("e014"));
2406 }
2407
2408 #[test]
2409 fn apply_project_config_rejects_non_overridable_lint_code() {
2410 let mut options = AnalysisOptions::default();
2411 let mut config = ProjectConfig::default();
2412 // E001 is a real code, but its default severity is `Error`, not
2413 // `Warning` — never reachable through `effective_severity`'s
2414 // hard-error exemption, so `[lints]` must not silently accept it.
2415 assert_eq!(
2416 brink_ir::DiagnosticCode::E001.severity(),
2417 brink_ir::Severity::Error
2418 );
2419 config.lints.insert("E001".to_owned(), LintLevel::Deny);
2420
2421 let warnings = options.apply_project_config(&config, false, false);
2422
2423 assert!(options.lints.overrides.is_empty());
2424 assert_eq!(warnings.len(), 1);
2425 assert!(warnings[0].0.contains("E001"));
2426 }
2427
2428 #[test]
2429 fn apply_project_config_reports_no_warnings_for_valid_overridable_codes() {
2430 let mut options = AnalysisOptions::default();
2431 let mut config = ProjectConfig::default();
2432 config.lints.insert("E014".to_owned(), LintLevel::Deny);
2433
2434 let warnings = options.apply_project_config(&config, false, false);
2435
2436 assert!(warnings.is_empty());
2437 }
2438
2439 /// Issue #1674: `E157`'s default severity is `Info`, not `Warning` — the
2440 /// widened `validate_lint_code` gate (anything short of `Error`) must
2441 /// still accept a `[lints] E157 = "warn"` override rather than rejecting
2442 /// it the way the pre-#1674 `Warning`-base-only gate would have.
2443 #[test]
2444 fn apply_project_config_accepts_info_base_lint_code() {
2445 let mut options = AnalysisOptions::default();
2446 let mut config = ProjectConfig::default();
2447 assert_eq!(
2448 brink_ir::DiagnosticCode::E157.severity(),
2449 brink_ir::Severity::Info
2450 );
2451 config.lints.insert("E157".to_owned(), LintLevel::Warn);
2452
2453 let warnings = options.apply_project_config(&config, false, false);
2454
2455 assert!(warnings.is_empty());
2456 assert_eq!(options.lints.overrides.get("E157"), Some(&LintLevel::Warn));
2457 }
2458
2459 // ── AnalysisOptions::apply_project_config: `[project] conventions`
2460 // preset-name validation (issue #1874; key renamed from `elements` by
2461 // #2180) ──
2462
2463 #[test]
2464 fn apply_project_config_rejects_an_unrecognized_bare_preset_name() {
2465 let mut options = AnalysisOptions::default();
2466 let config = ProjectConfig {
2467 conventions: Some("screnplay".to_owned()),
2468 ..ProjectConfig::default()
2469 };
2470
2471 let warnings = options.apply_project_config(&config, false, false);
2472
2473 assert_eq!(
2474 options.conventions, None,
2475 "an unrecognized preset name must never be carried onto \
2476 `AnalysisOptions::conventions`"
2477 );
2478 assert_eq!(warnings.len(), 1);
2479 assert!(warnings[0].0.contains("screnplay"));
2480 }
2481
2482 /// #1720 (the built-in screenplay preset) shipped its authored source
2483 /// at `std/conventions/screenplay.brink` and added `"screenplay"` to
2484 /// `BUILTIN_CONVENTION_PRESETS` — this test used to pin the opposite
2485 /// (rejected-until-shipped) reality, per its own doc comment's promise
2486 /// to update alongside the registry change. Note this proves only the
2487 /// *validation* verdict flipped — `options.conventions` being
2488 /// populated does not by itself mean anything downstream consumes it
2489 /// yet (no `std::`-module resolution or `fn conventions()`
2490 /// registration exists, #2080/#1840).
2491 ///
2492 /// A #1720 review finding caught the first version of this test
2493 /// asserting `warnings.is_empty()`: recognizing the name here is
2494 /// validation-only, and silently accepting it would leave an author
2495 /// writing `conventions = "screenplay"` with zero diagnostics and zero
2496 /// behavior (rule 19h's failure mode). `options.conventions` is still
2497 /// populated (the name is not rejected as unrecognized), but a
2498 /// not-yet-injectable warning is still surfaced on the same channel.
2499 #[test]
2500 fn apply_project_config_accepts_screenplay_preset_name_now_that_it_shipped() {
2501 let mut options = AnalysisOptions::default();
2502 let config = ProjectConfig {
2503 conventions: Some("screenplay".to_owned()),
2504 ..ProjectConfig::default()
2505 };
2506
2507 let warnings = options.apply_project_config(&config, false, false);
2508
2509 assert_eq!(options.conventions.as_deref(), Some("screenplay"));
2510 assert_eq!(warnings.len(), 1, "unexpected warnings: {warnings:?}");
2511 assert!(warnings[0].0.contains("screenplay"));
2512 assert!(warnings[0].0.contains("not injectable yet"));
2513 assert!(warnings[0].0.contains("#2080"));
2514 assert!(warnings[0].0.contains("#1840"));
2515 }
2516
2517 #[test]
2518 fn apply_project_config_accepts_a_bare_path_shaped_conventions_pointer() {
2519 let mut options = AnalysisOptions::default();
2520 let config = ProjectConfig {
2521 conventions: Some("conventions.brink".to_owned()),
2522 ..ProjectConfig::default()
2523 };
2524
2525 let warnings = options.apply_project_config(&config, false, false);
2526
2527 assert!(
2528 warnings.is_empty(),
2529 "a path-shaped pointer (`.brink` extension) must never be \
2530 rejected by the preset-name closed set — that would break the \
2531 custom-conventions-module case #1844's confinement rule is \
2532 built around"
2533 );
2534 assert_eq!(options.conventions.as_deref(), Some("conventions.brink"));
2535 }
2536
2537 #[test]
2538 fn apply_project_config_accepts_a_directory_path_shaped_conventions_pointer() {
2539 let mut options = AnalysisOptions::default();
2540 let config = ProjectConfig {
2541 conventions: Some("scenes/conventions.brink".to_owned()),
2542 ..ProjectConfig::default()
2543 };
2544
2545 let warnings = options.apply_project_config(&config, false, false);
2546
2547 assert!(warnings.is_empty());
2548 assert_eq!(
2549 options.conventions.as_deref(),
2550 Some("scenes/conventions.brink")
2551 );
2552 }
2553
2554 #[test]
2555 fn apply_project_config_leaves_conventions_unset_when_absent() {
2556 let mut options = AnalysisOptions::default();
2557 let config = ProjectConfig::default();
2558
2559 let warnings = options.apply_project_config(&config, false, false);
2560
2561 assert!(warnings.is_empty());
2562 assert_eq!(options.conventions, None);
2563 }
2564
2565 /// Issue #2180: `apply_project_config` is only ever handed an already-
2566 /// reconciled `ProjectConfig` (the deprecated `elements` alias is
2567 /// resolved into `conventions` by `brink-project-config::parse_str_at`
2568 /// before this crate ever sees it) — this proves the deprecated-alias
2569 /// value flows through this layer identically to a native
2570 /// `conventions`-keyed one.
2571 #[test]
2572 fn apply_project_config_carries_a_conventions_value_reconciled_from_the_deprecated_alias() {
2573 let mut options = AnalysisOptions::default();
2574 let (config, parse_warnings) =
2575 brink_project_config::parse_str("[project]\nelements = \"conventions.brink\"\n")
2576 .expect("deprecated `elements` key must still parse");
2577 assert_eq!(parse_warnings.len(), 1, "{parse_warnings:?}");
2578
2579 let warnings = options.apply_project_config(&config, false, false);
2580
2581 assert!(warnings.is_empty(), "{warnings:?}");
2582 assert_eq!(options.conventions.as_deref(), Some("conventions.brink"));
2583 }
2584
2585 #[test]
2586 fn validate_conventions_preset_accepts_a_name_present_in_the_registry() {
2587 // Exercises the comparison logic itself against an explicit
2588 // registry literal, decoupled from `BUILTIN_CONVENTION_PRESETS`'s
2589 // own current contents — proves the check is a real membership
2590 // test, not a hardcoded "always reject a bare name". Since #1720,
2591 // production's real constant also contains `"screenplay"` (see
2592 // `apply_project_config_accepts_screenplay_preset_name_now_that_it_shipped`),
2593 // but this test's own point survives regardless of what the
2594 // constant holds.
2595 assert!(validate_conventions_preset("screenplay", &["screenplay"]).is_ok());
2596 }
2597
2598 #[test]
2599 fn validate_conventions_preset_rejects_a_name_outside_the_registry() {
2600 assert!(validate_conventions_preset("screnplay", &["screenplay"]).is_err());
2601 }
2602
2603 // ── AnalysisOptions::apply_lint_overrides: CLI/API tier (issue #1373) ──
2604
2605 #[test]
2606 fn apply_lint_overrides_merges_per_code_overrides() {
2607 let mut options = AnalysisOptions::default();
2608 let mut overrides = BTreeMap::new();
2609 overrides.insert("E014".to_owned(), LintLevel::Deny);
2610
2611 let warnings = options.apply_lint_overrides(&overrides, None);
2612
2613 assert!(warnings.is_empty());
2614 assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2615 }
2616
2617 #[test]
2618 fn apply_lint_overrides_sets_deny_warnings() {
2619 let mut options = AnalysisOptions::default();
2620
2621 let warnings = options.apply_lint_overrides(&BTreeMap::new(), Some(true));
2622
2623 assert!(warnings.is_empty());
2624 assert!(options.lints.deny_warnings);
2625 }
2626
2627 #[test]
2628 fn apply_lint_overrides_none_deny_warnings_leaves_it_untouched() {
2629 let mut options = AnalysisOptions::default();
2630 options.lints.deny_warnings = true;
2631
2632 options.apply_lint_overrides(&BTreeMap::new(), None);
2633
2634 assert!(options.lints.deny_warnings);
2635 }
2636
2637 #[test]
2638 fn apply_lint_overrides_rejects_unknown_code() {
2639 let mut options = AnalysisOptions::default();
2640 let mut overrides = BTreeMap::new();
2641 overrides.insert("E9999".to_owned(), LintLevel::Deny);
2642
2643 let warnings = options.apply_lint_overrides(&overrides, None);
2644
2645 assert!(options.lints.overrides.is_empty());
2646 assert_eq!(warnings.len(), 1);
2647 assert!(warnings[0].0.contains("E9999"));
2648 }
2649
2650 #[test]
2651 fn apply_lint_overrides_rejects_non_overridable_code() {
2652 let mut options = AnalysisOptions::default();
2653 let mut overrides = BTreeMap::new();
2654 // E001 is a real code, but its default severity is `Error`, not
2655 // `Warning` — same non-overridability rule as the file's `[lints]`
2656 // table (#1160).
2657 overrides.insert("E001".to_owned(), LintLevel::Deny);
2658
2659 let warnings = options.apply_lint_overrides(&overrides, None);
2660
2661 assert!(options.lints.overrides.is_empty());
2662 assert_eq!(warnings.len(), 1);
2663 assert!(warnings[0].0.contains("E001"));
2664 }
2665
2666 #[test]
2667 fn apply_lint_overrides_wins_over_a_prior_apply_project_config_for_the_same_code() {
2668 let mut options = AnalysisOptions::default();
2669 let mut config = ProjectConfig::default();
2670 config.lints.insert("E014".to_owned(), LintLevel::Deny);
2671 options.apply_project_config(&config, false, false);
2672 assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Deny));
2673
2674 let mut overrides = BTreeMap::new();
2675 overrides.insert("E014".to_owned(), LintLevel::Allow);
2676 options.apply_lint_overrides(&overrides, None);
2677
2678 // The explicit override replaces the file's value for the same
2679 // code — #1005/#1373's `CLI/API > file` precedence.
2680 assert_eq!(options.lints.overrides.get("E014"), Some(&LintLevel::Allow));
2681 }
2682}