Skip to main content

brink_analyzer/
ufcs.rs

1//! B3a — UFCS (uniform function call syntax) resolution: `recv.name(args)`
2//! (issue #1482; D1–D5 RULED 2026-07-26, `docs/decision-log.md` "UFCS
3//! resolution pass designed: type-directed, in the analyzer, five rulings").
4//!
5//! ## Why this lives in `brink-analyzer`
6//!
7//! UFCS resolution is **type-directed name resolution**: field-access-wins
8//! is unanswerable without the receiver's type, so the verdict cannot be
9//! reached in the frontend or in HIR lowering. The native lowering already
10//! produces `Expr::Call(Path, args)` for a dotted callee unchanged (see
11//! `brink-ir`'s `hir::lower_native::expr::lower_call`) — this module is the
12//! pass that decides what that shape *means*.
13//!
14//! ## The algorithm, per call site
15//!
16//! For `recv.name(args)` — an `Expr::Call` whose callee `Path` has more than
17//! one segment and whose head names a *value* in scope:
18//!
19//! 1. Infer the receiver's type (`recv` = every segment but the last).
20//! 2. The type declares a field `name` → **field access wins**. The field
21//!    must be function-typed; the call is a call *through the field's
22//!    value* ([`UfcsVerdict::FieldCall`], rows per #872).
23//!    **D1**: a matching but non-callable field is a **hard error**
24//!    ([`DiagnosticCode::E140`]) — never a fall-through to a free function,
25//!    so a call's meaning never hinges on a field's type.
26//! 3. Else resolve `name` as a free function in **ordinary lexical scope
27//!    only** (D4 — no method sets, no inherent impls: any in-scope free
28//!    function is method-callable) — file `use` + the T1b/NS stdlib
29//!    prelude (`len`, `push`, `sort_by`, …) — and record the desugar to
30//!    `name(recv, args)` ([`UfcsVerdict::FreeFnDesugar`] for an index
31//!    symbol, [`UfcsVerdict::PreludeDesugar`] for a prelude verb, which has
32//!    no index symbol to point at).
33//! 4. Neither → one diagnostic naming **both** attempts
34//!    ([`DiagnosticCode::E141`]).
35//!
36//! **D3**: an unknown receiver type at the resolution point is an error
37//! demanding an annotation ([`DiagnosticCode::E142`]), *not* a deferral —
38//! there is deliberately no deferral machinery here. The improvement
39//! (smarter inference ordering) is tracked separately and is additive.
40//!
41//! **D5 — auto-ref** (issue #1462, landed on top of this pass): the desugar
42//! is by value *unless* the resolved free function's first parameter is
43//! declared `ref`. Then the receiver is passed by reference
44//! ([`UfcsVerdict::FreeFnAutoRef`]) and the desugar spells the projection
45//! explicitly — `party.members.heal(5)` → `heal(ref party.members, 5)` —
46//! riding the T1e ref-argument/projection machinery
47//! (`brink_ir::lir::lower::expr::lower_call_args`) for a **durable** root,
48//! or (**RULED 2026-07-27**, issue #1531) a frame-local read/call/
49//! write-back RMW expansion for a **frame-local** root one field deep
50//! (`brink_ir::lir::lower::blocks::try_lower_frame_local_auto_ref_stmt`) —
51//! never a parallel path for the durable case. A receiver that cannot be
52//! written through is refused with [`DiagnosticCode::E143`] rather than
53//! silently desugared by value, which would drop the mutation: see
54//! [`UfcsVisitor::auto_ref_fault`] for exactly which receivers those are. A
55//! non-`ref` first parameter is unaffected — plain by-value desugar, with
56//! no lvalue requirement on the receiver.
57//!
58//! ## Scope fences
59//!
60//! - Only the final pre-`(` segment gets this treatment; a bare `a.b` (an
61//!   `Expr::FieldAccess`, or a dotted `Expr::Path`) is untouched.
62//! - Each call in `a.b().c()` resolves independently — this pass keys
63//!   verdicts by call-site range, never by chain.
64//! - **The ink dialect is untouched by construction.** ink's own
65//!   `FunctionCall` lowering always builds a *single-segment* callee path
66//!   (`brink-ir`'s `hir::lower::expr::references`), and its computed-callee
67//!   `CallExpr` is a structural `E104`. A multi-segment `Expr::Call` path
68//!   can therefore only originate in the native frontend, so no dialect
69//!   flag is needed to keep this pass off the ink corpus.
70//! - The explicit free-call spelling (`name(recv, args)`) is unaffected.
71//!
72//! ## The side table (D2)
73//!
74//! The verdict is recorded in a **side table** keyed by node
75//! ([`SideTable`]), not written back into the HIR — HIR stays immutable,
76//! matching the analyzer's existing "inference results travel beside the
77//! tree" posture (`infer::InferenceResult`).
78//!
79//! The table is published as the seam (`brink_analyzer::ufcs_resolution`)
80//! the two ruled consumers read. **LIR lowering is wired** (issue #1506,
81//! `brink-db`'s `ufcs_resolution_query` translates this table into
82//! `brink-ir`'s own lowering-facing mirror at the query boundary) — it now
83//! emits either a call through the field's value or the desugared free
84//! call for real. A resolved site LIR lowering cannot find a verdict for
85//! (a caller that never ran this pass) still refuses with
86//! [`DiagnosticCode::E144`] rather than lowering against the receiver's own
87//! id, which would be a silently wrong program — but that is a defensive
88//! fallback now, not the unconditional behavior. IDE hover/go-to-def
89//! (issue #1507, `brink-ide`'s `ufcs_hover` module) is wired too — it reads
90//! the same memoized table (`brink_db::ProjectDb::ufcs_verdict`) to name the
91//! real target rather than the receiver the [`ResolutionMap`] records for
92//! the callee path.
93//!
94//! [`SideTable`] is deliberately generic over its payload: it is
95//! `(node → verdict)` plumbing, so a second payload kind can ride the same
96//! keying and the same lookup without a parallel structure being invented.
97//! Issue #1492 did exactly that — `crate::coalesce`'s [`CoalesceTable`]
98//! is a `SideTable<CoalesceChain>` carrying `or`-coalescing's recorded
99//! operand/result types to the same LIR-lowering consumer, on this keying,
100//! with no second mechanism.
101//!
102//! [`CoalesceTable`]: crate::CoalesceTable
103//! [`CoalesceChain`]: crate::CoalesceChain
104
105use std::collections::BTreeMap;
106
107use brink_format::DefinitionId;
108use brink_ir::hir::visit::{self, HirVisitor};
109use brink_ir::{
110    Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, Path as HirPath, ResolutionMap,
111    Stitch, SymbolIndex, SymbolKind,
112};
113use rowan::TextRange;
114
115use crate::annotations;
116use crate::infer::{InferenceResult, InferredSig, Ty, assignable, ref_assignable};
117use crate::resolve::ImportScope;
118use crate::structs::{ShapeTable, declared_shapes};
119
120// ─── The side table (D2) ─────────────────────────────────────────────
121
122/// Identity of one HIR node for side-table purposes: the file it lives in
123/// plus its source range.
124///
125/// `TextRange` has no `Ord` impl (ranges have no single natural total
126/// order), so the range travels as a `(start, end)` `u32` pair — the same
127/// `range_key` convention `infer`, `strict`, and `structs` each already use
128/// for their own range-keyed maps. A range is only unique *within* a file,
129/// hence the [`FileId`] half: side-table entries must never be merged
130/// across files.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
132pub struct NodeKey {
133    /// The file the node was lowered from.
134    pub file: FileId,
135    /// The node's source range, as `(start, end)`.
136    pub range: (u32, u32),
137}
138
139impl NodeKey {
140    /// The key for a node at `range` in `file`.
141    #[must_use]
142    pub fn new(file: FileId, range: TextRange) -> Self {
143        Self {
144            file,
145            range: (range.start().into(), range.end().into()),
146        }
147    }
148}
149
150/// A `(node → payload)` side channel: analysis verdicts recorded *beside*
151/// the HIR rather than written into it (D2 — the HIR stays immutable).
152///
153/// Generic over the payload so a second kind of verdict can ride the same
154/// plumbing instead of a parallel structure being invented for it. Backed by
155/// a `BTreeMap` so iteration order is deterministic (house rule — never
156/// iterate a `HashMap` where order affects output); [`Self::iter`] is what a
157/// consumer that wants *every* verdict (e.g. an IDE building an overlay)
158/// walks.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct SideTable<V> {
161    entries: BTreeMap<NodeKey, V>,
162}
163
164impl<V> Default for SideTable<V> {
165    fn default() -> Self {
166        Self {
167            entries: BTreeMap::new(),
168        }
169    }
170}
171
172impl<V> SideTable<V> {
173    /// An empty table.
174    #[must_use]
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Record `value` for the node at `key`, returning any previous entry.
180    pub fn insert(&mut self, key: NodeKey, value: V) -> Option<V> {
181        self.entries.insert(key, value)
182    }
183
184    /// The payload recorded for the node at `key`, if any.
185    #[must_use]
186    pub fn get(&self, key: NodeKey) -> Option<&V> {
187        self.entries.get(&key)
188    }
189
190    /// The payload recorded for the node at `range` in `file`, if any — the
191    /// convenience spelling for a consumer holding an HIR node rather than a
192    /// pre-built [`NodeKey`].
193    #[must_use]
194    pub fn at(&self, file: FileId, range: TextRange) -> Option<&V> {
195        self.get(NodeKey::new(file, range))
196    }
197
198    /// Every recorded entry, in deterministic `(file, range)` order.
199    pub fn iter(&self) -> impl Iterator<Item = (NodeKey, &V)> {
200        self.entries.iter().map(|(k, v)| (*k, v))
201    }
202
203    /// How many nodes carry a payload.
204    #[must_use]
205    pub fn len(&self) -> usize {
206        self.entries.len()
207    }
208
209    /// Whether no node carries a payload.
210    #[must_use]
211    pub fn is_empty(&self) -> bool {
212        self.entries.is_empty()
213    }
214}
215
216/// What one `recv.name(args)` call site resolved to (D2's "node → resolved
217/// target"). Consumed by LIR lowering — which of the two code shapes to
218/// emit — and by IDE hover/go-to-def, which needs the *real* target rather
219/// than the receiver the [`ResolutionMap`] records for the callee path.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum UfcsVerdict {
222    /// Field access won (step 2): the receiver's type declares a
223    /// function-typed field with the called name, so the call is a call
224    /// *through that field's value*.
225    FieldCall {
226        /// The receiver's inferred type.
227        receiver: Ty,
228        /// The field name — the call's final pre-`(` path segment.
229        field: String,
230        /// The field's declared type. Always a [`Ty::Fn`] — a
231        /// non-callable match is `E140`, never a verdict.
232        field_ty: Ty,
233        /// Issue #1918: this verdict's own arity fact. Unlike
234        /// [`Self::FreeFnDesugar`]/[`Self::FreeFnAutoRef`], a field call has
235        /// no receiver-prepending desugar — `npc.on_greet(3)` lowers
236        /// straight to `lir::ExprKind::CallValue { callee, args }` calling the
237        /// field's own `fn(...)` value with the *written* arguments only
238        /// (`brink_ir::lir::lower::expr::lower_ufcs_call`'s `FieldCall`
239        /// arm), so a mismatch here is a plain expected/got pair, not a
240        /// per-argument [`UfcsArgMismatch`]. Computed unconditionally
241        /// alongside this verdict, like every other verdict's own arg-check
242        /// fields; reported only by strict mode ([`check_strict`], `E063`)
243        /// — this verdict is structurally `strict::check_value_calls`'s T1c
244        /// "call through a function value" domain, just reached via field
245        /// access, and this reuses that check's own
246        /// `ValueCallKind::ArityMismatch` wording verbatim. Gradual mode
247        /// relies on the runtime `FunctionValueArity` fault
248        /// `Opcode::CallValue` already raises for every call through a
249        /// function value — the same bytecode shape this verdict lowers
250        /// to, so arity is enforced there regardless of static policy.
251        arity_mismatch: Option<UfcsArityMismatch>,
252        /// Issue #1918: this verdict's own per-argument type mismatches —
253        /// the `FieldCall` sibling of [`Self::FreeFnDesugar`]'s own
254        /// `arg_mismatches`. **Differs from that sibling's index
255        /// convention**: a field call passes no receiver argument, so
256        /// `index` here is 0-based over the *written* arguments only —
257        /// matching `strict::check_value_calls`'s own
258        /// `ValueCallKind::ArgMismatch` convention, not
259        /// [`UfcsArgMismatch::index`]'s "receiver counts as 0" default (see
260        /// that field's own doc for the exception this carves out).
261        /// Reported only by strict mode (`E063`), same gate as
262        /// `arity_mismatch` above.
263        arg_mismatches: Vec<UfcsArgMismatch>,
264    },
265    /// **D5 auto-ref** (issue #1462): a free function won (step 3) *and* its
266    /// first parameter is declared `ref`, so the call desugars to
267    /// `name(ref recv, args)` — the receiver spelled as an explicit T1e
268    /// ref-argument/projection, so the callee's writes land in the
269    /// receiver's own cell instead of in a copy.
270    ///
271    /// Only ever recorded for a receiver that can actually be written
272    /// through ([`UfcsVisitor::auto_ref_fault`]); anything else is `E143`.
273    FreeFnAutoRef {
274        /// The receiver's inferred type.
275        receiver: Ty,
276        /// The free function's name, as written.
277        name: String,
278        /// The definition the desugared call targets.
279        target: DefinitionId,
280        /// Issue #1881: statically-checkable argument-type mismatches
281        /// between the desugared call `name(recv, args)` and `target`'s
282        /// already-known declared param types, computed unconditionally
283        /// alongside this verdict — see [`UfcsArgMismatch`]'s own doc.
284        /// Reported only by strict mode ([`check_strict`], `E063`).
285        arg_mismatches: Vec<UfcsArgMismatch>,
286    },
287    /// A free function won (step 3): the call desugars to
288    /// `name(recv, args)`, by value.
289    FreeFnDesugar {
290        /// The receiver's inferred type.
291        receiver: Ty,
292        /// The free function's name, as written.
293        name: String,
294        /// The definition the desugared call targets.
295        target: DefinitionId,
296        /// Issue #1881: identical posture to [`Self::FreeFnAutoRef`]'s own
297        /// `arg_mismatches` field — the by-value desugar shape.
298        arg_mismatches: Vec<UfcsArgMismatch>,
299    },
300    /// A T1b/NS stdlib prelude name won (step 3, D4's "file `use` + prelude"
301    /// candidate set): the call desugars to `name(recv, args)` exactly like
302    /// [`Self::FreeFnDesugar`], but the target is a VM-native intrinsic
303    /// (`resolve::is_t1b_stdlib_name`/`resolve::is_builtin_function`), not an
304    /// index symbol — there is no [`DefinitionId`] to record. `xs.len()`,
305    /// `inventory.push(sword)`, `a.sort_by(c)` all land here.
306    PreludeDesugar {
307        /// The receiver's inferred type.
308        receiver: Ty,
309        /// The prelude function's name, as written.
310        name: String,
311        /// Issue #1919: statically-checkable argument-domain mismatches
312        /// between the desugared call `name(recv, args)` and the verb's
313        /// own container-projected domain (the receiver's element/key/
314        /// value type — a prelude verb has no [`DefinitionId`] and so no
315        /// declared param list to compare against), computed
316        /// unconditionally alongside this verdict — see
317        /// [`UfcsVisitor::check_ufcs_prelude_arg_types`]'s own doc.
318        /// Reported only by strict mode ([`check_strict`], `E063`), the
319        /// same code [`Self::FreeFnDesugar`]'s own `arg_mismatches` uses.
320        arg_mismatches: Vec<UfcsArgMismatch>,
321    },
322}
323
324/// One statically-checkable argument-type mismatch at a UFCS-desugared free
325/// function call (`recv.name(args)` → `name(recv, args)`) — issue #1881,
326/// the UFCS sibling of `infer::DirectCallArgMismatch` (#1864/PR #1875,
327/// direct calls) and `infer::TypedAssignMismatch` (#1877/PR #1899,
328/// declaration initializers and assignments). Reported by [`check_strict`]
329/// as `E063`, the same code the other two siblings use — no new code minted
330/// for this position (docs/t1c-spec.md §8's "existing TM-3 machinery"
331/// posture, extended here rather than a parallel checker).
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct UfcsArgMismatch {
334    /// The mismatched argument's 0-based position in the **desugared** call
335    /// `name(recv, args)` — `0` names the receiver itself (the desugar's
336    /// first positional slot); `i` for `i >= 1` names the `(i - 1)`-th
337    /// *written* argument. Matches the "receiver counts as the first
338    /// argument" convention this call site's own arity-mismatch diagnostic
339    /// already uses (see [`UfcsVisitor::try_free_fn_desugar`]).
340    ///
341    /// **Exception (issue #1918):** a [`UfcsVerdict::FieldCall`]'s own
342    /// `arg_mismatches` does not follow this convention — a field call has
343    /// no receiver-prepending desugar, so `index` there is 0-based over the
344    /// *written* arguments only (`0` names the first written argument, not
345    /// the receiver); see that variant's own field doc.
346    pub index: usize,
347    /// The desugared target's declared parameter type at `index`.
348    pub expected: Ty,
349    /// The receiver's (`index == 0`) or written argument's statically
350    /// classified type.
351    pub found: Ty,
352}
353
354/// A [`UfcsVerdict::FieldCall`]'s own arity fact (issue #1918) — a call
355/// through a struct's fn-typed field expects/supplies a plain count, not a
356/// per-argument type, so this is a separate fact from [`UfcsArgMismatch`]
357/// rather than a shoehorned entry in that list. See that verdict's own
358/// `arity_mismatch` field doc for the full rationale (why this is checked
359/// at all, and why it's strict-mode-only).
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub struct UfcsArityMismatch {
362    /// The field's declared `fn(T…): R` row's own parameter count.
363    pub expected: usize,
364    /// The call site's own written argument count.
365    pub got: usize,
366}
367
368/// Every UFCS call site's verdict for one project.
369pub type UfcsTable = SideTable<UfcsVerdict>;
370
371/// Translate a [`UfcsTable`] into `brink-ir`'s own lowering-facing mirror
372/// (`brink_ir::lir::UfcsLookup`/`UfcsVerdict`) — issue #1506's one
373/// conversion point, so the `UfcsVerdict` → `brink_ir::lir::UfcsVerdict`
374/// mapping lives in exactly one place rather than once per caller.
375/// `brink-ir` sits below this crate in the crate graph (this crate depends
376/// on `brink-ir`, never the reverse), so it cannot provide this itself —
377/// see `brink_ir::lir::UfcsVerdict`'s own doc. Every LIR-lowering caller
378/// shares this: `brink-db`'s `ufcs_resolution_query` (the production path)
379/// and [`assemble_analyzer_tables`](crate::assemble_analyzer_tables) — the
380/// salsa-free path used by `brink-test-harness`
381/// (`corpus::compile_and_explore_from_brink_native`) and any other caller
382/// with no salsa layer of its own to memoize the table in.
383#[must_use]
384pub fn to_lir_lookup(table: &UfcsTable) -> brink_ir::lir::UfcsLookup {
385    let entries = table
386        .iter()
387        .map(|(key, verdict)| {
388            let range = TextRange::new(key.range.0.into(), key.range.1.into());
389            let mirrored = match verdict {
390                UfcsVerdict::FieldCall { .. } => brink_ir::lir::UfcsVerdict::FieldCall,
391                UfcsVerdict::FreeFnAutoRef { target, .. } => {
392                    brink_ir::lir::UfcsVerdict::FreeFnAutoRef { target: *target }
393                }
394                UfcsVerdict::FreeFnDesugar { target, .. } => {
395                    brink_ir::lir::UfcsVerdict::FreeFnDesugar { target: *target }
396                }
397                UfcsVerdict::PreludeDesugar { name, .. } => {
398                    brink_ir::lir::UfcsVerdict::PreludeDesugar { name: name.clone() }
399                }
400            };
401            (key.file, range, mirrored)
402        })
403        .collect();
404    brink_ir::lir::UfcsLookup::from_entries(entries)
405}
406
407// ─── The pass ────────────────────────────────────────────────────────
408
409/// Resolve every UFCS-shaped call in the project, returning the verdict
410/// side table plus the diagnostics the four outcomes above produce.
411///
412/// `inference` supplies the receiver types (the pass is type-directed by
413/// construction); `resolutions` identifies which dotted callee paths are
414/// UFCS-shaped at all — a path already resolving to a knot/stitch/external
415/// is an ordinary qualified call and is left completely alone.
416///
417/// Callers gate this on [`project_has_ufcs_call`] so a project without a
418/// single dotted-callee call never pays for whole-project inference on this
419/// pass's account.
420///
421/// **Issue #2096** (the `ufcs.rs` half of #1774's re-verification
422/// remainder — `comparator_contract.rs`'s own copy of the same gap was
423/// fixed by #2085): this used to drive [`UfcsVisitor`] with plain
424/// [`visit::visit`], which never reaches a file-level `VAR`/`CONST`
425/// initializer — so a UFCS-shaped call inside a decl-default lambda's own
426/// body (`const callGreet = |g| g.greet(3)`, legal since #1774's ruling)
427/// was never visited by this pass at all, and fell through to LIR
428/// lowering's defensive `E144` fallback (`brink_ir::lir::lower::expr`'s own
429/// doc). **The shared-visitor question (issue #1571/#2098), re-asked for
430/// this pass's own shape**: unlike `comparator_contract`'s hand-rolled
431/// `collect_sites`/`collect_expr` walk (which is not `HirVisitor`-driven at
432/// all, and so could not adopt the shared entry point without a larger
433/// refactor), [`UfcsVisitor`] already *is* a [`HirVisitor`] driven by
434/// `visit::visit` — the exact shape [`visit::visit_with_decl_initializers`]
435/// was built to extend. Switching costs one line and needs no new
436/// `enter_var_decl`/`enter_const_decl` hooks: `current_knot_name`/
437/// `knot_body`/`stitch_body` are already reset to `None` by every
438/// `exit_knot`/`exit_stitch`, and `lambda_locals` is empty once every lambda
439/// pushed during the block-tree walk has been popped — so by the time the
440/// walk reaches the file-level declarations (which
441/// `visit_with_decl_initializers` visits *after* the block tree), this
442/// visitor's state is already exactly what it was before any knot ran, the
443/// same "no state needs resetting" case `structs::check`'s own #2098 switch
444/// documents. See `structs::check`'s identical switch for the precedent.
445#[must_use]
446pub fn resolve(
447    files: &[(FileId, &HirFile)],
448    index: &SymbolIndex,
449    resolutions: &ResolutionMap,
450    inference: &InferenceResult,
451) -> (UfcsTable, Vec<Diagnostic>) {
452    let shapes = declared_shapes(files, index);
453    let globals = crate::infer::collect_globals(files, index, None);
454    let mut table = UfcsTable::new();
455    let mut diagnostics = Vec::new();
456
457    for &(file, hir) in files {
458        let resolution_by_range = resolution_index(resolutions, file);
459        let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
460        let mut v = UfcsVisitor {
461            file,
462            index,
463            scope: &scope,
464            shapes: &shapes,
465            globals: &globals,
466            bodies: &inference.bodies,
467            signatures: &inference.signatures,
468            resolution_by_range: &resolution_by_range,
469            current_knot_name: None,
470            knot_body: None,
471            stitch_body: None,
472            lambda_locals: Vec::new(),
473            table: &mut table,
474            diagnostics: &mut diagnostics,
475        };
476        visit::visit_with_decl_initializers(hir, &mut v);
477    }
478
479    (table, diagnostics)
480}
481
482/// The **strict-mode-only** diagnostics that fall out of the verdict table:
483///
484/// - Issue #1540 (second symptom): a typed check keyed on an intrinsic's
485///   receiver must see the UFCS spelling of that intrinsic too.
486///
487///   `infer::body::infer_call` deliberately branches away for a
488///   multi-segment callee *before* `infer_intrinsic` runs (a UFCS receiver
489///   is not the thing being called, so classifying it as a
490///   call-through-a-value would be a false `E066` on every legal method
491///   call — see that function's own note). Issue #1909 later gave that
492///   branch a result type for the *free-function* desugar
493///   (`infer::body::InferPass::infer_ufcs_free_fn_result`), but
494///   deliberately not for a prelude verb, precisely because routing one
495///   through `infer_intrinsic` would record the `array_remove_calls` fact
496///   below a second time and double-report this very `E149`. The
497///   consequence stands: `arr.remove(0)` records none of the facts
498///   `remove(arr, 0)` records, so
499///   every intrinsic-receiver diagnostic silently stopped at the free-call
500///   spelling. This pass is where the UFCS spelling gets them back: the
501///   verdict table already carries the receiver's resolved `Ty` next to the
502///   verb's name, which is exactly the `(receiver type, verb)` pair those
503///   checks key on — no second inference, and no `TypePolicy` threaded into
504///   [`resolve`] (which stays policy-independent, as LIR lowering and the
505///   IDE need it to be).
506///
507/// - Issue #1881: a `FreeFnDesugar`/`FreeFnAutoRef` verdict's own
508///   `arg_mismatches` (computed unconditionally alongside the verdict by
509///   [`UfcsVisitor::try_free_fn_desugar`]) — the UFCS sibling of
510///   `strict::check_direct_call_args` (#1864/PR #1875) and
511///   `strict::check_typed_assign_mismatches` (#1877/PR #1899): a UFCS
512///   receiver resolves to a *value*, so `InferenceResult::signatures` has
513///   no entry for it the way a direct call's callee does, which is exactly
514///   why this class of mismatch couldn't be checked by extending either of
515///   those two passes — the resolved free-function *target*'s signature is
516///   only ever available here, where this pass has already resolved it.
517///
518/// - Issue #1919: a `PreludeDesugar` verdict's own `arg_mismatches`
519///   ([`UfcsVisitor::check_ufcs_prelude_arg_types`]) — the prelude sibling
520///   of the `FreeFnDesugar` bullet above, for the collection verbs whose
521///   domain is a plain container projection (`xs.push(v)`, `m.get(k)`, and
522///   the rest of that family). `remove`'s array leg stays the hand-written
523///   `E149` check just below rather than folding into this fact: the two
524///   are disjoint diagnostic families keyed on the same `(receiver, name)`
525///   pair, not a double-report risk.
526///
527/// - Issue #1918: a `FieldCall` verdict's own `arity_mismatch`/
528///   `arg_mismatches` ([`UfcsVisitor::check_field_call_args`]) — left
529///   uncovered by #1881/PR #1914, which deliberately scoped to the
530///   `FreeFnDesugar`/`FreeFnAutoRef` bullet above and flagged this gap in
531///   review rather than filing it (issue comment on #1881). Structurally
532///   `strict::check_value_calls`'s T1c "call through a function value"
533///   domain, reached via field access instead of a bare name — a call
534///   through the field's `Ty::Fn` value directly, with no receiver-
535///   prepending desugar, so it gets its own arity fact
536///   ([`UfcsArityMismatch`]) rather than folding into a per-argument
537///   `UfcsArgMismatch` at index `0`.
538///
539/// Strict-mode-only **by convention, not by construction**, exactly like
540/// `coalesce::resolve`'s `E066` half: production reaches this only from
541/// `strict::check`, after `strict::config_error` has confirmed
542/// `types = strict` + `dialect = brink`. A caller that surfaces these
543/// without that gate would emit strict-only codes under `types = gradual`.
544///
545/// Gated on [`project_has_ufcs_call`] internally so a project with no
546/// dotted-callee call anywhere pays nothing — the same laziness
547/// `whole_project_diagnostics` applies to [`resolve`]'s own diagnostics.
548#[must_use]
549pub fn check_strict(
550    files: &[(FileId, &HirFile)],
551    index: &SymbolIndex,
552    resolutions: &ResolutionMap,
553    inference: &InferenceResult,
554) -> Vec<Diagnostic> {
555    if !files.iter().any(|&(_, hir)| project_has_ufcs_call(hir)) {
556        return Vec::new();
557    }
558    // The unconditional `E140`–`E144` half is discarded here: it is already
559    // reported by `whole_project_diagnostics`' own call to `resolve`, and
560    // double-reporting it under strict would be a regression.
561    let (table, _unconditional) = resolve(files, index, resolutions, inference);
562    table
563        .iter()
564        .flat_map(|(key, verdict)| strict_verdict_diagnostics(key, verdict))
565        .collect()
566}
567
568/// One verdict's strict-mode diagnostics, if it has any.
569///
570/// `E149` (issue #1540) — `remove` went map-only in issue #1484 with no
571/// compatibility shim, so an array receiver means the site wants
572/// `remove_at`. The free-call spelling of this exact check lives in
573/// `strict::check_array_remove_calls`, reading the fact
574/// `infer::body`'s `remove` arm records; the two spellings must agree, so
575/// the receiver test here (`Ty::Array`) is deliberately the same one.
576///
577/// `E063` (issue #1881, widened to `PreludeDesugar` by issue #1919, and to
578/// `FieldCall` by issue #1918) — every recorded [`UfcsArgMismatch`] on a
579/// `FreeFnDesugar`/`FreeFnAutoRef`/`PreludeDesugar`/`FieldCall` verdict,
580/// reported the same way `strict::check_direct_call_args` reports
581/// `DirectCallArgMismatch` — plus, for `FieldCall` alone, its own
582/// [`UfcsArityMismatch`] (that verdict has no receiver-prepending desugar to
583/// fold an arity fact into `UfcsArgMismatch`'s index-`0` slot the way the
584/// other three verdicts do), phrased identically to
585/// `strict::check_value_calls`'s own `ValueCallKind::ArityMismatch` — the
586/// T1c "call through a value" domain this verdict structurally is.
587///
588/// Every future collection-typed check that keys on `(receiver type, verb)`
589/// belongs in this match rather than in a parallel walk — that is the point
590/// of routing through the verdict table at all.
591fn strict_verdict_diagnostics(key: NodeKey, verdict: &UfcsVerdict) -> Vec<Diagnostic> {
592    match verdict {
593        UfcsVerdict::PreludeDesugar {
594            receiver,
595            name,
596            arg_mismatches,
597        } => {
598            let mut out: Vec<Diagnostic> = arg_mismatches
599                .iter()
600                .map(|mismatch| ufcs_arg_mismatch_diagnostic(key, name, mismatch))
601                .collect();
602            if let ("remove", Ty::Array(_)) = (name.as_str(), receiver) {
603                out.push(Diagnostic {
604                    file: key.file,
605                    range: TextRange::new(key.range.0.into(), key.range.1.into()),
606                    message: DiagnosticCode::E149.title().to_owned(),
607                    code: DiagnosticCode::E149,
608                });
609            }
610            out
611        }
612        UfcsVerdict::FreeFnDesugar {
613            name,
614            arg_mismatches,
615            ..
616        }
617        | UfcsVerdict::FreeFnAutoRef {
618            name,
619            arg_mismatches,
620            ..
621        } => arg_mismatches
622            .iter()
623            .map(|mismatch| ufcs_arg_mismatch_diagnostic(key, name, mismatch))
624            .collect(),
625        UfcsVerdict::FieldCall {
626            field,
627            arity_mismatch,
628            arg_mismatches,
629            ..
630        } => {
631            let mut out: Vec<Diagnostic> = Vec::new();
632            if let Some(arity) = arity_mismatch {
633                out.push(field_call_arity_diagnostic(key, field, *arity));
634            }
635            out.extend(
636                arg_mismatches
637                    .iter()
638                    .map(|mismatch| field_call_arg_mismatch_diagnostic(key, field, mismatch)),
639            );
640            out
641        }
642    }
643}
644
645/// A [`UfcsVerdict::FieldCall`]'s own [`UfcsArgMismatch`] as a diagnostic
646/// — issue #1918. Like [`field_call_arity_diagnostic`] just below, the
647/// wording matches `strict::check_value_calls`'s own
648/// `ValueCallKind::ArgMismatch` phrasing exactly ("call **through**", the
649/// T1c domain this verdict structurally is) — deliberately NOT
650/// [`ufcs_arg_mismatch_diagnostic`]'s desugared-call "call to" phrasing,
651/// so both halves of a `FieldCall` verdict (arity and argument type) speak
652/// with one voice. `mismatch.index` here is 0-based over the *written*
653/// arguments (no receiver prepend — see [`UfcsArgMismatch::index`]'s
654/// Exception paragraph), so `+ 1` yields the same 1-based "argument N"
655/// numbering `check_value_calls` reports.
656fn field_call_arg_mismatch_diagnostic(
657    key: NodeKey,
658    field: &str,
659    mismatch: &UfcsArgMismatch,
660) -> Diagnostic {
661    Diagnostic {
662        file: key.file,
663        range: TextRange::new(key.range.0.into(), key.range.1.into()),
664        message: format!(
665            "argument {} of call through `{field}` has type `{}` but its known type expects `{}`",
666            mismatch.index + 1,
667            mismatch.found.display(),
668            mismatch.expected.display(),
669        ),
670        code: DiagnosticCode::E063,
671    }
672}
673
674/// A [`UfcsVerdict::FieldCall`]'s own [`UfcsArityMismatch`] as a diagnostic
675/// — issue #1918. The message wording matches
676/// `strict::check_value_calls`'s own `ValueCallKind::ArityMismatch`
677/// phrasing exactly (the T1c "call through a value" sibling this verdict
678/// structurally is), naming the field rather than a bare callee name.
679fn field_call_arity_diagnostic(
680    key: NodeKey,
681    field: &str,
682    mismatch: UfcsArityMismatch,
683) -> Diagnostic {
684    Diagnostic {
685        file: key.file,
686        range: TextRange::new(key.range.0.into(), key.range.1.into()),
687        message: format!(
688            "call through `{field}` supplies {got} argument(s) but its known type expects \
689             {expected}",
690            got = mismatch.got,
691            expected = mismatch.expected,
692        ),
693        code: DiagnosticCode::E063,
694    }
695}
696
697/// One [`UfcsArgMismatch`] as a diagnostic — the message wording matches
698/// `strict::check_direct_call_args`'s own `E063` phrasing exactly (the
699/// direct-call sibling this parallels), just against the *desugared* call's
700/// own argument numbering (`index` `0` is the receiver).
701fn ufcs_arg_mismatch_diagnostic(
702    key: NodeKey,
703    name: &str,
704    mismatch: &UfcsArgMismatch,
705) -> Diagnostic {
706    Diagnostic {
707        file: key.file,
708        range: TextRange::new(key.range.0.into(), key.range.1.into()),
709        message: format!(
710            "argument {} of call to `{name}` has type `{}` but its known type expects `{}`",
711            mismatch.index + 1,
712            mismatch.found.display(),
713            mismatch.expected.display(),
714        ),
715        code: DiagnosticCode::E063,
716    }
717}
718
719/// Cheap structural scan: does any call in `hir` have a multi-segment
720/// callee path? The laziness gate for [`resolve`]'s caller — a project
721/// (every ink project, by construction; see the module doc) with no
722/// dotted-callee call never triggers whole-project inference on this pass's
723/// account, mirroring `whole_project_diagnostics`' own `needs_effects`
724/// gate.
725///
726/// Issue #2096: must see a decl-default lambda's own body too, or the
727/// laziness gate itself would skip [`resolve`] entirely for a project whose
728/// only UFCS-shaped call sits inside one — the exact fix [`resolve`]'s own
729/// walk just got would never run. `visit::visit_with_decl_initializers`
730/// (not plain `visit::visit`), same reasoning as that doc.
731#[must_use]
732pub fn project_has_ufcs_call(hir: &HirFile) -> bool {
733    struct Scan {
734        found: bool,
735    }
736    impl HirVisitor for Scan {
737        fn visit_exprs(&self) -> bool {
738            true
739        }
740        fn enter_expr(&mut self, expr: &Expr) {
741            if let Expr::Call(path, _) = expr
742                && path.segments.len() > 1
743            {
744                self.found = true;
745            }
746        }
747    }
748    let mut scan = Scan { found: false };
749    visit::visit_with_decl_initializers(hir, &mut scan);
750    scan.found
751}
752
753/// This file's own reference resolutions, projected to a range-keyed lookup
754/// — mirrors `structs::resolution_index` (a `Path`'s range is only unique
755/// within its own file).
756fn resolution_index(
757    resolutions: &ResolutionMap,
758    file: FileId,
759) -> BTreeMap<(u32, u32), DefinitionId> {
760    resolutions
761        .iter()
762        .filter(|r| r.file == file)
763        .map(|r| ((r.range.start().into(), r.range.end().into()), r.target))
764        .collect()
765}
766
767/// Walks one file's knot/stitch bodies, tracking the enclosing def's
768/// finalized locals so a receiver's head segment can be typed. Structurally
769/// a twin of `structs::ConstructionVisitor` — same `enter_knot`/
770/// `enter_stitch` locals bookkeeping, for the same reason (`BodyTypes` is
771/// keyed by def, `locals` by name).
772struct UfcsVisitor<'a> {
773    file: FileId,
774    index: &'a SymbolIndex,
775    scope: &'a ImportScope,
776    shapes: &'a ShapeTable,
777    globals: &'a BTreeMap<DefinitionId, Ty>,
778    bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
779    /// Every inferable def's finalized signature (issue #1881) — the UFCS
780    /// desugar's *target* (a knot/stitch, never the receiver) has its
781    /// declared param types here, the same firewall-facing projection a
782    /// direct call's `known_sigs` lookup reads. See
783    /// [`UfcsVisitor::try_free_fn_desugar`]'s own argument-type check.
784    signatures: &'a BTreeMap<DefinitionId, InferredSig>,
785    resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
786    current_knot_name: Option<String>,
787    /// The enclosing knot's own `BodyTypes` (issue #1881 widened this from
788    /// `locals` alone to the whole `BodyTypes`, so [`Self::current_body`]
789    /// can also read back the enclosing def's own recorded
790    /// `ufcs_call_args` — see [`Self::current_locals`] for the `locals`
791    /// projection every earlier call site still wants).
792    knot_body: Option<&'a crate::infer::BodyTypes>,
793    stitch_body: Option<&'a crate::infer::BodyTypes>,
794    /// Issue #2773: a stack of pruned-locals frames, one per currently-open
795    /// lambda literal (innermost last). Mirrors
796    /// `structs::ConstructionVisitor`'s identical field/hook pair exactly —
797    /// see that field's own doc. Composes with the same
798    /// `structs::pruned_locals_for_lambda` helper even though this visitor
799    /// has no `MistypeCtx` of its own — the helper takes the raw
800    /// `index`/`outer_locals` pair, not a `MistypeCtx`, for exactly this
801    /// reason.
802    lambda_locals: Vec<BTreeMap<String, Ty>>,
803    table: &'a mut UfcsTable,
804    diagnostics: &'a mut Vec<Diagnostic>,
805}
806
807impl HirVisitor for UfcsVisitor<'_> {
808    fn visit_exprs(&self) -> bool {
809        true
810    }
811
812    fn enter_knot(&mut self, knot: &Knot) {
813        self.current_knot_name = Some(knot.name.text.clone());
814        self.knot_body =
815            annotations::def_id_for(self.index, self.file, knot.symbol_kind(), &knot.name.text)
816                .and_then(|id| self.bodies.get(&id));
817    }
818
819    fn exit_knot(&mut self, _knot: &Knot) {
820        self.current_knot_name = None;
821        self.knot_body = None;
822    }
823
824    fn enter_stitch(&mut self, stitch: &Stitch) {
825        self.stitch_body = self.current_knot_name.as_ref().and_then(|knot_name| {
826            let qualified = format!("{knot_name}.{}", stitch.name.text);
827            annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
828                .and_then(|id| self.bodies.get(&id))
829        });
830    }
831
832    fn exit_stitch(&mut self, _stitch: &Stitch) {
833        self.stitch_body = None;
834    }
835
836    fn enter_expr(&mut self, expr: &Expr) {
837        if let Expr::Call(path, args) = expr {
838            self.resolve_call(path, args.len());
839        }
840    }
841
842    fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
843        let pruned = crate::structs::pruned_locals_for_lambda(l, self.index, self.current_locals());
844        self.lambda_locals.push(pruned);
845    }
846
847    fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
848        self.lambda_locals.pop();
849    }
850}
851
852/// The receiver half of one UFCS call site, resolved: everything the two
853/// resolution steps and D5's auto-ref gate need to know about `recv` in
854/// `recv.name(args)`.
855struct Receiver<'a> {
856    /// The definition the head segment resolved to (a param/temp/`VAR`/
857    /// `CONST` — [`UfcsVisitor::value_receiver_def`]).
858    def: DefinitionId,
859    /// Every segment before the final pre-`(` one, head first.
860    segments: &'a [brink_ir::Name],
861    /// The receiver as written (`party.members`), for diagnostics.
862    text: String,
863    /// The receiver's inferred type ([`UfcsVisitor::receiver_ty`]) — never
864    /// `Unknown`/`Conflicted`, which is `E142` one step earlier.
865    ty: Ty,
866}
867
868impl UfcsVisitor<'_> {
869    /// The innermost enclosing def's own `BodyTypes` — a stitch's own body
870    /// wins over its enclosing knot's, exactly like [`Self::current_locals`]
871    /// already preferred.
872    fn current_body(&self) -> Option<&crate::infer::BodyTypes> {
873        self.stitch_body.or(self.knot_body)
874    }
875
876    fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
877        self.lambda_locals
878            .last()
879            .or_else(|| self.current_body().map(|b| &b.locals))
880    }
881
882    /// The single call-site decision. Returns without touching the table or
883    /// the diagnostics for any call that is not UFCS-shaped.
884    fn resolve_call(&mut self, path: &HirPath, arg_count: usize) {
885        let Some((method, receiver_segs)) = path.segments.split_last() else {
886            return;
887        };
888        if receiver_segs.is_empty() {
889            // A bare `name(args)` — ordinary direct call, never UFCS.
890            return;
891        }
892        let Some(head_def) = self.value_receiver_def(path) else {
893            // The callee path resolves to a real callable (a
894            // module-qualified free call, an ink `knot.stitch()` visit) —
895            // an ordinary qualified call, not method-call syntax.
896            return;
897        };
898
899        let receiver_text = receiver_segs
900            .iter()
901            .map(|s| s.text.as_str())
902            .collect::<Vec<_>>()
903            .join(".");
904
905        let Some(receiver_ty) = self.receiver_ty(head_def, receiver_segs) else {
906            // D3: no deferral machinery — demand an annotation.
907            self.push(
908                path.range,
909                DiagnosticCode::E142,
910                &format!(
911                    "cannot resolve `{receiver_text}.{method}(…)`: the type of `{receiver_text}` \
912                     is not known here, so it is undecidable whether `{method}` is one of its \
913                     fields — annotate the receiver",
914                    method = method.text,
915                ),
916            );
917            return;
918        };
919
920        let receiver = Receiver {
921            def: head_def,
922            segments: receiver_segs,
923            text: receiver_text,
924            ty: receiver_ty,
925        };
926
927        // Step 2 — field access wins outright (D1).
928        if self.try_field_call(path, method, &receiver, arg_count) {
929            return;
930        }
931
932        // Step 3 — a free function in ordinary lexical scope (D4), by value
933        // or auto-ref'd (D5).
934        if self.try_free_fn_desugar(path, method, &receiver, arg_count) {
935            return;
936        }
937
938        // Step 4 — neither; one diagnostic naming both attempts.
939        self.push(
940            path.range,
941            DiagnosticCode::E141,
942            &format!(
943                "cannot resolve `{receiver_text}.{method}(…)`: `{recv_ty}` declares no field \
944                 `{method}`, and no function `{method}` is in scope here",
945                method = method.text,
946                receiver_text = receiver.text,
947                recv_ty = receiver.ty.display(),
948            ),
949        );
950    }
951
952    /// Step 2 (D1). Returns `true` when the receiver's type declares a field
953    /// of the called name — the call is settled either way, as a
954    /// [`UfcsVerdict::FieldCall`] or as the `E140` hard error, and never
955    /// falls through to step 3.
956    fn try_field_call(
957        &mut self,
958        path: &HirPath,
959        method: &brink_ir::Name,
960        receiver: &Receiver<'_>,
961        arg_count: usize,
962    ) -> bool {
963        let receiver_ty = &receiver.ty;
964        let receiver_text = &receiver.text;
965        let Ty::Struct(shape_name) = receiver_ty else {
966            return false;
967        };
968        let Some(field_ty) = self
969            .shapes
970            .resolve(shape_name, self.scope, self.index)
971            .and_then(|shape| shape.field_ty(&method.text))
972        else {
973            return false;
974        };
975        if matches!(field_ty, Ty::Fn(..)) {
976            // Issue #1918: this verdict's own argument checking — computed
977            // here (unconditionally, like every other verdict's own
978            // arg-check fields) and carried on the verdict for
979            // `check_strict` to report as `E063`. See
980            // `Self::check_field_call_args`'s own doc.
981            let (arity_mismatch, arg_mismatches) =
982                self.check_field_call_args(path.range, field_ty, arg_count);
983            let verdict = UfcsVerdict::FieldCall {
984                receiver: receiver_ty.clone(),
985                field: method.text.clone(),
986                field_ty: field_ty.clone(),
987                arity_mismatch,
988                arg_mismatches,
989            };
990            self.table
991                .insert(NodeKey::new(self.file, path.range), verdict);
992        } else {
993            let message = format!(
994                "field `{field}` on `{shape_name}` is not callable (its type is `{found}`) — \
995                 field access wins over a free function of the same name, so this is never \
996                 re-read as `{field}({receiver_text}, …)`",
997                field = method.text,
998                found = field_ty.display(),
999            );
1000            self.push(path.range, DiagnosticCode::E140, &message);
1001        }
1002        true
1003    }
1004
1005    /// Step 3 (D4/D5). Returns `true` when a free function of the called
1006    /// name is in ordinary lexical scope, or the name is a T1b/NS stdlib
1007    /// prelude verb (D4's candidate set is "ordinary lexical scope only
1008    /// (file `use` + prelude)" — `resolve::is_t1b_stdlib_name`/
1009    /// `resolve::is_builtin_function`, e.g. `len`/`push`/`sort_by`, are not
1010    /// index symbols and would otherwise fall through to the `E141` "no
1011    /// function in scope" diagnostic, which is false: `push(xs, v)` compiles
1012    /// today).
1013    ///
1014    /// **D5** picks the desugar's shape from the target's *first declared
1015    /// parameter*: `ref` → [`UfcsVerdict::FreeFnAutoRef`] (the receiver is
1016    /// passed by reference, provided it can be written through — otherwise
1017    /// `E143`, see [`Self::auto_ref_fault`]); anything else → the plain
1018    /// by-value [`UfcsVerdict::FreeFnDesugar`], with no lvalue requirement on
1019    /// the receiver at all. The prelude verbs have no user-declared params to
1020    /// read, so they are always the by-value shape here — the collection
1021    /// mutators' own lvalue discipline is LIR lowering's ruled RMW expansion
1022    /// (`brink_ir::lir::lower::blocks::try_lower_mutator_stmt`), unchanged.
1023    fn try_free_fn_desugar(
1024        &mut self,
1025        path: &HirPath,
1026        method: &brink_ir::Name,
1027        receiver: &Receiver<'_>,
1028        arg_count: usize,
1029    ) -> bool {
1030        let Some(target) = crate::resolve::lookup_by_name(
1031            self.index,
1032            self.scope,
1033            &method.text,
1034            &[SymbolKind::Knot, SymbolKind::External],
1035        ) else {
1036            // No index symbol of this name — the T1b/NS stdlib prelude is
1037            // the other half of D4's candidate set. It has no `DefinitionId`
1038            // (VM-native, resolved at LIR lowering) and so no arity to check
1039            // here.
1040            if crate::resolve::is_t1b_stdlib_name(&method.text)
1041                || crate::resolve::is_builtin_function(&method.text)
1042            {
1043                // Issue #1919: the prelude sibling of the `arg_mismatches`
1044                // computed below for the free-fn desugar — see
1045                // `check_ufcs_prelude_arg_types`'s own doc for why this is
1046                // safe to compute here (D1's field-access-wins check has
1047                // already run by the time this verdict is reached) and why
1048                // it cannot double-report `E149` (a disjoint diagnostic
1049                // family from the domain mismatches this checks).
1050                let arg_mismatches =
1051                    self.check_ufcs_prelude_arg_types(path.range, &receiver.ty, &method.text);
1052                let verdict = UfcsVerdict::PreludeDesugar {
1053                    receiver: receiver.ty.clone(),
1054                    name: method.text.clone(),
1055                    arg_mismatches,
1056                };
1057                self.table
1058                    .insert(NodeKey::new(self.file, path.range), verdict);
1059                return true;
1060            }
1061            return false;
1062        };
1063        let first_param_is_ref = self
1064            .index
1065            .symbols
1066            .get(&target)
1067            .and_then(|info| info.params.first())
1068            .is_some_and(|p| p.is_ref);
1069        if first_param_is_ref && let Some(cause) = self.auto_ref_fault(receiver) {
1070            let message = format!(
1071                "cannot mutate `{receiver_text}` through `{name}`: `{name}`'s first parameter is \
1072                 `ref`, so `{receiver_text}.{name}(…)` auto-refs its receiver (D5) — but {cause}. \
1073                 Bind the receiver to a durable cell, or call a by-value function on it",
1074                name = method.text,
1075                receiver_text = receiver.text,
1076            );
1077            self.push(path.range, DiagnosticCode::E143, &message);
1078            return true;
1079        }
1080        // Every other resolved call gets an arity check (`resolve::
1081        // check_arity`) before it is declared resolved; this desugar owes
1082        // the same — the receiver counts as the first argument.
1083        let expected = self
1084            .index
1085            .symbols
1086            .get(&target)
1087            .map(|info| info.params.len());
1088        let actual = arg_count + 1;
1089        if let Some(expected) = expected
1090            && expected != actual
1091        {
1092            let message = format!(
1093                "`{name}` expects {expected} argument(s), got {actual} \
1094                 (`{receiver_text}.{name}(…)` desugars to `{name}({receiver_text}, …)`, counting \
1095                 the receiver as the first argument)",
1096                name = method.text,
1097                receiver_text = receiver.text,
1098            );
1099            self.push(path.range, DiagnosticCode::E031, &message);
1100        }
1101        // Issue #1881: the argument-type half of this call site's check —
1102        // computed here (unconditionally, like everything else in this
1103        // resolution pass) and carried on the verdict for `check_strict` to
1104        // report as `E063`.
1105        let arg_mismatches = self.check_ufcs_arg_types(path.range, target, receiver);
1106        let verdict = if first_param_is_ref {
1107            UfcsVerdict::FreeFnAutoRef {
1108                receiver: receiver.ty.clone(),
1109                name: method.text.clone(),
1110                target,
1111                arg_mismatches,
1112            }
1113        } else {
1114            UfcsVerdict::FreeFnDesugar {
1115                receiver: receiver.ty.clone(),
1116                name: method.text.clone(),
1117                target,
1118                arg_mismatches,
1119            }
1120        };
1121        self.table
1122            .insert(NodeKey::new(self.file, path.range), verdict);
1123        true
1124    }
1125
1126    /// Issue #1881: the desugared call's argument-type check — `target`'s
1127    /// already-known declared param types (`self.signatures`, this pass's
1128    /// own `InferenceResult::signatures` projection) against the receiver
1129    /// (param `0`) and every *written* argument (param `1..`, read back
1130    /// from `infer::body`'s own recorded [`super::UfcsCallArgs`] fact for
1131    /// this exact call-site `range` — this pass has no expression-type
1132    /// inference of its own, see that struct's own doc for why the split
1133    /// lives here).
1134    ///
1135    /// Mirrors `infer::body::InferPass::infer_call`'s own direct-call check
1136    /// (`assignable`, skipping whenever either side is `Unknown`/
1137    /// `Conflicted`) with one simplification: unlike a direct call's
1138    /// argument, nothing in `infer::body`'s own walk ever `observe`s a
1139    /// UFCS receiver or written argument against `target`'s declared param
1140    /// type — the multi-segment branch in `infer_call` runs no `observe`
1141    /// call at all (issue #1909's `infer_ufcs_free_fn_result` gave that
1142    /// branch a *result type*, deliberately read-only in the receiver and
1143    /// silent on the arguments, exactly so this stays true). So there
1144    /// is no `arg_is_observed_local`-style double-report risk against
1145    /// `E066` to guard against here, unlike `DirectCallArgMismatch`'s own
1146    /// exclusion.
1147    ///
1148    /// **The D5 auto-ref interaction** (both call sites — `first_param_is_ref`
1149    /// or not — share this one check): a `ref` first param's entry in
1150    /// `self.signatures` is *not* a special "reference" `Ty` — `InferredSig`
1151    /// carries no such variant. `body::infer_def_body` derives every
1152    /// param's row (`ref` included) from `pass.locals`, the type the body
1153    /// itself observed that parameter holding — i.e. the **referent's**
1154    /// own type, exactly what `receiver.ty` (a value type, never wrapped)
1155    /// already is. So comparing `receiver.ty` against `sig.params[0]` with
1156    /// the same plain `assignable` this whole function otherwise uses is
1157    /// correct for `FreeFnAutoRef` too, not just `FreeFnDesugar` — no
1158    /// auto-ref-specific unwrapping needed, and none of the false positives
1159    /// #1895 shipped by assuming a receiver's call-site type must literally
1160    /// equal a param's declared spelling.
1161    fn check_ufcs_arg_types(
1162        &self,
1163        range: TextRange,
1164        target: DefinitionId,
1165        receiver: &Receiver<'_>,
1166    ) -> Vec<UfcsArgMismatch> {
1167        let Some(sig) = self.signatures.get(&target) else {
1168            return Vec::new();
1169        };
1170        let empty: Vec<Ty> = Vec::new();
1171        let written: &[Ty] = self
1172            .current_body()
1173            .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1174            .map_or(empty.as_slice(), |f| f.args.as_slice());
1175        // Issue #1995/#1920: which positions are declared `ref` lives on
1176        // the symbol index's own `params` — `InferredSig` (`sig`, above)
1177        // carries no `is_ref` bit, exactly like the direct-call sibling in
1178        // `infer::body::InferPass::infer_call`.
1179        let ref_positions = self.index.symbols.get(&target);
1180        let is_ref_param = |i: usize| {
1181            ref_positions
1182                .and_then(|info| info.params.get(i))
1183                .is_some_and(|p| p.is_ref)
1184        };
1185
1186        let mut mismatches = Vec::new();
1187        // Index 0: the receiver itself — `name(recv, args)`'s first
1188        // positional slot, same "receiver counts as the first argument"
1189        // convention this call site's own arity-mismatch diagnostic uses.
1190        // D5's auto-ref desugar makes this slot `ref` whenever
1191        // `first_param_is_ref` selected `FreeFnAutoRef` — the exact write-
1192        // back-through-the-caller's-cell case the invariant check exists
1193        // for.
1194        if let Some(param_ty) = sig.params.first()
1195            && !param_ty.is_unresolved()
1196            && if is_ref_param(0) {
1197                !ref_assignable(param_ty, &receiver.ty)
1198            } else {
1199                !assignable(param_ty, &receiver.ty)
1200            }
1201        {
1202            mismatches.push(UfcsArgMismatch {
1203                index: 0,
1204                expected: param_ty.clone(),
1205                found: receiver.ty.clone(),
1206            });
1207        }
1208        for (i, arg_ty) in written.iter().enumerate() {
1209            if arg_ty.is_unresolved() {
1210                continue;
1211            }
1212            let Some(param_ty) = sig.params.get(i + 1) else {
1213                continue;
1214            };
1215            let ty_disagrees = if is_ref_param(i + 1) {
1216                !ref_assignable(param_ty, arg_ty)
1217            } else {
1218                !assignable(param_ty, arg_ty)
1219            };
1220            if !param_ty.is_unresolved() && ty_disagrees {
1221                mismatches.push(UfcsArgMismatch {
1222                    index: i + 1,
1223                    expected: param_ty.clone(),
1224                    found: arg_ty.clone(),
1225                });
1226            }
1227        }
1228        mismatches
1229    }
1230
1231    /// Issue #1919: `PreludeDesugar`'s own argument-domain check — the T1b/
1232    /// NS-A1 stdlib-verb sibling of [`Self::check_ufcs_arg_types`] (issue
1233    /// #1881, `FreeFnDesugar`/`FreeFnAutoRef`). A prelude verb has no
1234    /// [`DefinitionId`] and no declared parameter list
1235    /// ([`UfcsVerdict::PreludeDesugar`]'s own doc) — its "signature" instead
1236    /// lives as `infer::body::InferPass::infer_intrinsic`'s own per-verb
1237    /// domain rules, keyed off the receiver's *inferred container type*
1238    /// (an array's element type, a map's key/value types).
1239    ///
1240    /// This mirrors that domain knowledge declaratively, for the verbs
1241    /// whose domain is a plain container projection, rather than calling
1242    /// `infer_intrinsic` itself: that method needs a live `Expr` for its
1243    /// receiver-write arms (`push`/`insert`/… call
1244    /// `record_write(args.first())`, resolving an `Expr::Path` back through
1245    /// the `ResolutionMap` — keyed at the *whole call's* own range, so a
1246    /// synthetic receiver-only `Expr` would resolve to nothing there) and
1247    /// mutates the body-inference walk's own `self.locals`/
1248    /// `self.array_remove_calls` state, neither of which this post-hoc,
1249    /// already-fully-resolved verdict pass has access to (see
1250    /// `check_strict`'s own module doc for why the `E149` half stays a
1251    /// hand-written twin rather than an `infer_intrinsic` call, for the
1252    /// same reason).
1253    ///
1254    /// Unlike [`infer::body::InferPass::infer_ufcs_free_fn_result`]
1255    /// (issue #1909), which has to decline a `Ty::Struct` receiver because
1256    /// D1's field-access-wins rule has not run yet at body-inference time,
1257    /// this check runs no such risk: [`Self::resolve_call`] always tries
1258    /// [`Self::try_field_call`] (D1) before [`Self::try_free_fn_desugar`]
1259    /// (D3/D4), so a `PreludeDesugar` verdict is only ever constructed once
1260    /// field access has already lost.
1261    ///
1262    /// `remove`'s *array* leg is deliberately excluded from the domain
1263    /// table below: that shape is `E149` (issue #1540), already reported
1264    /// unconditionally for this exact verdict by
1265    /// [`strict_verdict_diagnostics`] — a disjoint diagnostic family from
1266    /// the domain mismatches this method reports, so there is no risk of
1267    /// double-reporting between the two.
1268    fn check_ufcs_prelude_arg_types(
1269        &self,
1270        range: TextRange,
1271        receiver: &Ty,
1272        name: &str,
1273    ) -> Vec<UfcsArgMismatch> {
1274        let expected: Vec<Ty> = match (name, receiver) {
1275            ("push" | "heap_push" | "index_of" | "contains", Ty::Array(elem)) => {
1276                vec![(**elem).clone()]
1277            }
1278            ("contains" | "get" | "remove", Ty::Map(k, _)) => vec![(**k).clone()],
1279            ("contains_value", Ty::Map(_, v)) => vec![(**v).clone()],
1280            ("insert", Ty::Map(k, v)) => vec![(**k).clone(), (**v).clone()],
1281            _ => return Vec::new(),
1282        };
1283        let empty: Vec<Ty> = Vec::new();
1284        let written: &[Ty] = self
1285            .current_body()
1286            .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1287            .map_or(empty.as_slice(), |f| f.args.as_slice());
1288
1289        let mut mismatches = Vec::new();
1290        for (i, expected_ty) in expected.iter().enumerate() {
1291            let Some(arg_ty) = written.get(i) else {
1292                continue;
1293            };
1294            if arg_ty.is_unresolved() {
1295                continue;
1296            }
1297            if !assignable(expected_ty, arg_ty) {
1298                mismatches.push(UfcsArgMismatch {
1299                    index: i + 1,
1300                    expected: expected_ty.clone(),
1301                    found: arg_ty.clone(),
1302                });
1303            }
1304        }
1305        mismatches
1306    }
1307
1308    /// Issue #1918: `FieldCall`'s own argument checking — structurally
1309    /// `strict::check_value_calls`'s T1c "call through a function value"
1310    /// domain (the issue's own framing: `recv.name(args)` resolving through
1311    /// a struct's fn-typed field *is* a call through a value, just reached
1312    /// via field access instead of a bare name), reached here rather than
1313    /// through that pass because a UFCS receiver is deliberately invisible
1314    /// to `infer::body::infer_call`'s own T1c branch (see
1315    /// [`Self::check_ufcs_arg_types`]'s own doc for why — the same reason
1316    /// applies here) — this pass has already resolved the field's own
1317    /// `Ty::Fn` row by the time a `FieldCall` verdict is being built, which
1318    /// `infer::body` never does for a multi-segment callee.
1319    ///
1320    /// **No receiver-prepending desugar, unlike [`Self::check_ufcs_arg_types`]
1321    /// (`FreeFnDesugar`/`FreeFnAutoRef`).** Those two desugar
1322    /// `recv.name(args)` into `name(recv, args)`, so the receiver becomes
1323    /// the desugared call's own first argument and gets checked at index
1324    /// `0`. A field call has no such rewrite: `npc.on_greet(3)` calls the
1325    /// field's own `fn(...)` value directly with the *written* arguments
1326    /// only (`brink_ir::lir::lower::expr::lower_ufcs_call`'s `FieldCall`
1327    /// arm lowers straight to `lir::ExprKind::CallValue { callee, args }`, no
1328    /// synthetic receiver argument) — the receiver's own type already did
1329    /// its only job selecting this field via `try_field_call`'s
1330    /// `Ty::Struct` match, so it is never checked as an argument here. Every
1331    /// [`UfcsArgMismatch`] this returns is therefore `index`-0-based over
1332    /// the written arguments alone, matching
1333    /// `strict::check_value_calls`'s own `ValueCallKind::ArgMismatch`
1334    /// convention rather than [`UfcsArgMismatch::index`]'s "receiver counts
1335    /// as 0" default.
1336    ///
1337    /// **The arity half reads `arg_count`** — the call site's own
1338    /// AST-derived written-argument count (`resolve_call`'s own
1339    /// `args.len()`), always available — rather than `current_body()`'s
1340    /// best-effort `ufcs_call_args` projection, exactly like
1341    /// [`Self::try_free_fn_desugar`]'s own `E031` arity check does for the
1342    /// same reason: a missing `BodyTypes` (global-initializer position, see
1343    /// [`Self::check_ufcs_arg_types`]'s own doc) must degrade only the
1344    /// per-argument *type* half, never arity — an arity fact this cheap to
1345    /// derive structurally has no excuse to go missing alongside a body
1346    /// lookup failure it doesn't actually depend on.
1347    fn check_field_call_args(
1348        &self,
1349        range: TextRange,
1350        field_ty: &Ty,
1351        arg_count: usize,
1352    ) -> (Option<UfcsArityMismatch>, Vec<UfcsArgMismatch>) {
1353        let Ty::Fn(params, _ret, _) = field_ty else {
1354            // `try_field_call` only ever calls this once `field_ty` has
1355            // already matched `Ty::Fn(..)` — kept defensive (no panic
1356            // outside a test helper; house rule) rather than assuming the
1357            // caller's own invariant holds.
1358            return (None, Vec::new());
1359        };
1360        let arity_mismatch = (arg_count != params.len()).then_some(UfcsArityMismatch {
1361            expected: params.len(),
1362            got: arg_count,
1363        });
1364
1365        let empty: Vec<Ty> = Vec::new();
1366        let written: &[Ty] = self
1367            .current_body()
1368            .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1369            .map_or(empty.as_slice(), |f| f.args.as_slice());
1370
1371        let mut mismatches = Vec::new();
1372        for (i, param_ty) in params.iter().enumerate() {
1373            let Some(arg_ty) = written.get(i) else {
1374                continue;
1375            };
1376            if arg_ty.is_unresolved() || param_ty.is_unresolved() {
1377                continue;
1378            }
1379            if !assignable(param_ty, arg_ty) {
1380                mismatches.push(UfcsArgMismatch {
1381                    index: i,
1382                    expected: param_ty.clone(),
1383                    found: arg_ty.clone(),
1384                });
1385            }
1386        }
1387        (arity_mismatch, mismatches)
1388    }
1389
1390    /// **D5's receiver gate.** `Some(cause)` when auto-ref cannot write
1391    /// through this receiver, phrased as the tail of the `E143` message;
1392    /// `None` when it can.
1393    ///
1394    /// The desugar rides the T1e ref-argument machinery verbatim
1395    /// (`brink_ir::lir::lower::expr::lower_call_args`), so it inherits that
1396    /// machinery's own rules rather than inventing a second set:
1397    ///
1398    /// - A **bare** receiver (`gold.bump(1)`) binds like any unmarked
1399    ///   ref-argument: a frame slot (param/temp) or a global `VAR` both work
1400    ///   — `lower_ref_path_call_arg`'s `RefTemp`/`RefGlobal` pair.
1401    /// - A **projection off a durable cell** (`party.leader.heal(5)` where
1402    ///   `party` is a `VAR`) becomes a real `lir::CallArg::RefProjection`,
1403    ///   whose root must be durable (`docs/t1e-spec.md` §2, the `E080` rule
1404    ///   `ref_projection::check_durable_root` enforces for the explicitly
1405    ///   spelled form) — that requirement is unchanged by this gate.
1406    /// - A **projection off a frame-local** (`g.hp.heal(5)` where `g` is a
1407    ///   `let`/param) is legal too, **RULED 2026-07-27** (issue #1531,
1408    ///   `docs/decision-log.md`): a frame-local cell is a valid projection
1409    ///   root, and the mutation needs no effect row because it is
1410    ///   unobservable outside the frame. `RefProjection`'s own root stays
1411    ///   durable-only (`docs/format-v4-rfc.md` §1), so LIR lowering does not
1412    ///   reuse that machinery for this case — it splices a read/call/
1413    ///   write-back RMW sequence instead (`brink_ir::lir::lower::blocks::
1414    ///   try_lower_frame_local_auto_ref_stmt`), the same discipline plain
1415    ///   assignment (`g.hp = 5`) already uses. That lowering only has a
1416    ///   statement-shaped expansion, so it covers a **single field level**
1417    ///   only — the same boundary `try_lower_field_assignment` draws
1418    ///   (`E074` for a deeper chain); this gate mirrors that boundary by
1419    ///   only clearing a two-segment receiver (root + one field).
1420    /// - A `CONST` is never writable at any depth.
1421    ///
1422    /// The ruled rvalue receivers (`[1,2].push(3)`, `a.sorted().push(x)` —
1423    /// "mutating a temporary loses the mutation") reach this gate as soon as
1424    /// they are spellable: today's native grammar admits only a dotted path
1425    /// as a call's callee (`brink-syntax-native`'s `parser::expr::
1426    /// path_or_call`), so a literal or a call cannot yet sit in receiver
1427    /// position at all.
1428    fn auto_ref_fault(&self, receiver: &Receiver<'_>) -> Option<String> {
1429        let head = receiver.segments.first().map_or("", |s| s.text.as_str());
1430        // A frame-local projection root is legal (issue #1531) only one
1431        // field level deep — `head.field`, i.e. exactly two receiver
1432        // segments. Anything deeper has no lowering (LIR's RMW expansion is
1433        // single-level, matching `try_lower_field_assignment`'s own `E074`
1434        // boundary), so it still faults here.
1435        let frame_local = || {
1436            (receiver.segments.len() > 2).then(|| {
1437                format!(
1438                    "`{head}` is a temp/param — a frame-local projection can only reach one \
1439                     field level (`{head}.field`); this receiver goes deeper than that"
1440                )
1441            })
1442        };
1443        match self.index.symbols.get(&receiver.def) {
1444            Some(info) => match info.kind {
1445                SymbolKind::Variable => None,
1446                SymbolKind::Constant => Some(format!("`{head}` is a CONST, not a mutable cell")),
1447                SymbolKind::Param | SymbolKind::Temp => frame_local(),
1448                // `value_receiver_def` admits no other kind as a receiver.
1449                _ => Some(format!(
1450                    "`{head}` is not a value that can be written through"
1451                )),
1452            },
1453            // Absent from `brink-db`'s narrowed index projection: a local
1454            // temp/param, exactly as `value_receiver_def`/`head_ty` already
1455            // treat it (and `ref_projection::check_durable_root`'s own
1456            // `LocalVar` fallback).
1457            None => frame_local(),
1458        }
1459    }
1460
1461    /// The resolved definition of `path`'s head when `path` is a
1462    /// *method-call-shaped* callee: the resolver recorded the head value (a
1463    /// param/temp/VAR/CONST) as the callee's target rather than a callable
1464    /// definition. `None` for an ordinary qualified call (a module-qualified
1465    /// free call, an ink `knot.stitch()` visit).
1466    ///
1467    /// This is the mirror of `resolve::resolve_function`'s own UFCS-shaped
1468    /// fallback — the two must agree, or a call would either be diagnosed
1469    /// twice or not at all. `resolve_function`'s lookup is project-wide
1470    /// (`resolve::lookup_by_name`), not file-scoped, so this returns the
1471    /// same project-wide [`DefinitionId`] rather than re-deriving one from
1472    /// the head's name alone — [`Self::head_ty`] types it from exactly that
1473    /// id, the same way `structs::resolved_symbol_ty` types any other
1474    /// resolved reference.
1475    fn value_receiver_def(&self, path: &HirPath) -> Option<DefinitionId> {
1476        // `path.range` here is the callee `Path`'s whole span — this lookup
1477        // is one of the four consumers keyed on the call-path
1478        // `ResolvedRef::range` contract (issue #1561); see that field's doc.
1479        let key = (path.range.start().into(), path.range.end().into());
1480        let &target = self.resolution_by_range.get(&key)?;
1481        match self.index.symbols.get(&target) {
1482            Some(info)
1483                if matches!(
1484                    info.kind,
1485                    SymbolKind::Param
1486                        | SymbolKind::Temp
1487                        | SymbolKind::Variable
1488                        | SymbolKind::Constant
1489                ) =>
1490            {
1491                Some(target)
1492            }
1493            // brink-db's narrowed index projection can strip locals; the
1494            // definition tag still identifies them (mirrors
1495            // `infer::body::infer_call`'s own `is_value_callee`).
1496            None if target.tag() == brink_format::DefinitionTag::LocalVar => Some(target),
1497            Some(_) | None => None,
1498        }
1499    }
1500
1501    /// The receiver's type: the head segment's own type (typed from
1502    /// `head_def`, the definition `resolve::resolve_function` actually
1503    /// bound the head to), then each further segment walked through the
1504    /// declared shape table. `None` whenever any step lands on an unknown or
1505    /// conflicted type — the D3 case.
1506    fn receiver_ty(&self, head_def: DefinitionId, segments: &[brink_ir::Name]) -> Option<Ty> {
1507        let (head, rest) = segments.split_first()?;
1508        let mut ty = self.head_ty(head_def, head)?;
1509        for seg in rest {
1510            let Ty::Struct(shape_name) = &ty else {
1511                return None;
1512            };
1513            let field = self
1514                .shapes
1515                .resolve(shape_name, self.scope, self.index)?
1516                .field_ty(&seg.text)?
1517                .clone();
1518            ty = field;
1519        }
1520        (!ty.is_unknown() && ty != Ty::Conflicted).then_some(ty)
1521    }
1522
1523    /// The head segment's type, read from `def` — the *resolved* definition,
1524    /// exactly as `structs::resolved_symbol_ty` reads any other resolved
1525    /// reference: a param/temp reads the enclosing def's finalized local *by
1526    /// name* (`def`'s own name — locals are keyed by name, not id); a global
1527    /// `VAR`/`CONST` reads `infer::collect_globals`'s declaration-derived
1528    /// type *by id*, project-wide, never file-scoped. Dispatching on `def`'s
1529    /// own kind (rather than trying `current_locals()` by `head.text` first,
1530    /// unconditionally) also means a body-local shadowing a same-named
1531    /// global after the call site can never be mistaken for the global the
1532    /// resolver actually bound.
1533    fn head_ty(&self, def: DefinitionId, head: &brink_ir::Name) -> Option<Ty> {
1534        match self.index.symbols.get(&def) {
1535            Some(info) => match info.kind {
1536                SymbolKind::Param | SymbolKind::Temp => {
1537                    self.current_locals()?.get(&info.name).cloned()
1538                }
1539                SymbolKind::Variable | SymbolKind::Constant => self.globals.get(&def).cloned(),
1540                _ => None,
1541            },
1542            // brink-db's narrowed index projection can strip locals (see
1543            // `value_receiver_def`'s own fallback); the enclosing body's
1544            // finalized locals are keyed by name and unaffected by that
1545            // projection, so fall back to `head.text`.
1546            None => self.current_locals()?.get(&head.text).cloned(),
1547        }
1548    }
1549
1550    fn push(&mut self, range: TextRange, code: DiagnosticCode, detail: &str) {
1551        self.diagnostics.push(Diagnostic {
1552            file: self.file,
1553            range,
1554            message: format!("{}: {detail}", code.title()),
1555            code,
1556        });
1557    }
1558}