Skip to main content

brink_analyzer/
structs.rs

1//! TM-4b struct construction-literal semantic checks (docs/typed-mode-spec.md
2//! §6).
3//!
4//! Strict-mode-only (`types = strict`): "missing/extra fields at
5//! construction: compile error (strict) / construction fault (gradual)" —
6//! under `types = gradual` a project never runs [`check`] at all (mirrors
7//! `strict::check`'s own gating), deferring entirely to the runtime fault
8//! PR #664 already built (`RecordGetDyn`'s missing-field fault). Wired into
9//! `strict::check` alongside E065/E066/E067, behind the same
10//! `TypePolicy::Strict` + `dialect = brink` guard `strict::config_error`
11//! already enforces.
12//!
13//! Three checks, each naming the offending field, all strict-only per the
14//! spec's own wording ("missing/extra fields at construction: compile error
15//! (strict) / construction fault (gradual)"):
16//! - **Missing** (`E069`): a declared field with no initializer in the
17//!   literal.
18//! - **Extra** (`E070`): an initializer for a field the shape doesn't
19//!   declare.
20//! - **Mistyped** (`E071`): an initializer whose *statically
21//!   classifiable* type disagrees with the field's declared type.
22//!   Literal-shaped initializers (int/float/bool/string/array/map/nested
23//!   struct literals) classify from their own shape alone. A variable-,
24//!   call-, or index-valued initializer (issue #670) instead consults the
25//!   inference substrate already threaded into `strict::check`: a `Path`
26//!   resolving to a param/temp reads its finalized type from that def's own
27//!   `BodyTypes::locals`; a `Path` resolving to a global `VAR`/`CONST` reads
28//!   its declaration-derived type (`infer::collect_globals`, the same
29//!   source `infer::body` itself reads through the firewall); a call reads
30//!   the resolved callee's `InferredSig::return_ty`; an index expression
31//!   recurses into its base's classified type and takes the
32//!   array-element/map-value type. Whenever that resolution lands on
33//!   `Unknown` or `Conflicted` (unresolved, unannotated, or genuinely
34//!   contradictory), the field stays silently unchecked — same "Unknown
35//!   never disagrees" spirit as `annotations::mismatches`.
36//!
37//! An unresolved shape name (`E068`, already reported by
38//! `resolve::resolve_struct_ref`) is not re-reported here — a construction
39//! against a shape that doesn't exist has no declared fields to check
40//! against.
41//!
42//! [`check_duplicates`] (`E084`, issue #675) is a fourth, *policy-
43//! independent* check: a construction literal supplying the same field
44//! name more than once is flagged under both `types = gradual` and
45//! `types = strict` — it doesn't need the shape to resolve, so it's wired
46//! into `per_file_diagnostics` unconditionally within a file (no
47//! `TypePolicy` gate) rather than behind `strict::config_error` the way
48//! [`check`] is. Its `dialect`/`is_native` gating is wider than `check`'s
49//! own brink-only block, though: B5 (issue #1464, #1103 cascade ruling
50//! (A)) made `TypeName { … }` construction reach `StructLiteral` from the
51//! native surface (`Point { x: 1 }`) as well as the brink dialect's own
52//! `#{…}` spelling, so the caller runs this under `dialect = Brink ||
53//! is_native` — same reasoning `map_keys::check_duplicate_keys`'s own doc
54//! gives for `E138`.
55
56use std::collections::{BTreeMap, BTreeSet};
57
58use brink_format::DefinitionId;
59use brink_ir::hir::visit::{self, HirVisitor};
60use brink_ir::{
61    AssignOp, BlockStmt, Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, ResolutionMap,
62    Stitch, StructLiteral, SymbolIndex, SymbolKind,
63};
64use rowan::TextRange;
65
66use crate::annotations;
67use crate::infer::{
68    FieldAssignMismatch, InferenceResult, InferredSig, Ty, is_string_numeric_concat,
69};
70use crate::resolve::ImportScope;
71
72/// One declared struct shape: fields in declaration order, name -> declared
73/// type (`Ty::Unknown` if the field's own annotation doesn't resolve —
74/// e.g. an unrecognized type name, already flagged elsewhere by
75/// `annotations::check`'s `E061`).
76///
77/// Originally `pub(crate)` (issue #831) so `ref_projection`'s strict-mode
78/// path-segment check could reuse this exact shape table for `ref
79/// lvalue-path` field segments — "reuse existing machinery"
80/// (docs/t1e-spec.md §6) rather than building a second one. Promoted to a
81/// crate-public API (issue #858) so out-of-crate tooling (e.g. `brink-ide`
82/// struct-field ref-path completion, T1e-3's deferred "path continuations
83/// after a `.`/`[`" item) can query declared shapes without duplicating this
84/// table.
85pub struct ShapeInfo {
86    fields: Vec<(String, Ty)>,
87}
88
89impl ShapeInfo {
90    /// The declared type of `name`, or `None` if the shape has no such
91    /// field.
92    #[must_use]
93    pub fn field_ty(&self, name: &str) -> Option<&Ty> {
94        self.fields.iter().find(|(n, _)| n == name).map(|(_, t)| t)
95    }
96
97    /// Whether the shape declares a field named `name`.
98    #[must_use]
99    pub fn has_field(&self, name: &str) -> bool {
100        self.fields.iter().any(|(n, _)| n == name)
101    }
102}
103
104/// Every declared `STRUCT` shape in the project — a referrer-scoped lookup
105/// table (issue #2241).
106///
107/// A bare struct name is **not** a unique key: the stdlib mount (#2080) lets
108/// a project's own `struct Cue { … }` coexist with a same-named
109/// `struct Cue { … }` a mounted std preset declares (M-2d module
110/// coexistence, `manifest::is_cross_declared_module_collision`) — both are
111/// genuinely distinct `Struct` symbols with distinct `DefinitionId`s, the
112/// same shape `brink_ir::lir::lower::structs::ShapeTable` already handles one
113/// layer down (issue #2238). This table used to be a flat
114/// `BTreeMap<String, ShapeInfo>` populated by plain last-`insert`-wins —
115/// whichever file's declaration was iterated last silently overwrote every
116/// earlier same-named one, with no per-caller scope to break the tie.
117/// [`ShapeTable::resolve`] is the fix: every caller with an [`ImportScope`]
118/// in hand resolves the *right* candidate through the same
119/// `Candidacy`-based module scoping [`crate::resolve::lookup_by_name`]
120/// already applies to every other symbol kind — instead of a global winner
121/// or a second, diverging std-exclusion policy (2026-08-04 peer-root
122/// ruling, `docs/decision-log.md`). [`ShapeTable::get_by_def`] is for
123/// callers that already hold an exact `DefinitionId` (e.g. a construction
124/// literal's shape name, resolved with full module-scope `Candidacy` by
125/// `resolve::resolve_struct_ref` and recorded in the project's
126/// `ResolutionMap` — see [`check`]).
127///
128/// Public (issue #858) so tooling outside `brink-analyzer` can resolve a
129/// `STRUCT`'s declared fields — e.g. offering field-name completions after
130/// `npc.` in a `ref lvalue-path` — without re-deriving the shape table this
131/// crate already builds for its own construction-literal checks
132/// ([`check`]) and `ref`-projection path-segment validation.
133#[must_use]
134pub fn declared_shapes(files: &[(FileId, &HirFile)], index: &SymbolIndex) -> ShapeTable {
135    // No manifest access at this call site (`structs::check` isn't
136    // threaded a `HostManifest` — struct field types aren't in T1d-2's
137    // scope), so `Handle<K>` field types resolve `None` here, same as any
138    // other name `TypeNames` doesn't recognize — consistent with
139    // `annotations::resolve`'s documented "unresolved -> silent" contract.
140    let names = annotations::TypeNames::new(index, None);
141    let mut by_def = BTreeMap::new();
142    for &(file, hir) in files {
143        for s in &hir.structs {
144            // NOT actually an invariant (review finding on #2240/#2258):
145            // `annotations::def_id_for` is exact-file-only, with no
146            // fallback arm at all — unlike `lir::lower::structs`'
147            // `decls::lookup_global`, which at least rescues a surviving
148            // non-std sibling before giving up. So this lookup misses on
149            // *every* true intra-module duplicate this file's own
150            // declaration lost to (`E023` dropped its symbol-index entry),
151            // not only the narrower std-declared-survivor case `E181`
152            // reports one layer down. When it misses, this decl silently
153            // contributes nothing to `by_def` — a fourth, still-undiagnosed
154            // silent-drop site of the exact class `E181` exists to make
155            // loud (see that code's own doc and `build_shape_table`'s),
156            // just with no diagnostic sink wired here yet.
157            let Some(def_id) =
158                annotations::def_id_for(index, file, SymbolKind::Struct, &s.name.text)
159            else {
160                continue;
161            };
162            if by_def.contains_key(&def_id) {
163                continue;
164            }
165            let fields = s
166                .fields
167                .iter()
168                .map(|f| {
169                    let ty = annotations::resolve(&f.ty, &names).unwrap_or(Ty::Unknown);
170                    (f.name.text.clone(), ty)
171                })
172                .collect();
173            by_def.insert(def_id, ShapeInfo { fields });
174        }
175    }
176    ShapeTable { by_def }
177}
178
179/// [`declared_shapes`]' referrer-scoped lookup table — see that function's
180/// doc for the coexistence story this exists to resolve correctly.
181#[derive(Default)]
182pub struct ShapeTable {
183    /// Every shape by its own symbol-index identity — the canonical store,
184    /// unambiguous by construction.
185    by_def: BTreeMap<DefinitionId, ShapeInfo>,
186}
187
188impl ShapeTable {
189    /// Number of declared shapes in the project — referrer-free, since a
190    /// count needs no disambiguation.
191    #[must_use]
192    pub fn len(&self) -> usize {
193        self.by_def.len()
194    }
195
196    /// Whether the project declares no `STRUCT` shapes at all.
197    #[must_use]
198    pub fn is_empty(&self) -> bool {
199        self.by_def.is_empty()
200    }
201
202    /// Resolve a shape already pinned to an exact `DefinitionId` — no
203    /// referrer ambiguity possible, since the identity was already resolved
204    /// once, correctly, by whatever recorded it (e.g. a construction
205    /// literal's `RefKind::Struct` resolution, `resolve::resolve_struct_ref`).
206    #[must_use]
207    pub fn get_by_def(&self, id: DefinitionId) -> Option<&ShapeInfo> {
208        self.by_def.get(&id)
209    }
210
211    /// Scope-aware lookup (issue #2241, corrected per #2245/#2246's
212    /// peer-root ruling — `docs/decision-log.md`, 2026-08-04): when more
213    /// than one declared `STRUCT` shares `name`, resolve through the same
214    /// `Candidacy`-based module scoping every other symbol kind uses —
215    /// [`crate::resolve::lookup_by_name`], the exact function
216    /// `resolve::resolve_struct_ref` already calls for `SymbolKind::Struct`.
217    /// This used to hand-roll its own `find(info.file == referrer)
218    /// .or_else(find(!is_reserved_root_module))` fallback — a bolt-on std
219    /// gate the ruling calls out by name as one of the five symptom gates
220    /// to unwind, not a second, diverging implementation of the same
221    /// policy. Returns
222    /// `None` when `name` names no declared `STRUCT` at all, or
223    /// [`crate::resolve::lookup_by_name`] itself resolves to none (e.g.
224    /// every candidate sharing the name is std-declared and out of
225    /// `scope`).
226    #[must_use]
227    pub fn resolve(
228        &self,
229        name: &str,
230        scope: &ImportScope,
231        index: &SymbolIndex,
232    ) -> Option<&ShapeInfo> {
233        let def_id = crate::resolve::lookup_by_name(index, scope, name, &[SymbolKind::Struct])?;
234        self.by_def.get(&def_id)
235    }
236}
237
238/// Strict-mode construction checks over every struct literal in the
239/// project. Callers only reach this once `strict::config_error` has
240/// confirmed `types = strict` + `dialect = brink` (mirrors
241/// `strict::check`'s own entry condition).
242///
243/// `inference`/`resolutions` (issue #670): the same whole-project
244/// `InferenceResult`/`ResolutionMap` `strict::check` already computes for
245/// its own escape/mismatch checks — this is what lets the mistyped-field
246/// check (`E071`) classify a variable/call/index-valued initializer instead
247/// of only literal-shaped ones (see the module doc).
248#[must_use]
249pub fn check(
250    files: &[(FileId, &HirFile)],
251    index: &SymbolIndex,
252    inference: &InferenceResult,
253    resolutions: &ResolutionMap,
254) -> Vec<Diagnostic> {
255    let shapes = declared_shapes(files, index);
256    // No manifest access at this call site, same as `declared_shapes` above
257    // — a global's own annotation resolving against `Handle<K>` isn't in
258    // this check's scope any more than a struct field's is.
259    let globals = crate::infer::collect_globals(files, index, None);
260    let mut out = Vec::new();
261    for &(file, hir) in files {
262        let resolution_by_range = resolution_index(resolutions, file);
263        let mut v = ConstructionVisitor {
264            file,
265            shapes: &shapes,
266            index,
267            globals: &globals,
268            signatures: &inference.signatures,
269            bodies: &inference.bodies,
270            resolution_by_range: &resolution_by_range,
271            current_knot_name: None,
272            knot_locals: None,
273            stitch_locals: None,
274            lambda_locals: Vec::new(),
275            diagnostics: &mut out,
276        };
277        // Issue #2098: `ConstructionVisitor::enter_expr` has no state that
278        // needs resetting between the block tree and a file-level
279        // declaration's own initializer (`locals` is already `None` at this
280        // scope) — so the shared entry point covers both in one drive, and
281        // the hand-rolled `check_expr`/`expr_children` mirror of
282        // `visit::visit`'s own descent this used to need is gone.
283        visit::visit_with_decl_initializers(hir, &mut v);
284    }
285    out
286}
287
288// ─── Issue #1900: plain struct-field assignment target checking ──────
289
290/// Strict-mode-only: every [`crate::infer::FieldAssignMismatch`] fact body
291/// inference recorded (`~ p.x = expr`, a dotted assignment target — see that
292/// type's own doc for why the field chain is left unresolved until now),
293/// walked against [`declared_shapes`]/[`ShapeInfo`] to resolve the specific
294/// field's declared type and reported as `E063` — the same code
295/// `strict::check_typed_assign_mismatches` reports for a *bare* assignment
296/// target (issue #1877); this is that check's dotted sibling, split into
297/// its own issue (#1900) because the root's declared type is not the
298/// field's, so the bare-name comparison doesn't apply as-is. Callers only
299/// reach this once `strict::config_error` has confirmed `types = strict` +
300/// `dialect = brink` (mirrors [`check`]'s own entry condition).
301///
302/// Walks `inference.bodies` directly (keyed by `DefinitionId`, itself
303/// `Ord`) rather than re-deriving a per-file `def_ids` list the way
304/// `strict::check_typed_assign_mismatches` does — every fact already
305/// carries its own diagnostic range, so grouping by file first buys nothing
306/// extra here. Correction (issue #1900 review finding): the caller
307/// (`strict::check`) does *not* sort this aggregate — `strict::check` and
308/// `strict_diagnostics` only concatenate each check's output in a fixed
309/// call order, with no sort in either. The only downstream ordering is
310/// `brink_db::queries::mod::partition_diagnostics` grouping by `FileId` for
311/// the salsa query path; the pure `analyze_with_options` path has no
312/// ordering step at all. Iterating `inference.bodies` (`Ord`-keyed by
313/// `DefinitionId`) still makes this function's own output deterministic —
314/// just not because anything downstream re-sorts it.
315#[must_use]
316pub fn check_assignments(
317    files: &[(FileId, &HirFile)],
318    index: &SymbolIndex,
319    inference: &InferenceResult,
320) -> Vec<Diagnostic> {
321    let shapes = declared_shapes(files, index);
322    // Per-file scope, keyed the same way `resolve::resolve` and `ufcs::resolve`
323    // build one per file — `check_field_assign_mismatch` doesn't loop `files`
324    // itself (it's driven by `inference.bodies`, keyed by `DefinitionId`), so
325    // the scope for the fact's own declaring file is looked up here instead.
326    let scopes: BTreeMap<FileId, ImportScope> = files
327        .iter()
328        .map(|&(file, hir)| {
329            let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
330            (file, scope)
331        })
332        .collect();
333    let mut out = Vec::new();
334    for (def, body) in &inference.bodies {
335        let Some(info) = index.symbols.get(def) else {
336            continue;
337        };
338        let Some(scope) = scopes.get(&info.file) else {
339            continue;
340        };
341        for fact in &body.field_assign_mismatches {
342            check_field_assign_mismatch(fact, info.file, scope, index, &shapes, &mut out);
343        }
344    }
345    out
346}
347
348/// Walk one [`FieldAssignMismatch`]'s field chain from its already-resolved
349/// root type down to the specific field being assigned, comparing the
350/// result against the RHS's own type. Silently unclassifiable (no
351/// diagnostic) whenever the walk hits a non-`Struct` type or an unresolved
352/// shape name (`E068` already covers that separately) — matching
353/// [`check_literal`]'s own "unresolved -> silent" contract for the same
354/// reason: with no resolved shape to check the name against, "Unknown never
355/// disagrees" still holds.
356///
357/// Issue #1944: a field name the *resolved* shape doesn't declare is a
358/// different case — the shape itself is known, so the name can actually be
359/// checked, and now is: `E185`, the plain-assignment-target mirror of
360/// [`check_literal`]'s own `E070` (construction-literal unknown field).
361/// Fired only from inside this already-`Some(shape)` branch — an Unknown/
362/// untyped root never reaches here at all (the loop returns above, on the
363/// first segment, the moment `current` isn't a resolved `Ty::Struct`), so
364/// the "Unknown never disagrees" posture for an *unresolved* receiver is
365/// untouched. A chained target (`o.i.a = v`, 3+ segments) never reaches this
366/// function in the first place — `check_declared_field_assign_target`'s own
367/// `segments.len() == 2` fence means no [`FieldAssignMismatch`] fact is ever
368/// recorded for one; LIR's `try_lower_field_assignment` already rejects it
369/// outright with `E074`.
370///
371/// BLOCKING review finding (issue #1900): the `+=` string-numeric
372/// display-concat carve-out (issue #1911, `body::is_string_numeric_concat`)
373/// applies to a dotted target exactly like it applies to a bare one — `~
374/// v.s += 5` on a `string`-declared field desugars to the identical runtime
375/// `String`/`Int`|`Float` `Add` arm as `~ v.s = v.s + 5` — but body
376/// inference can't decide that carve-out itself: it only ever resolves the
377/// *root's* type (`Ty::Struct("S")`, never `string`) when it records the
378/// fact, well before the field's own declared type is known. So the
379/// carve-out has to be re-applied here, once `current` has been walked all
380/// the way down to the field's actual declared type.
381fn check_field_assign_mismatch(
382    fact: &FieldAssignMismatch,
383    file: FileId,
384    scope: &ImportScope,
385    index: &SymbolIndex,
386    shapes: &ShapeTable,
387    out: &mut Vec<Diagnostic>,
388) {
389    let mut current = fact.root_ty.clone();
390    for segment in &fact.path {
391        let Ty::Struct(shape_name) = &current else {
392            return;
393        };
394        let Some(shape) = shapes.resolve(shape_name, scope, index) else {
395            return;
396        };
397        let Some(field_ty) = shape.field_ty(&segment.text) else {
398            // Issue #1944: the receiver's shape resolved, but it declares
399            // no field with this name — the E070 mirror for a plain
400            // assignment target. Stop the walk here (there is no further
401            // field type to compare the RHS against, and continuing would
402            // only risk a confusing second diagnostic on the same target).
403            out.push(Diagnostic {
404                file,
405                range: segment.range,
406                message: format!(
407                    "{}: `{}` has no field `{}`",
408                    DiagnosticCode::E185.title(),
409                    shape_name,
410                    segment.text
411                ),
412                code: DiagnosticCode::E185,
413            });
414            return;
415        };
416        current = field_ty.clone();
417    }
418    // BLOCKING review finding (issue #1944, PR #2901): `found` can now reach
419    // here unresolved (an `EXTERNAL` call with no declared return type,
420    // e.g.) since `check_declared_field_assign_target` no longer bails out
421    // on an unresolved RHS before recording the fact — that early return was
422    // exactly what kept E185 (below) unreachable for such an RHS. Guarded
423    // explicitly rather than folded into the `assignable` check that
424    // follows: `assignable(T, Unknown)` is `true` (unify, infer/ty.rs:477),
425    // but `assignable(T, Conflicted)` is not, so relying on `assignable`
426    // alone would start false-firing E063 for an unresolved RHS instead of
427    // staying silent on it.
428    if fact.found.is_unresolved() {
429        return;
430    }
431    if current.is_unresolved() || crate::infer::assignable(&current, &fact.found) {
432        return;
433    }
434    // Issue #1911's carve-out, re-applied here (BLOCKING review finding,
435    // issue #1900) now that `current` is the field's own resolved declared
436    // type, not the root's — see this function's own doc.
437    if fact.op == AssignOp::Add && is_string_numeric_concat(&current, &fact.found) {
438        return;
439    }
440    // `path` is never empty by construction (the recording site only ever
441    // records a fact for a multi-segment target — `segments[1..]` is
442    // therefore non-empty), but a defensive `None` here (rather than
443    // `expect`, denied in production code) just silently skips the
444    // diagnostic instead of panicking if that invariant ever changes.
445    let Some(last) = fact.path.last() else {
446        return;
447    };
448    let dotted: Vec<&str> = std::iter::once(fact.root.as_str())
449        .chain(fact.path.iter().map(|n| n.text.as_str()))
450        .collect();
451    out.push(Diagnostic {
452        file,
453        range: last.range,
454        message: format!(
455            "`{}` has type `{}` but its declared type is `{}`",
456            dotted.join("."),
457            fact.found.display(),
458            current.display()
459        ),
460        code: DiagnosticCode::E063,
461    });
462}
463
464/// `TextRange` has no `Ord` impl, so a `Path`/`Call` reference's range keys
465/// this file-local `BTreeMap` as a `(start, end)` `u32` pair — mirrors
466/// `infer::mod`'s and `strict`'s own identically-named helper (each module
467/// owns its own copy rather than centralizing; the codebase's established
468/// convention for this exact utility).
469fn range_key(range: TextRange) -> (u32, u32) {
470    (range.start().into(), range.end().into())
471}
472
473/// This file's own reference resolutions, projected to a range-keyed lookup
474/// — mirrors `strict::resolution_index`, narrowed to one file at a time (a
475/// `Path`'s range is only unique within its own file).
476fn resolution_index(
477    resolutions: &ResolutionMap,
478    file: FileId,
479) -> BTreeMap<(u32, u32), DefinitionId> {
480    resolutions
481        .iter()
482        .filter(|r| r.file == file)
483        .map(|r| (range_key(r.range), r.target))
484        .collect()
485}
486
487/// Everything [`classify_expr_ty`] needs to resolve a non-literal
488/// initializer's type: the project symbol index, declaration-derived
489/// global types, every inferable def's finalized signature (for a
490/// call-valued initializer's return type), this file's range→`DefinitionId`
491/// resolutions, and — when the struct literal sits inside a knot/stitch
492/// body — that def's own finalized `BodyTypes::locals` (`None` at file
493/// scope, where only globals are in play).
494///
495/// `pub(crate)` (issue #983) so `conversions::check`'s own non-literal
496/// `int()`/`float()` argument classification can reuse this exact
497/// inference-substrate plumbing instead of re-deriving it — same "reuse
498/// existing machinery" precedent `ref_projection` follows for
499/// [`ShapeInfo`].
500pub(crate) struct MistypeCtx<'a> {
501    pub(crate) index: &'a SymbolIndex,
502    pub(crate) globals: &'a BTreeMap<DefinitionId, Ty>,
503    pub(crate) signatures: &'a BTreeMap<DefinitionId, InferredSig>,
504    pub(crate) resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
505    pub(crate) locals: Option<&'a BTreeMap<String, Ty>>,
506}
507
508/// Issue #2773's shared lambda-frame helper: the name set a lambda literal
509/// itself binds — its own param row, plus (for a block body) every name the
510/// block's own statements introduce (`TempDecl`, a `for` loop's var/val, an
511/// `if`/`while` `as` binding, recursed through nested `if`/`while`/`for` via
512/// [`crate::infer::lambda_own_bindings`]) — pruned out of `outer_locals`
513/// (a bare-name-keyed `BodyTypes::locals`-shaped map), with the lambda's own
514/// explicitly `: T`-annotated param types seeded back in under their own
515/// names.
516///
517/// This is the fix for the hazard class issue #2773 tracks: `resolved_symbol_ty`
518/// (below) resolves a `Param`/`Temp` `Path` by **bare name** out of
519/// `ctx.locals`, with no shadowing frame of its own — so a lambda-own
520/// binding that shares a name with a *different-typed* outer local gets
521/// silently attributed the outer binding's type for any expression inside
522/// the lambda's own body. Before this helper existed, every consumer of
523/// [`MistypeCtx::locals`]/`BodyTypes::locals` that reads it from
524/// [`brink_ir::hir::visit::HirVisitor::enter_expr`] — `conversions.rs`,
525/// `coalesce.rs`, this file's own [`ConstructionVisitor`], `ufcs.rs`,
526/// `range_refinement.rs`, `contains_domain.rs` — inherited this hazard
527/// automatically the moment `hir::visit::walk_expr`'s pre-existing
528/// `Expr::Lambda` descent (issue #1685) reached an expression inside a
529/// lambda body, because nothing signaled that a new scope had opened. Every
530/// one of those `HirVisitor` impls now pushes a pruned frame (built by this
531/// function) in [`brink_ir::hir::visit::HirVisitor::enter_lambda`] and pops
532/// it in `exit_lambda`.
533///
534/// `option_conditions.rs`'s condition-position walk (issue #2764/#2768)
535/// composes with this same function instead of keeping its own private copy
536/// — it cannot use the `enter_lambda`/`exit_lambda` hooks directly (its walk
537/// is hand-rolled, not `HirVisitor`-driven, because it needs to distinguish
538/// "this is a condition position" from an arbitrary expression — see that
539/// module's own doc), but the pruning logic itself is identical, so it is
540/// not re-implemented a second time.
541///
542/// Falls back to an empty pruned base when `outer_locals` is `None` (a
543/// file-scope lambda, e.g. a `var f = |x: Option<int>| { … }` initializer)
544/// rather than staying `None` itself: an annotated param must still be
545/// classifiable there. `outer_locals` and the returned map both use "absent
546/// name" identically for `MistypeCtx::locals`'s own `Option`-wrapped
547/// `ctx.locals?` reads, so this is not a behavior change for the
548/// pre-existing pruning path.
549#[must_use]
550pub(crate) fn pruned_locals_for_lambda(
551    l: &brink_ir::LambdaExpr,
552    index: &SymbolIndex,
553    outer_locals: Option<&BTreeMap<String, Ty>>,
554) -> BTreeMap<String, Ty> {
555    let stmts: &[BlockStmt] = match &l.body {
556        brink_ir::LambdaBody::Block { stmts, .. } => stmts,
557        brink_ir::LambdaBody::Expr(_) => &[],
558    };
559    let mut body_names: BTreeMap<String, (TextRange, Option<brink_ir::TypeExpr>)> = BTreeMap::new();
560    crate::infer::lambda_own_bindings(stmts, &mut body_names);
561    let body_bound_names: BTreeSet<String> = body_names.keys().cloned().collect();
562
563    let mut own_names = body_names;
564    for p in &l.params {
565        own_names
566            .entry(p.name.text.clone())
567            .or_insert((p.name.range, None));
568    }
569
570    let mut pruned: BTreeMap<String, Ty> = outer_locals.map_or_else(BTreeMap::new, |locals| {
571        locals
572            .iter()
573            .filter(|(name, _)| !own_names.contains_key(*name))
574            .map(|(name, ty)| (name.clone(), ty.clone()))
575            .collect()
576    });
577
578    let type_names = annotations::TypeNames::new(index, None);
579    for p in &l.params {
580        if body_bound_names.contains(&p.name.text) {
581            continue;
582        }
583        if let Some(te) = &p.annotation
584            && let Some(ty) = annotations::resolve(te, &type_names)
585        {
586            pruned.insert(p.name.text.clone(), ty);
587        }
588    }
589
590    pruned
591}
592
593struct ConstructionVisitor<'a> {
594    file: FileId,
595    shapes: &'a ShapeTable,
596    index: &'a SymbolIndex,
597    globals: &'a BTreeMap<DefinitionId, Ty>,
598    signatures: &'a BTreeMap<DefinitionId, InferredSig>,
599    bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
600    resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
601    /// The currently-open knot's own name — `enter_stitch` needs it to
602    /// reconstruct the qualified `knot.stitch` name a stitch is indexed
603    /// under (mirrors `strict::check_value_calls`' own lookup).
604    current_knot_name: Option<String>,
605    /// The enclosing knot's own finalized locals, set for the duration of
606    /// its body (and every stitch nested inside it — `visit::visit` walks a
607    /// knot's own body before descending into its stitches, so a stitch's
608    /// `enter_stitch` overrides this with its *own* locals rather than
609    /// inheriting the parent knot's).
610    knot_locals: Option<&'a BTreeMap<String, Ty>>,
611    /// The currently-open stitch's own finalized locals, if any — takes
612    /// priority over `knot_locals` while set.
613    stitch_locals: Option<&'a BTreeMap<String, Ty>>,
614    /// Issue #2773: a stack of pruned-locals frames, one per currently-open
615    /// lambda literal (innermost last) — pushed in `enter_lambda`, popped in
616    /// `exit_lambda`. Takes priority over `stitch_locals`/`knot_locals`
617    /// while non-empty, so an expression inside a lambda's own body sees its
618    /// own bindings' types (or "unclassifiable") instead of a same-named
619    /// outer binding's.
620    lambda_locals: Vec<BTreeMap<String, Ty>>,
621    diagnostics: &'a mut Vec<Diagnostic>,
622}
623
624impl ConstructionVisitor<'_> {
625    fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
626        self.lambda_locals
627            .last()
628            .or_else(|| self.stitch_locals.or(self.knot_locals))
629    }
630
631    /// The `DefinitionId` a knot/stitch's own name resolves to, mirroring
632    /// `strict::check_escapes`'/`check_value_calls`' own lookup — a top-level
633    /// stitch promoted to knot status is indexed under `SymbolKind::Stitch`
634    /// (#626), hence the `knot.ptr`-derived `kind`.
635    fn knot_def_id(&self, knot: &Knot) -> Option<DefinitionId> {
636        let kind = knot.symbol_kind();
637        annotations::def_id_for(self.index, self.file, kind, &knot.name.text)
638    }
639}
640
641impl HirVisitor for ConstructionVisitor<'_> {
642    fn visit_exprs(&self) -> bool {
643        true
644    }
645
646    fn enter_knot(&mut self, knot: &Knot) {
647        self.current_knot_name = Some(knot.name.text.clone());
648        self.knot_locals = self
649            .knot_def_id(knot)
650            .and_then(|id| self.bodies.get(&id))
651            .map(|b| &b.locals);
652    }
653
654    fn exit_knot(&mut self, _knot: &Knot) {
655        self.current_knot_name = None;
656        self.knot_locals = None;
657    }
658
659    fn enter_stitch(&mut self, stitch: &Stitch) {
660        // Stitches are indexed by qualified `knot.stitch` name (mirrors
661        // `strict::check_escapes`'/`check_value_calls`' own lookup).
662        // `visit::visit` only ever calls `enter_stitch` nested inside an
663        // `enter_knot`/`exit_knot` pair, so `current_knot_name` is always
664        // set here.
665        self.stitch_locals = self.current_knot_name.as_ref().and_then(|knot_name| {
666            let qualified = format!("{knot_name}.{}", stitch.name.text);
667            annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
668                .and_then(|id| self.bodies.get(&id))
669                .map(|b| &b.locals)
670        });
671    }
672
673    fn exit_stitch(&mut self, _stitch: &Stitch) {
674        self.stitch_locals = None;
675    }
676
677    fn enter_expr(&mut self, expr: &Expr) {
678        if let Expr::StructLiteral(sl) = expr {
679            // Built from direct field projections (not `self.ctx()`/
680            // `self.current_locals()`) so the borrow checker sees this only
681            // borrows `index`/`globals`/`signatures`/`resolution_by_range`/
682            // the three locals fields, disjoint from the `self.diagnostics`
683            // reborrow below — a method call opaquely borrows the whole
684            // `&self` receiver for as long as its return value lives, which
685            // would conflict with `self.diagnostics` inside the same call.
686            let ctx = MistypeCtx {
687                index: self.index,
688                globals: self.globals,
689                signatures: self.signatures,
690                resolution_by_range: self.resolution_by_range,
691                locals: self
692                    .lambda_locals
693                    .last()
694                    .or_else(|| self.stitch_locals.or(self.knot_locals)),
695            };
696            check_literal(sl, self.file, self.shapes, &ctx, self.diagnostics);
697        }
698    }
699
700    fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
701        let pruned = pruned_locals_for_lambda(l, self.index, self.current_locals());
702        self.lambda_locals.push(pruned);
703    }
704
705    fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
706        self.lambda_locals.pop();
707    }
708}
709
710/// Duplicate-field diagnostic (`E084`, issue #675) — unlike [`check`]'s
711/// missing/extra/mistyped diagnostics above, this doesn't need the shape to
712/// resolve (a repeated field name is detectable from the literal's own
713/// field list alone) and runs under *both* `types` policies: a duplicate
714/// field is a structural authoring mistake, not a type-checking concern.
715/// Callers wire this in under `dialect = Brink || is_native` (wider than
716/// every other TM-4c construction-literal check, matching `E138`'s own
717/// wiring — see the module doc) rather than gating it behind
718/// `strict::config_error` the way [`check`] is.
719#[must_use]
720pub fn check_duplicates(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
721    let mut out = Vec::new();
722    for &(file, hir) in files {
723        let mut v = DuplicateFieldVisitor {
724            file,
725            diagnostics: &mut out,
726        };
727        // Issue #2098: `DuplicateFieldVisitor::enter_expr` carries no
728        // per-position state at all, so the shared entry point covers the
729        // block tree and every file-level declaration's own initializer in
730        // one drive — the hand-rolled `check_duplicates_expr`/`expr_children`
731        // mirror of `visit::visit`'s own descent this used to need is gone.
732        visit::visit_with_decl_initializers(hir, &mut v);
733    }
734    out
735}
736
737struct DuplicateFieldVisitor<'a> {
738    file: FileId,
739    diagnostics: &'a mut Vec<Diagnostic>,
740}
741
742impl HirVisitor for DuplicateFieldVisitor<'_> {
743    fn visit_exprs(&self) -> bool {
744        true
745    }
746
747    fn enter_expr(&mut self, expr: &Expr) {
748        if let Expr::StructLiteral(sl) = expr {
749            check_literal_duplicates(sl, self.file, self.diagnostics);
750        }
751    }
752}
753
754/// Flag every field-name occurrence in `sl` beyond its first — one
755/// diagnostic per repeated initializer, naming the field and pointing at
756/// the *repeated* occurrence (not the first, so authors see exactly which
757/// initializer is the redundant one).
758fn check_literal_duplicates(sl: &StructLiteral, file: FileId, out: &mut Vec<Diagnostic>) {
759    let mut seen: crate::determinism::LookupSet<&str> = crate::determinism::LookupSet::new();
760    for (name, _value) in &sl.fields {
761        if !seen.insert(name.text.as_str()) {
762            out.push(Diagnostic {
763                file,
764                range: name.range,
765                message: format!(
766                    "{}: field `{}` is initialized more than once",
767                    DiagnosticCode::E084.title(),
768                    name.text
769                ),
770                code: DiagnosticCode::E084,
771            });
772        }
773    }
774}
775
776/// Check one struct literal against its declared shape (if resolvable — an
777/// unresolved shape name has nothing to check against, and is already
778/// diagnosed separately by `resolve::resolve_struct_ref`'s `E068`).
779///
780/// Issue #2241: `sl.shape`'s own name is a `RefKind::Struct` reference the
781/// analyzer already resolved with full module-scope `Candidacy`
782/// (`resolve::resolve_struct_ref`, walked into the `ResolutionMap` every
783/// construction literal's shape name gets — `symbols::project`'s `walk_expr`
784/// registers one for every `Expr::StructLiteral`, unconditionally). Consuming
785/// that recorded resolution by range (`ctx.resolution_by_range`) rather than
786/// re-deriving the shape from `sl.shape.text` by bare name is exactly the
787/// "lowering consumes analyzer types" fix PR #2248 already applied on the LIR
788/// side for this same reference kind — this is its analyzer-side twin. A
789/// missing entry here means `resolve_struct_ref` itself couldn't resolve the
790/// name (already reported as `E068`), so there is nothing to check against,
791/// same as before.
792fn check_literal(
793    sl: &StructLiteral,
794    file: FileId,
795    shapes: &ShapeTable,
796    ctx: &MistypeCtx<'_>,
797    out: &mut Vec<Diagnostic>,
798) {
799    let Some(shape) = ctx
800        .resolution_by_range
801        .get(&range_key(sl.shape.range))
802        .and_then(|def_id| shapes.get_by_def(*def_id))
803    else {
804        return;
805    };
806
807    // Extra fields (strict-only, since `check` only ever runs under strict
808    // per its own doc — the module's `structs::check` is only reached from
809    // `strict::check`).
810    for (name, _value) in &sl.fields {
811        if !shape.has_field(&name.text) {
812            out.push(Diagnostic {
813                file,
814                range: name.range,
815                message: format!(
816                    "{}: `{}` has no field `{}`",
817                    DiagnosticCode::E070.title(),
818                    sl.shape.text,
819                    name.text
820                ),
821                code: DiagnosticCode::E070,
822            });
823        }
824    }
825
826    // Missing fields (strict-only, since `check` only ever runs under
827    // strict per its own doc).
828    for (field_name, _ty) in &shape.fields {
829        if !sl.fields.iter().any(|(n, _)| &n.text == field_name) {
830            out.push(Diagnostic {
831                file,
832                range: sl.ptr.text_range(),
833                message: format!(
834                    "{}: `{}` is missing field `{field_name}`",
835                    DiagnosticCode::E069.title(),
836                    sl.shape.text
837                ),
838                code: DiagnosticCode::E069,
839            });
840        }
841    }
842
843    // Mistyped fields — only for classifiable initializers (see module doc:
844    // literal-shaped classify from their own shape; variable/call/index-
845    // valued ones consult `ctx`'s inference substrate).
846    for (name, value) in &sl.fields {
847        let Some(declared_ty) = shape.field_ty(&name.text) else {
848            continue; // already flagged as an extra field above
849        };
850        if declared_ty.is_unresolved() {
851            continue; // the field's own annotation didn't resolve (E061)
852        }
853        let Some(actual_ty) = classify_expr_ty(value, ctx) else {
854            continue; // not classifiable — see module doc
855        };
856        // Row-insensitive (issue #1680): a `fn`-typed field's declared type
857        // carries the top effect row and the initializer's carries its real
858        // creation target, so rows must not decide this comparison — see
859        // `infer::assignable`.
860        if !crate::infer::assignable(declared_ty, &actual_ty) {
861            out.push(Diagnostic {
862                file,
863                range: name.range,
864                message: format!(
865                    "{}: field `{}` declared `{}` but initialized with `{}`",
866                    DiagnosticCode::E071.title(),
867                    name.text,
868                    declared_ty.display(),
869                    actual_ty.display()
870                ),
871                code: DiagnosticCode::E071,
872            });
873        }
874    }
875}
876
877/// Classify a struct-field initializer's type when it's statically obvious
878/// from its own shape — literals, and (recursively) array/map/struct
879/// literals. Anything else (a variable/call/index/…) returns `None` here;
880/// [`classify_expr_ty`] is the entry point [`check_literal`] actually calls,
881/// falling back to the inference-substrate classification for those forms
882/// (issue #670) before finally treating an unclassifiable expression as
883/// silently clean — the same "Unknown never disagrees" posture
884/// `annotations::mismatches` takes.
885fn literal_ty(expr: &Expr) -> Option<Ty> {
886    match expr {
887        Expr::Int(_) => Some(Ty::Int),
888        Expr::Float(_) => Some(Ty::Float),
889        Expr::Bool(_) => Some(Ty::Bool),
890        Expr::String(s) => match s.parts.as_slice() {
891            [] | [brink_ir::StringPart::Literal(_)] => Some(Ty::String),
892            _ => None, // interpolated — not purely a literal
893        },
894        Expr::ArrayLiteral(a) => {
895            let elems: Vec<Ty> = a.elements.iter().map(literal_ty).collect::<Option<_>>()?;
896            Some(Ty::Array(Box::new(crate::infer::unify_all(elems))))
897        }
898        Expr::MapLiteral(m) => {
899            let mut keys = Vec::with_capacity(m.entries.len());
900            let mut vals = Vec::with_capacity(m.entries.len());
901            for (k, v) in &m.entries {
902                keys.push(literal_ty(k)?);
903                vals.push(literal_ty(v)?);
904            }
905            Some(Ty::Map(
906                Box::new(crate::infer::unify_all(keys)),
907                Box::new(crate::infer::unify_all(vals)),
908            ))
909        }
910        Expr::StructLiteral(sl) => Some(Ty::Struct(sl.shape.text.clone())),
911        _ => None,
912    }
913}
914
915/// Classify a struct-field initializer's type — [`literal_ty`]'s
916/// literal-shaped classification first, falling back to the non-literal
917/// forms issue #670 adds: a `Path` (variable), a `Call` (function), or an
918/// `Index` expression, each resolved through `ctx`'s inference substrate.
919/// `None` — "not classifiable" — whenever the resolved type is itself
920/// `Unknown`/`Conflicted`, or the expression shape isn't handled at all
921/// (e.g. a field access, an infix expression): the same "Unknown never
922/// disagrees" posture [`literal_ty`] and `annotations::mismatches` both take.
923///
924/// `pub(crate)` (issue #983) — see [`MistypeCtx`]'s doc for why.
925pub(crate) fn classify_expr_ty(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<Ty> {
926    if let Some(ty) = literal_ty(expr) {
927        return Some(ty);
928    }
929    match expr {
930        Expr::Path(p) => resolved_symbol_ty(p.range, ctx),
931        Expr::Call(path, _args) => {
932            // Only a direct call to a known inferable knot/stitch is
933            // classified here — a call through a function *value* is T1c's
934            // own domain (`strict::check_value_calls`'s `ValueCallFact`s),
935            // not this diagnostic's.
936            let def = ctx.resolution_by_range.get(&range_key(path.range))?;
937            let sig = ctx.signatures.get(def)?;
938            (!sig.return_ty.is_unresolved()).then(|| sig.return_ty.clone())
939        }
940        Expr::Index(idx) => {
941            let base_ty = classify_expr_ty(&idx.base, ctx)?;
942            match base_ty {
943                Ty::Array(elem) if !elem.is_unresolved() => Some(*elem),
944                Ty::Map(_key, val) if !val.is_unresolved() => Some(*val),
945                _ => None,
946            }
947        }
948        _ => None,
949    }
950}
951
952/// Resolve a `Path` expression's own range to a concrete [`Ty`]: a
953/// param/temp reads the enclosing def's finalized `BodyTypes::locals`
954/// (`ctx.locals`, `None` at file scope — see [`MistypeCtx`]'s doc); a
955/// global `VAR`/`CONST` reads `infer::collect_globals`'s declaration-derived
956/// type; a `LIST`/list-item name is nominally `List<L>`. Mirrors
957/// `infer::body::InferPass::ty_of_def`'s own dispatch exactly (the same
958/// firewall a body's own inference already enforces), just read post hoc
959/// from the finalized results instead of live during a body solve.
960fn resolved_symbol_ty(range: TextRange, ctx: &MistypeCtx<'_>) -> Option<Ty> {
961    let def = *ctx.resolution_by_range.get(&range_key(range))?;
962    let info = ctx.index.symbols.get(&def)?;
963    let ty = match info.kind {
964        SymbolKind::Param | SymbolKind::Temp => ctx.locals?.get(&info.name)?.clone(),
965        SymbolKind::Variable | SymbolKind::Constant => ctx.globals.get(&def)?.clone(),
966        SymbolKind::List => Ty::List(info.name.clone()),
967        SymbolKind::ListItem => {
968            let (list, _item) = info.name.split_once('.')?;
969            Ty::List(list.to_string())
970        }
971        SymbolKind::Knot
972        | SymbolKind::Stitch
973        | SymbolKind::External
974        | SymbolKind::Struct
975        | SymbolKind::Label => {
976            return None;
977        }
978    };
979    if ty.is_unresolved() { None } else { Some(ty) }
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985    use brink_ir::hir::lower;
986
987    fn build(src: &str) -> (HirFile, SymbolIndex) {
988        let parsed = brink_syntax::parse(src);
989        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
990        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
991        (hir, (*index).clone())
992    }
993
994    /// Like [`build`], but also computes real resolutions and a whole-project
995    /// [`InferenceResult`] — needed by every test exercising the non-literal
996    /// (variable/call/index) classification issue #670 adds, since that path
997    /// consults exactly this substrate.
998    fn build_with_inference(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
999        let parsed = brink_syntax::parse(src);
1000        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
1001        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1002        let (resolutions, _diag) =
1003            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1004        let inference = crate::infer_project(
1005            &[(FileId(0), &hir)],
1006            &index,
1007            &resolutions,
1008            None,
1009            &BTreeMap::new(),
1010        );
1011        (hir, (*index).clone(), (*resolutions).clone(), inference)
1012    }
1013
1014    /// [`check`] driven by [`build_with_inference`]'s output — the harness
1015    /// every non-literal-classification test below shares.
1016    fn check_all(src: &str) -> Vec<Diagnostic> {
1017        let (hir, index, resolutions, inference) = build_with_inference(src);
1018        check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
1019    }
1020
1021    /// [`build_with_inference`]'s native-surface twin. Lambdas exist only on
1022    /// the native surface, so the #1764 fixtures below must go through
1023    /// `lower_native` (the same reason `coalesce`'s `build_native` exists).
1024    fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
1025        let parsed = brink_syntax_native::parse(src);
1026        assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
1027        let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &parsed.tree());
1028        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1029        let (resolutions, _diag) =
1030            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1031        let inference = crate::infer_project(
1032            &[(FileId(0), &hir)],
1033            &index,
1034            &resolutions,
1035            None,
1036            &BTreeMap::new(),
1037        );
1038        (hir, (*index).clone(), (*resolutions).clone(), inference)
1039    }
1040
1041    /// [`check`] over native source — [`check_all`]'s native twin.
1042    fn check_all_native(src: &str) -> Vec<Diagnostic> {
1043        let (hir, index, resolutions, inference) = build_native(src);
1044        check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
1045    }
1046
1047    /// [`check_assignments_all`]'s native-surface twin — [`build_native`]'s
1048    /// output run through [`check_assignments`] rather than [`check`]. Only
1049    /// the native surface has lambdas, so the lambda-frame regression test
1050    /// below (issue #2906 BLOCKING review finding) needs this rather than
1051    /// `check_assignments_all`.
1052    fn check_assignments_all_native(src: &str) -> Vec<Diagnostic> {
1053        let (hir, index, _resolutions, inference) = build_native(src);
1054        check_assignments(&[(FileId(0), &hir)], &index, &inference)
1055    }
1056
1057    #[test]
1058    fn clean_construction_produces_no_diagnostics() {
1059        let diags = check_all(
1060            "STRUCT Point = #{x: float, y: float}\n\
1061             === main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
1062        );
1063        assert!(diags.is_empty(), "{diags:?}");
1064    }
1065
1066    #[test]
1067    fn missing_field_is_e069_naming_the_field() {
1068        let diags = check_all(
1069            "STRUCT Point = #{x: float, y: float}\n\
1070             === main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
1071        );
1072        assert_eq!(diags.len(), 1, "{diags:?}");
1073        assert_eq!(diags[0].code, DiagnosticCode::E069);
1074        assert!(diags[0].message.contains('y'), "{:?}", diags[0].message);
1075    }
1076
1077    #[test]
1078    fn extra_field_is_e070_naming_the_field() {
1079        let diags = check_all(
1080            "STRUCT Point = #{x: float}\n\
1081             === main ===\n~ p = Point#{x: 1.0, z: 2.0}\n-> DONE\n",
1082        );
1083        assert_eq!(diags.len(), 1, "{diags:?}");
1084        assert_eq!(diags[0].code, DiagnosticCode::E070);
1085        assert!(diags[0].message.contains('z'), "{:?}", diags[0].message);
1086    }
1087
1088    #[test]
1089    fn mistyped_field_is_e071_naming_the_field() {
1090        let diags = check_all(
1091            "STRUCT Point = #{x: float}\n\
1092             === main ===\n~ p = Point#{x: \"hi\"}\n-> DONE\n",
1093        );
1094        assert_eq!(diags.len(), 1, "{diags:?}");
1095        assert_eq!(diags[0].code, DiagnosticCode::E071);
1096        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1097    }
1098
1099    #[test]
1100    fn int_initializer_for_a_float_field_is_the_legal_coercion() {
1101        // §4's directional int -> float coercion applies here too.
1102        let diags = check_all(
1103            "STRUCT Point = #{x: float}\n\
1104             === main ===\n~ p = Point#{x: 1}\n-> DONE\n",
1105        );
1106        assert!(diags.is_empty(), "{diags:?}");
1107    }
1108
1109    // ── issue #670: variable/call/index-valued initializers ────────────
1110
1111    #[test]
1112    fn global_variable_valued_initializer_fires_when_provably_mistyped() {
1113        // `v`'s declaration-derived type is a concrete `string` (its own
1114        // literal initializer) — disagrees with `Point.x`'s declared
1115        // `float`, so this now fires exactly like a literal `"hi"` would.
1116        let diags = check_all(
1117            "STRUCT Point = #{x: float}\n\
1118             VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
1119        );
1120        assert_eq!(diags.len(), 1, "{diags:?}");
1121        assert_eq!(diags[0].code, DiagnosticCode::E071);
1122        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1123    }
1124
1125    #[test]
1126    fn global_variable_valued_initializer_of_the_right_type_is_clean() {
1127        let diags = check_all(
1128            "STRUCT Point = #{x: float}\n\
1129             VAR v = 1.0\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
1130        );
1131        assert!(diags.is_empty(), "{diags:?}");
1132    }
1133
1134    #[test]
1135    fn param_variable_valued_initializer_fires_when_provably_mistyped() {
1136        // `n`'s only use in the body is compared against a string literal,
1137        // so its inferred `BodyTypes::locals` type is a concrete `string` —
1138        // disagrees with `Point.x`'s declared `float`.
1139        let diags = check_all(
1140            "STRUCT Point = #{x: float}\n\
1141             === main(n) ===\n\
1142             {n == \"a\":\n  yes\n}\n~ p = Point#{x: n}\n-> DONE\n",
1143        );
1144        assert_eq!(diags.len(), 1, "{diags:?}");
1145        assert_eq!(diags[0].code, DiagnosticCode::E071);
1146    }
1147
1148    // ─── issue #2793: the ordinary (non-lambda) fn/knot annotated-param
1149    // half of #2786's `BodyTypes::locals` visibility fix ─────────────────
1150
1151    /// #2786 overlaid an *ordinary* `fn`/knot param's own written annotation
1152    /// onto `pass.locals` whenever the body walk left it absent — the exact
1153    /// same mechanism `option_conditions.rs`'s
1154    /// `annotated_fn_param_option_condition_is_e116` pins for E116, here for
1155    /// this file's E071 field-mismatch check instead. `n`'s only other
1156    /// appearance is the `Point#{x: n}` field initializer itself — no other
1157    /// statement observes it (mirrors
1158    /// `unused_param_variable_valued_initializer_stays_silent_when_unknown`
1159    /// just below, minus the annotation), so pre-#2786 this param stayed
1160    /// `Unknown` in `pass.locals`. Unlike `coalesce.rs`'s `or` chains and
1161    /// `contains_domain.rs`'s `contains` needle (both #2793 findings where a
1162    /// sibling `infer_intrinsic`/`infer_infix` arm's own `observe` call
1163    /// forces the param's locals entry to something else *before* the
1164    /// annotation overlay runs), a struct literal's field values are never
1165    /// `observe`d against their declared field type during the main walk
1166    /// (`infer::body::InferPass::infer_expr`'s own `Expr::StructLiteral` arm
1167    /// doc: "Field-type propagation through a struct's declared shape is
1168    /// out of scope for this slice") — so this position has no such
1169    /// pre-emption, and the annotation overlay is what finally supplies the
1170    /// classification: `n: string` disagrees with `Point.x: float`, so this
1171    /// must now fire — the new true positive #2793 asks each consumer to
1172    /// confirm.
1173    #[test]
1174    fn annotated_fn_param_field_mismatch_is_e071() {
1175        let diags = check_all(
1176            "STRUCT Point = #{x: float}\n\
1177             === main(n: string) ===\n~ p = Point#{x: n}\n-> DONE\n",
1178        );
1179        assert_eq!(diags.len(), 1, "{diags:?}");
1180        assert_eq!(diags[0].code, DiagnosticCode::E071);
1181        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1182    }
1183
1184    /// Negative control alongside
1185    /// [`annotated_fn_param_field_mismatch_is_e071`]: an ordinary annotated
1186    /// param whose declared type already agrees with the field's declared
1187    /// type must stay clean.
1188    #[test]
1189    fn annotated_fn_param_field_agreement_stays_clean() {
1190        let diags = check_all(
1191            "STRUCT Point = #{x: float}\n\
1192             === main(n: float) ===\n~ p = Point#{x: n}\n-> DONE\n",
1193        );
1194        assert!(diags.is_empty(), "{diags:?}");
1195    }
1196
1197    #[test]
1198    fn unused_param_variable_valued_initializer_stays_silent_when_unknown() {
1199        // `n` is never used anywhere else in the body, so it stays `Unknown`
1200        // — "Unknown never disagrees" holds even for a variable initializer.
1201        let diags = check_all(
1202            "STRUCT Point = #{x: float}\n\
1203             === main(n) ===\n~ p = Point#{x: n}\n-> DONE\n",
1204        );
1205        assert!(diags.is_empty(), "{diags:?}");
1206    }
1207
1208    #[test]
1209    fn call_valued_initializer_fires_when_provably_mistyped() {
1210        // `label()`'s only `~ return` is a string literal, so its
1211        // finalized `InferredSig::return_ty` is a concrete `string`.
1212        let diags = check_all(
1213            "STRUCT Point = #{x: float}\n\
1214             === function label() ===\n~ return \"a\"\n\
1215             === main ===\n~ p = Point#{x: label()}\n-> DONE\n",
1216        );
1217        assert_eq!(diags.len(), 1, "{diags:?}");
1218        assert_eq!(diags[0].code, DiagnosticCode::E071);
1219    }
1220
1221    #[test]
1222    fn call_valued_initializer_of_the_right_type_is_clean() {
1223        let diags = check_all(
1224            "STRUCT Point = #{x: float}\n\
1225             === function label() ===\n~ return 1.0\n\
1226             === main ===\n~ p = Point#{x: label()}\n-> DONE\n",
1227        );
1228        assert!(diags.is_empty(), "{diags:?}");
1229    }
1230
1231    #[test]
1232    fn index_valued_initializer_fires_when_provably_mistyped() {
1233        // `xs` is a local `~ temp` bound to a `#[...]` array-of-strings
1234        // literal, so its finalized locals type is `Array<string>` — indexing
1235        // it yields `string`, disagreeing with `Point.x`'s declared `float`.
1236        let diags = check_all(
1237            "STRUCT Point = #{x: float}\n\
1238             === main ===\n\
1239             ~ temp xs = #[\"a\", \"b\"]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1240        );
1241        assert_eq!(diags.len(), 1, "{diags:?}");
1242        assert_eq!(diags[0].code, DiagnosticCode::E071);
1243    }
1244
1245    #[test]
1246    fn index_valued_initializer_of_the_right_type_is_clean() {
1247        let diags = check_all(
1248            "STRUCT Point = #{x: float}\n\
1249             === main ===\n\
1250             ~ temp xs = #[1.0, 2.0]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1251        );
1252        assert!(diags.is_empty(), "{diags:?}");
1253    }
1254
1255    #[test]
1256    fn index_valued_initializer_stays_silent_when_unknown() {
1257        // `xs` is only ever indexed, never assigned/observed to a concrete
1258        // type elsewhere — reading through an `Unknown` base never learns an
1259        // array shape (`infer::body`'s own `Expr::Index` arm), so this stays
1260        // silent rather than false-flagging.
1261        let diags = check_all(
1262            "STRUCT Point = #{x: float}\n\
1263             === main(xs) ===\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1264        );
1265        assert!(diags.is_empty(), "{diags:?}");
1266    }
1267
1268    #[test]
1269    fn unresolved_shape_name_is_not_double_reported_here() {
1270        // No `STRUCT Bogus` declared — `resolve::resolve_struct_ref` already
1271        // reports E068 elsewhere; this pass has nothing to check against.
1272        let diags = check_all("=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n");
1273        assert!(diags.is_empty(), "{diags:?}");
1274    }
1275
1276    #[test]
1277    fn nested_struct_literal_field_is_checked_by_shape_name() {
1278        let diags = check_all(
1279            "STRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
1280             === main ===\n~ o = Outer#{inner: Inner#{v: 1.0}}\n-> DONE\n",
1281        );
1282        assert!(diags.is_empty(), "{diags:?}");
1283    }
1284
1285    #[test]
1286    fn nested_struct_literal_mistyped_field_still_flags_outer() {
1287        let diags = check_all(
1288            "STRUCT Wrong = #{v: float}\nSTRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
1289             === main ===\n~ o = Outer#{inner: Wrong#{v: 1.0}}\n-> DONE\n",
1290        );
1291        assert_eq!(diags.len(), 1, "{diags:?}");
1292        assert_eq!(diags[0].code, DiagnosticCode::E071);
1293    }
1294
1295    #[test]
1296    fn struct_literal_inside_var_initializer_is_checked() {
1297        let diags = check_all("STRUCT Point = #{x: float}\nVAR p = Point#{x: \"hi\"}\n");
1298        assert_eq!(diags.len(), 1, "{diags:?}");
1299        assert_eq!(diags[0].code, DiagnosticCode::E071);
1300    }
1301
1302    #[test]
1303    fn variable_valued_initializer_inside_var_initializer_uses_global_scope_only() {
1304        // A struct literal in a file-level VAR/CONST initializer has no
1305        // enclosing knot/stitch body — only a reference to *another* global
1306        // is classifiable there (never a param/temp, which can't exist at
1307        // file scope). `other`'s declared type disagrees with `Point.x`.
1308        let diags =
1309            check_all("STRUCT Point = #{x: float}\nVAR other = \"hi\"\nVAR p = Point#{x: other}\n");
1310        assert_eq!(diags.len(), 1, "{diags:?}");
1311        assert_eq!(diags[0].code, DiagnosticCode::E071);
1312    }
1313
1314    #[test]
1315    fn stitch_local_variable_valued_initializer_fires_when_provably_mistyped() {
1316        // Every other non-literal-classification test above only ever
1317        // exercises knot scope (`main`), file scope, or `main(n)`'s own
1318        // params — never a stitch body. This drives the `enter_stitch`/
1319        // `stitch_locals` dispatch path specifically: `t`'s finalized
1320        // `BodyTypes::locals` type (a concrete `string`, from its own
1321        // literal initializer) disagrees with `Point.x`'s declared `float`.
1322        let diags = check_all(
1323            "STRUCT Point = #{x: float}\n\
1324             === room ===\n= inside\n~ temp t = \"hi\"\n~ p = Point#{x: t}\n-> DONE\n",
1325        );
1326        assert_eq!(diags.len(), 1, "{diags:?}");
1327        assert_eq!(diags[0].code, DiagnosticCode::E071);
1328        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1329    }
1330
1331    #[test]
1332    fn mistyped_variable_field_diagnostic_is_order_independent() {
1333        // Issue #670's own scope names "order-independence property tests
1334        // per the #627 discipline" as a deliverable — mirrors strict.rs's
1335        // `escape_diagnostics_are_order_independent` forward/reversed
1336        // pattern. `v`'s mistyped classification (and the resulting E071)
1337        // must not depend on which position its field initializer occupies
1338        // in the literal.
1339        let forward = "STRUCT Point = #{x: float, y: float}\n\
1340             VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v, y: 1.0}\n-> DONE\n";
1341        let reversed = "STRUCT Point = #{x: float, y: float}\n\
1342             VAR v = \"hi\"\n=== main ===\n~ p = Point#{y: 1.0, x: v}\n-> DONE\n";
1343
1344        let diags_f = check_all(forward);
1345        let diags_r = check_all(reversed);
1346
1347        assert_eq!(diags_f.len(), 1, "{diags_f:?}");
1348        assert_eq!(diags_f[0].code, DiagnosticCode::E071);
1349        assert!(diags_f[0].message.contains('x'), "{:?}", diags_f[0].message);
1350
1351        assert_eq!(diags_r.len(), 1, "{diags_r:?}");
1352        assert_eq!(diags_r[0].code, DiagnosticCode::E071);
1353        assert!(diags_r[0].message.contains('x'), "{:?}", diags_r[0].message);
1354    }
1355
1356    // ─── check_assignments (E063, issue #1900) ─────────────────────────
1357
1358    /// [`check_assignments`] driven by [`build_with_inference`]'s output —
1359    /// mirrors [`check_all`] for the plain-assignment sibling check.
1360    fn check_assignments_all(src: &str) -> Vec<Diagnostic> {
1361        let (hir, index, _resolutions, inference) = build_with_inference(src);
1362        check_assignments(&[(FileId(0), &hir)], &index, &inference)
1363    }
1364
1365    #[test]
1366    fn field_assignment_mismatch_on_var_is_e063_naming_the_dotted_target() {
1367        // The issue's own repro: `p.x`'s declared `float` disagrees with the
1368        // RHS `string`.
1369        let diags = check_assignments_all(
1370            "STRUCT Point = #{x: float, y: float}\n\
1371             VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
1372             === main ===\n~ p.x = \"wrong\"\n-> DONE\n",
1373        );
1374        assert_eq!(diags.len(), 1, "{diags:?}");
1375        assert_eq!(diags[0].code, DiagnosticCode::E063);
1376        assert!(diags[0].message.contains("p.x"), "{:?}", diags[0].message);
1377    }
1378
1379    #[test]
1380    fn field_assignment_of_the_declared_type_is_clean() {
1381        let diags = check_assignments_all(
1382            "STRUCT Point = #{x: float, y: float}\n\
1383             VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
1384             === main ===\n~ p.x = 1.0\n-> DONE\n",
1385        );
1386        assert!(diags.is_empty(), "{diags:?}");
1387    }
1388
1389    #[test]
1390    fn field_assignment_int_initializer_for_a_float_field_is_the_legal_coercion() {
1391        // §4's directional int -> float coercion applies here too, same as
1392        // `int_initializer_for_a_float_field_is_the_legal_coercion` above.
1393        let diags = check_assignments_all(
1394            "STRUCT Point = #{x: float}\nVAR p: Point = Point#{x: 0.0}\n\
1395             === main ===\n~ p.x = 1\n-> DONE\n",
1396        );
1397        assert!(diags.is_empty(), "{diags:?}");
1398    }
1399
1400    #[test]
1401    fn field_assignment_mismatch_on_annotated_temp_is_e063() {
1402        // The second of the issue's two named root sources: an annotated `~
1403        // temp`'s own ascription, not a global.
1404        let diags = check_assignments_all(
1405            "STRUCT Point = #{x: float}\n\
1406             === main ===\n~ temp p: Point = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
1407        );
1408        assert_eq!(diags.len(), 1, "{diags:?}");
1409        assert_eq!(diags[0].code, DiagnosticCode::E063);
1410    }
1411
1412    #[test]
1413    fn field_assignment_mismatch_on_unannotated_temp_with_construction_literal_initializer_is_e063_issue_2906()
1414     {
1415        // Issue #2906: before this fix, `check_declared_field_assign_target`
1416        // only ever resolved a Temp root's shape from `self.annotated` (an
1417        // explicit `~ temp p: Point = …` ascription) — an *unannotated* `~
1418        // temp p = Point#{…}` has no ascription, so this test used to pin
1419        // silence here even though the shape is plainly knowable from the
1420        // construction-literal initializer itself. That was the recording-
1421        // site gap the issue reports, not a genuine "no shape to check
1422        // against" case — `check_declared_field_assign_target` now also
1423        // consults the initializer's own inferred `Ty::Struct` shape when
1424        // there is no explicit ascription, so this fires `E063` exactly like
1425        // the annotated spelling does (see
1426        // `field_assignment_mismatch_on_annotated_temp_is_e063` above).
1427        let diags = check_assignments_all(
1428            "STRUCT Point = #{x: float}\n\
1429             === main ===\n~ temp p = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
1430        );
1431        assert_eq!(diags.len(), 1, "{diags:?}");
1432        assert_eq!(diags[0].code, DiagnosticCode::E063);
1433    }
1434
1435    #[test]
1436    fn field_assignment_on_genuinely_unresolved_temp_stays_silent() {
1437        // The genuine "Unknown never disagrees" case, distinct from the one
1438        // above (issue #2906): a `~ temp` whose value is never statically
1439        // knowable at all — here, an unannotated `EXTERNAL` call with no
1440        // declared return type — never resolves past `Ty::Unknown`, so
1441        // there is truly no shape to check the field name or value against.
1442        let diags = check_assignments_all(
1443            "STRUCT Point = #{x: float}\n\
1444             EXTERNAL make_point()\n\
1445             === main ===\n~ temp p = make_point()\n~ p.x = \"wrong\"\n-> DONE\n",
1446        );
1447        assert!(diags.is_empty(), "{diags:?}");
1448    }
1449
1450    #[test]
1451    fn field_assignment_to_a_nonexistent_field_is_e185_issue_1944() {
1452        // Issue #1944: before this fix, a field name the resolved shape
1453        // doesn't declare was silently accepted here — this test itself
1454        // used to pin that as `diags.is_empty()`, which was the exact hole
1455        // the issue reports (a plain assignment to an unknown field
1456        // compiled clean under strict, with no E070-equivalent for a
1457        // non-literal target). The shape *is* resolved here (`p: Point`),
1458        // so the name can actually be checked — unlike an Unknown/untyped
1459        // receiver, where "Unknown never disagrees" still applies (see
1460        // `field_assignment_to_a_nonexistent_field_on_unresolved_receiver_stays_silent`
1461        // below).
1462        let diags = check_assignments_all(
1463            "STRUCT Point = #{x: float}\n\
1464             VAR p: Point = Point#{x: 0.0}\n\
1465             === main ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
1466        );
1467        assert_eq!(diags.len(), 1, "{diags:?}");
1468        assert_eq!(diags[0].code, DiagnosticCode::E185);
1469        assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
1470    }
1471
1472    /// Sibling should-NOT-fire case (issue #1944 design constraint,
1473    /// corrected per review finding): an unannotated function parameter's
1474    /// root never resolves past `Ty::Unknown` — there is genuinely no shape
1475    /// anywhere to check the field name against — so "Unknown never
1476    /// disagrees" holds for the *receiver* exactly as it does for `E063`
1477    /// (see `field_assignment_on_genuinely_unresolved_temp_stays_silent`
1478    /// above, E063's own sibling). Issue #2906 closed the *other* silent
1479    /// case this test used to also cover — an unannotated `~ temp p =
1480    /// Point#{x: 0.0}` — by widening the recording site's fallback to the
1481    /// initializer's own inferred shape (see
1482    /// `field_assignment_to_a_nonexistent_field_on_unannotated_temp_with_construction_literal_initializer_is_e185_issue_2906`
1483    /// below); a function param has no initializer at all to fall back to,
1484    /// so it stays the genuine "no shape anywhere" case.
1485    #[test]
1486    fn field_assignment_to_a_nonexistent_field_on_unresolved_receiver_stays_silent() {
1487        let diags = check_assignments_all(
1488            "STRUCT Point = #{x: float}\n\
1489             === function f(p) ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
1490        );
1491        assert!(diags.is_empty(), "{diags:?}");
1492    }
1493
1494    #[test]
1495    fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_with_construction_literal_initializer_is_e185_issue_2906()
1496     {
1497        // Issue #2906: the `E185` twin of
1498        // `field_assignment_mismatch_on_unannotated_temp_with_construction_literal_initializer_is_e063_issue_2906`
1499        // above — same recording-site widening, same seam
1500        // (`check_field_assign_mismatch`), the unknown-field-name arm
1501        // instead of the type-mismatch arm.
1502        let diags = check_assignments_all(
1503            "STRUCT Point = #{x: float}\n\
1504             === main ===\n~ temp p = Point#{x: 0.0}\n~ p.bogus = 1\n-> DONE\n",
1505        );
1506        assert_eq!(diags.len(), 1, "{diags:?}");
1507        assert_eq!(diags[0].code, DiagnosticCode::E185);
1508        assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
1509    }
1510
1511    #[test]
1512    fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_a_different_struct_stays_silent()
1513     {
1514        // Issue #2906's own conservative-reassignment carve-out: `p`'s
1515        // initializer resolves to `Point`, but `p` is reassigned to a
1516        // different, incompatible-shaped struct before the field write. The
1517        // initializer-inferred shape lives in a declaration-time-only map
1518        // (mirroring `self.annotated`'s own "recorded once, consulted as a
1519        // fallback" shape) that a later plain reassignment never touches —
1520        // so this is NOT caught by `Ty::unify` driving `self.locals["p"]` to
1521        // `Ty::Conflicted` the way a bare `check_declared_assign_target`
1522        // fact would be. It is the explicit `reassigned_temps` bookkeeping
1523        // this fix adds (every bare `~ p = expr` reassignment anywhere in
1524        // the body, recorded during the walk) that withdraws the pending
1525        // fact post-walk instead.
1526        let diags = check_assignments_all(
1527            "STRUCT Point = #{x: float}\n\
1528             STRUCT Other = #{y: float}\n\
1529             === main ===\n~ temp p = Point#{x: 0.0}\n~ p = Other#{y: 1.0}\n\
1530             ~ p.bogus = 1\n-> DONE\n",
1531        );
1532        assert!(diags.is_empty(), "{diags:?}");
1533    }
1534
1535    #[test]
1536    fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_an_unknown_shape_stays_silent()
1537     {
1538        // Issue #2906's harder conservative case, called out explicitly by
1539        // the issue: `p` is reassigned to an *unresolvable* value (an
1540        // unannotated `EXTERNAL` call's return) rather than a different
1541        // concrete struct. `Ty::unify(Ty::Struct(_), Ty::Unknown)` is the
1542        // identity rule — it stays `Ty::Struct("Point")`, NOT `Conflicted`
1543        // — so `self.locals` alone can never distinguish "p was never
1544        // reassigned" from "p was reassigned to something unresolvable".
1545        // Without the explicit `reassigned_temps` tracking this fix adds,
1546        // the widened fallback would still trust the stale `Point` shape
1547        // here and false-fire `E185` on a receiver that might legitimately
1548        // be any shape at this point.
1549        let diags = check_assignments_all(
1550            "STRUCT Point = #{x: float}\n\
1551             EXTERNAL make_thing()\n\
1552             === main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
1553             ~ p.bogus = 1\n-> DONE\n",
1554        );
1555        assert!(diags.is_empty(), "{diags:?}");
1556    }
1557
1558    /// BLOCKING review finding on issue #2906's own PR: `temp_init_shapes`
1559    /// used to be excluded from [`FrameSnapshot`] on the claim that a
1560    /// lambda-local shadow's imprecision here was under-detection-only,
1561    /// never a false positive. It was a false positive — a lambda-local `~
1562    /// temp p = …` of a *different* struct permanently clobbered the
1563    /// *outer* `p`'s entry, surviving past the lambda's own frame, so the
1564    /// outer `p.y = 1.0` (legal — `p` is an `Other`, which does declare
1565    /// `y`) read the lambda's stale `Point` shape instead and false-fired
1566    /// `E185` (`Point` has no field `y`). Reproduced against PR head
1567    /// 98d2ad24 verbatim from the review finding. Native surface only —
1568    /// lambdas don't exist on the ink-compat surface.
1569    #[test]
1570    fn dotted_assign_target_outer_temp_survives_a_lambda_local_shadow_of_the_same_name() {
1571        let diags = check_assignments_all_native(
1572            "struct Point { x: float }\n\
1573             struct Other { y: float }\n\
1574             fn main() {\n\
1575             \x20 let p = Other { y: 0.0 };\n\
1576             \x20 let f = ||: int { let p = Point { x: 0.0 }; 0 };\n\
1577             \x20 p.y = 1.0;\n\
1578             }\n",
1579        );
1580        assert!(diags.is_empty(), "{diags:?}");
1581    }
1582
1583    /// BLOCKING review finding on issue #2906's own PR: `record_ref_param_writes`
1584    /// only ever folded a `ref`-out call-site rebind into `record_write`/
1585    /// `record_fn_write` (the effect-row summary), never into
1586    /// `reassigned_temps` — so `resolve_pending_field_assign_mismatches`
1587    /// stayed blind to a `ref`-out param rebinding the caller's local to an
1588    /// entirely different, unresolvable-shaped value. `ref`-out params are
1589    /// idiomatic ink, not an exotic shape; a callee that always rebinds its
1590    /// `ref` param is common (`reset`, `swap`, …). Reproduced against PR
1591    /// head 98d2ad24 verbatim from the review finding: `p` is a `Point` at
1592    /// its own declaration, but `reset(ref p)` rebinds it to an `Other`
1593    /// before the dotted write, so `p.bogus = 1` must stay silent exactly
1594    /// like the bare-reassignment carve-out above already does.
1595    #[test]
1596    fn dotted_assign_target_stays_silent_after_a_ref_out_param_rebind() {
1597        let diags = check_assignments_all(
1598            "STRUCT Point = #{x: float}\n\
1599             STRUCT Other = #{y: float}\n\
1600             === function reset(ref q) ===\n~ q = Other#{y: 1.0}\n~ return\n\
1601             === main ===\n~ temp p = Point#{x: 0.0}\n~ reset(ref p)\n\
1602             ~ p.bogus = 1\n-> DONE\n",
1603        );
1604        assert!(diags.is_empty(), "{diags:?}");
1605    }
1606
1607    /// BLOCKING review finding (smaller) on issue #2906's own PR:
1608    /// `register_temp_init_shape` used to unconditionally clear
1609    /// `reassigned_temps` on every same-named `TempDecl` redeclaration —
1610    /// erasing reassignment history a fact staged *earlier* (between the
1611    /// reassignment and the redeclaration) depended on, since
1612    /// `pending_inferred_field_assign_mismatches` is resolved once, post-walk.
1613    /// Reproduced against PR head 98d2ad24 verbatim from the review finding:
1614    /// `p` is reassigned to an unresolvable `make_thing()` result before the
1615    /// dotted write (already covered by the "reassigned to an unknown
1616    /// shape" carve-out above), then redeclared again afterward — the
1617    /// redeclaration must not retroactively un-withdraw the staged fact.
1618    #[test]
1619    fn dotted_assign_target_reassignment_history_survives_a_later_redeclaration_of_the_same_name() {
1620        let diags = check_assignments_all(
1621            "STRUCT Point = #{x: float}\n\
1622             EXTERNAL make_thing()\n\
1623             === main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
1624             ~ p.bogus = 1\n~ temp p = Point#{x: 0.0}\n-> DONE\n",
1625        );
1626        assert!(diags.is_empty(), "{diags:?}");
1627    }
1628
1629    /// Sibling enumeration (issue #1944): `BlockStmt::Assignment` — the T1b
1630    /// `~ { … }` block form — reports E185 exactly like `Stmt::Assignment`
1631    /// above. `infer_block_stmt`'s `BlockStmt::Assignment` arm calls the
1632    /// identical `check_declared_field_assign_target`, recording the same
1633    /// `FieldAssignMismatch` fact `check_field_assign_mismatch` walks
1634    /// regardless of which statement form produced it — the two call sites
1635    /// are structural mirrors, not independently-checked paths.
1636    #[test]
1637    fn field_assignment_to_a_nonexistent_field_inside_a_block_stmt_is_e185_issue_1944() {
1638        let diags = check_assignments_all(
1639            "STRUCT Point = #{x: float}\n\
1640             VAR p: Point = Point#{x: 0.0}\n\
1641             === main ===\n~ {\n    p.bogus = \"wrong\"\n}\n-> DONE\n",
1642        );
1643        assert_eq!(diags.len(), 1, "{diags:?}");
1644        assert_eq!(diags[0].code, DiagnosticCode::E185);
1645    }
1646
1647    #[test]
1648    fn bare_var_assignment_is_not_double_reported_by_check_assignments() {
1649        // A single-segment target is `check_declared_assign_target`'s job
1650        // (issue #1877 / E063 via `strict::check_typed_assign_mismatches`),
1651        // never this dotted-target check's — `check_assignments` must stay
1652        // silent for it (no double-report across the two checks).
1653        let diags = check_assignments_all("VAR v: int = 5\n=== main ===\n~ v = \"hi\"\n-> DONE\n");
1654        assert!(diags.is_empty(), "{diags:?}");
1655    }
1656
1657    // ─── check_duplicates (E084, issue #675) ──────────────────────────
1658
1659    #[test]
1660    fn duplicate_field_is_e084_naming_the_field() {
1661        let (hir, _index) = build(
1662            "STRUCT Point = #{x: float, y: float}\n\
1663             === main ===\n~ p = Point#{x: 1.0, x: 2.0, y: 3.0}\n-> DONE\n",
1664        );
1665        let diags = check_duplicates(&[(FileId(0), &hir)]);
1666        assert_eq!(diags.len(), 1, "{diags:?}");
1667        assert_eq!(diags[0].code, DiagnosticCode::E084);
1668        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1669    }
1670
1671    #[test]
1672    fn duplicate_field_points_at_the_repeated_occurrence_not_the_first() {
1673        let src =
1674            "STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0, x: 2.0}\n-> DONE\n";
1675        let (hir, _index) = build(src);
1676        let diags = check_duplicates(&[(FileId(0), &hir)]);
1677        assert_eq!(diags.len(), 1, "{diags:?}");
1678        let second_x = src.rfind("x: 2.0").expect("second x initializer");
1679        assert_eq!(usize::from(diags[0].range.start()), second_x);
1680    }
1681
1682    #[test]
1683    fn clean_construction_has_no_duplicate_diagnostic() {
1684        let (hir, _index) = build(
1685            "STRUCT Point = #{x: float, y: float}\n\
1686             === main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
1687        );
1688        let diags = check_duplicates(&[(FileId(0), &hir)]);
1689        assert!(diags.is_empty(), "{diags:?}");
1690    }
1691
1692    #[test]
1693    fn duplicate_field_flagged_even_under_gradual_and_unresolved_shape() {
1694        // No `types = strict` context needed here (this build() harness
1695        // never runs `strict::check`'s gate) — and the shape name doesn't
1696        // even need to resolve, unlike `check`'s missing/extra/mistyped
1697        // trio: a repeated field name is a mistake regardless.
1698        let (hir, _index) = build("=== main ===\n~ p = Bogus#{x: 1, x: 2}\n-> DONE\n");
1699        let diags = check_duplicates(&[(FileId(0), &hir)]);
1700        assert_eq!(diags.len(), 1, "{diags:?}");
1701        assert_eq!(diags[0].code, DiagnosticCode::E084);
1702    }
1703
1704    #[test]
1705    fn duplicate_field_inside_var_initializer_is_checked() {
1706        let (hir, _index) = build("STRUCT Point = #{x: float}\nVAR p = Point#{x: 1.0, x: 2.0}\n");
1707        let diags = check_duplicates(&[(FileId(0), &hir)]);
1708        assert_eq!(diags.len(), 1, "{diags:?}");
1709        assert_eq!(diags[0].code, DiagnosticCode::E084);
1710    }
1711
1712    // ─── issue #1764: a lambda's statements in a VAR/CONST initializer ──
1713
1714    /// Coverage for a lambda's statements in a VAR/CONST initializer comes
1715    /// from `visit::visit_with_decl_initializers` (which reaches the
1716    /// initializer at all) composed with `walk_expr`'s `Expr::Lambda` arm
1717    /// (which already descends a lambda's statements) — there is no
1718    /// separate hand-rolled recursion for this position (issue #2098). A
1719    /// block-bodied lambda's `let` is a statement, not the body's value
1720    /// expression.
1721    #[test]
1722    fn a_duplicate_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
1723        let (hir, _index, _res, _inf) = build_native(
1724            "struct Point { x: float }\nvar f = ||: int {\n  let p = Point { x: 1.0, x: 2.0 };\n  0\n};\n",
1725        );
1726        let diags = check_duplicates(&[(FileId(0), &hir)]);
1727        assert_eq!(diags.len(), 1, "{diags:?}");
1728        assert_eq!(diags[0].code, DiagnosticCode::E084);
1729    }
1730
1731    /// The shape-agreement trio reaches the same position — a literal-valued
1732    /// initializer classifies without any locals, so `MistypeCtx::locals =
1733    /// None` is no obstacle here.
1734    #[test]
1735    fn a_mistyped_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
1736        let diags = check_all_native(
1737            "struct Point { x: float }\nvar f = ||: int {\n  let p = Point { x: \"hi\" };\n  0\n};\n",
1738        );
1739        assert_eq!(diags.len(), 1, "{diags:?}");
1740        assert_eq!(diags[0].code, DiagnosticCode::E071);
1741        assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1742    }
1743
1744    /// The tail position was already covered — pinned so a later refactor
1745    /// can't trade one half of the body for the other.
1746    #[test]
1747    fn a_mistyped_field_in_a_lambda_tail_of_a_var_initializer_is_still_reported() {
1748        let diags = check_all_native(
1749            "struct Point { x: float }\nvar f = ||: Point {\n  let a = 1;\n  Point { x: \"hi\" }\n};\n",
1750        );
1751        assert_eq!(diags.len(), 1, "{diags:?}");
1752        assert_eq!(diags[0].code, DiagnosticCode::E071);
1753    }
1754
1755    // ─── issue #2773: a lambda-own binding must not inherit an outer
1756    // same-named local's type ────────────────────────────────────────
1757
1758    /// Reproduces the hazard issue #2773 tracks, live in this file's own
1759    /// `ConstructionVisitor` before its `enter_lambda`/`exit_lambda` frame
1760    /// existed: `resolved_symbol_ty` reads `ctx.locals` (bare-name-keyed
1761    /// `BodyTypes::locals`) with no shadowing frame of its own, and
1762    /// `ConstructionVisitor` is `HirVisitor`-driven — `hir::visit::walk_expr`
1763    /// has descended into a lambda's own block body since issue #1685, so
1764    /// every `enter_expr` this visitor received for an expression inside the
1765    /// lambda body was already reading the *enclosing* `build`'s locals,
1766    /// unpruned. `build`'s own temp `x` is `array`-typed
1767    /// (`[1, 2, 3]`); the lambda's own `x: int` param shadows it. Pre-fix,
1768    /// `Point { x: x }`'s field initializer resolved `x` to the outer
1769    /// `array` — never assignable to `Point.x: float` — a false-positive
1770    /// `E071`. `int` *is* legally assignable to `float` (the directional
1771    /// coercion `int_initializer_for_a_float_field_is_the_legal_coercion`
1772    /// pins above), so the fixed behavior is clean.
1773    #[test]
1774    fn lambda_param_shadowing_outer_local_of_a_different_type_is_not_misclassified() {
1775        let diags = check_all_native(
1776            "struct Point { x: float }\n\
1777             fn build() {\n  let x = [1, 2, 3];\n  let f = |x: int| {\n    let p = Point { x: x };\n  };\n}\n",
1778        );
1779        assert!(diags.is_empty(), "{diags:?}");
1780    }
1781
1782    /// The pruning must not silence a *genuine* mistype inside the lambda's
1783    /// own body — only the outer binding's type is discarded, not
1784    /// classification itself. The lambda's own `x: string` param really is
1785    /// the wrong type for `Point.x: float`.
1786    #[test]
1787    fn lambda_param_own_annotation_still_flags_a_genuine_mistype() {
1788        let diags = check_all_native(
1789            "struct Point { x: float }\n\
1790             fn build() {\n  let x = [1, 2, 3];\n  let f = |x: string| {\n    let p = Point { x: x };\n  };\n}\n",
1791        );
1792        assert_eq!(diags.len(), 1, "{diags:?}");
1793        assert_eq!(diags[0].code, DiagnosticCode::E071);
1794    }
1795
1796    #[test]
1797    fn three_way_duplicate_flags_every_repeat_after_the_first() {
1798        let (hir, _index) = build(
1799            "STRUCT Point = #{x: float}\n\
1800             === main ===\n~ p = Point#{x: 1.0, x: 2.0, x: 3.0}\n-> DONE\n",
1801        );
1802        let diags = check_duplicates(&[(FileId(0), &hir)]);
1803        assert_eq!(diags.len(), 2, "{diags:?}");
1804        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E084));
1805    }
1806
1807    // ─── issue #2241: declared_shapes is referrer-scoped, not last-wins ──
1808
1809    /// Build a project file coexisting with a "std"-shaped file (M-2d
1810    /// cross-declared-module coexistence, mirroring the real stdlib mount
1811    /// #2080) — each declares its own `STRUCT Cue`, with the project's own
1812    /// carrying MORE fields than the coexisting file's same-named one.
1813    /// Returns everything [`check`] needs to validate the project's own
1814    /// construction literal.
1815    ///
1816    /// No `#@module` directive in either source: the module tag a real
1817    /// compile derives from `#@module`/the native path is supplied directly
1818    /// here via `ModuleMap`, exactly as `manifest::tests`'s own
1819    /// `cross_declared_module_duplicate_coexists_under_brink` does — this
1820    /// test only needs the tag on the index, not the parsed source's own
1821    /// (irrelevant) `HirFile::module`.
1822    fn build_project_with_std_homonym(
1823        project_src: &str,
1824        std_src: &str,
1825    ) -> (
1826        FileId,
1827        HirFile,
1828        FileId,
1829        HirFile,
1830        SymbolIndex,
1831        ResolutionMap,
1832        InferenceResult,
1833    ) {
1834        let project_file = FileId(0);
1835        let std_file = FileId(1);
1836
1837        let project_parsed = brink_syntax::parse(project_src);
1838        let (project_hir, project_manifest, _diag) = lower(project_file, &project_parsed.tree());
1839        let std_parsed = brink_syntax::parse(std_src);
1840        let (std_hir, std_manifest, _diag) = lower(std_file, &std_parsed.tree());
1841
1842        let mut modules = crate::ModuleMap::new();
1843        modules.insert(
1844            project_file,
1845            crate::ResolvedModule {
1846                name: "story::main".to_string(),
1847                declared: true,
1848                was: None,
1849            },
1850        );
1851        modules.insert(
1852            std_file,
1853            crate::ResolvedModule {
1854                name: "std::conventions::screenplay".to_string(),
1855                declared: true,
1856                was: None,
1857            },
1858        );
1859
1860        let (index, diag) = crate::symbol_index_with_modules(
1861            &[(project_file, &project_manifest), (std_file, &std_manifest)],
1862            &modules,
1863            crate::Dialect::Brink,
1864            false,
1865        );
1866        assert!(
1867            diag.is_empty(),
1868            "cross-declared-module `Cue`s must coexist with no diagnostic: {diag:?}"
1869        );
1870
1871        let project_scope =
1872            crate::ImportScope::new(Some("story::main".to_string()), &project_hir.imports);
1873        let (project_resolutions, _diag) =
1874            crate::resolve(project_file, &project_manifest, &index, &project_scope);
1875        let std_scope = crate::ImportScope::new(
1876            Some("std::conventions::screenplay".to_string()),
1877            &std_hir.imports,
1878        );
1879        let (std_resolutions, _diag) = crate::resolve(std_file, &std_manifest, &index, &std_scope);
1880
1881        let mut resolutions: ResolutionMap = (*project_resolutions).clone();
1882        resolutions.extend((*std_resolutions).iter().cloned());
1883
1884        let files = [(project_file, &project_hir), (std_file, &std_hir)];
1885        let inference = crate::infer_project(&files, &index, &resolutions, None, &BTreeMap::new());
1886
1887        (
1888            project_file,
1889            project_hir,
1890            std_file,
1891            std_hir,
1892            (*index).clone(),
1893            resolutions,
1894            inference,
1895        )
1896    }
1897
1898    /// The wave's own headline scenario: a project's own `STRUCT Cue`
1899    /// coexists with a same-named `STRUCT Cue` from a distinct declared
1900    /// module (mirrors `std/conventions/screenplay.brink`'s real one-field
1901    /// `Cue`). Before this fix, `declared_shapes` built a flat
1902    /// `BTreeMap<String, ShapeInfo>` via plain last-`insert`-wins — with
1903    /// `files` ordered `[project, std]` below, std's `ShapeInfo` is inserted
1904    /// LAST and silently overwrites the project's own in the table, even
1905    /// though the construction literal itself lives in the project file and
1906    /// `resolve::resolve_struct_ref` already resolves it to the PROJECT's own
1907    /// `Cue` (never std's — the referrer's own module wins that tie-break).
1908    /// The missing-field check would then validate against std's one-field
1909    /// shape, which the literal's sole `speaker` initializer already
1910    /// satisfies — a silent E069 false negative: "accepted when it should
1911    /// error" (issue #2241's own words).
1912    ///
1913    /// Rule 20a: verified this test FAILS on the pre-fix code (reverting
1914    /// `declared_shapes` to the flat bare-name `BTreeMap::insert` and
1915    /// `check_literal` to `shapes.get(&sl.shape.text)`) — the assertion
1916    /// below (`diags.len() == 1`, `E069` naming `voiceover`) fails with
1917    /// `diags` empty instead, because std's one-field shape (which won the
1918    /// last-insert race with `files = [project, std]`) sees the literal's
1919    /// sole `speaker` field as complete.
1920    #[test]
1921    fn construction_check_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
1922        let project_src = "STRUCT Cue = #{speaker: string, voiceover: string}\n\
1923             === main ===\n~ p = Cue#{speaker: \"A\"}\n-> DONE\n";
1924        let std_src = "STRUCT Cue = #{speaker: string}\nHello.\n";
1925
1926        let (project_file, project_hir, std_file, std_hir, index, resolutions, inference) =
1927            build_project_with_std_homonym(project_src, std_src);
1928
1929        // Deliberately `[project, std]` — std's shape is inserted LAST into
1930        // the pre-fix flat table, exposing the last-wins bug.
1931        let files = [(project_file, &project_hir), (std_file, &std_hir)];
1932        let diags = check(&files, &index, &inference, &resolutions);
1933
1934        assert_eq!(diags.len(), 1, "{diags:?}");
1935        assert_eq!(diags[0].code, DiagnosticCode::E069);
1936        assert!(
1937            diags[0].message.contains("voiceover"),
1938            "the missing-field diagnostic must name the PROJECT's own missing field \
1939             (`voiceover`), proving the check validated the literal against the project's own \
1940             2-field `Cue` shape rather than the coexisting file's 1-field one: {diags:?}"
1941        );
1942    }
1943
1944    /// F2 review finding (#2253): [`ShapeTable::resolve`] is the path
1945    /// [`check_assignments`]/[`check_field_assign_mismatch`] (E063) uses —
1946    /// unlike [`check`]/[`check_literal`] above, which never calls
1947    /// `resolve` at all (it goes through [`ShapeTable::get_by_def`] with an
1948    /// identity already resolved by `resolve::resolve_struct_ref`). This
1949    /// exercises the multi-candidate branch `resolve` exists to handle,
1950    /// through its own dedicated consumer rather than a proxy.
1951    ///
1952    /// Project and std each declare their own `STRUCT Cue`, deliberately
1953    /// with *different* declared types for the same field name (`x: float`
1954    /// vs `x: string`) so a wrong resolution doesn't just report the wrong
1955    /// message — it silently reports NOTHING: assigning the string
1956    /// `"wrong"` to `p.x` disagrees with the project's own `float`, but
1957    /// would agree with std's `string`. If `resolve` ever picked std's
1958    /// `Cue` for a reference inside the project file, this regresses to an
1959    /// empty `diags` exactly like the pre-fix last-insert-wins bug did for
1960    /// E069 above.
1961    ///
1962    /// Rule 20a: verified this test FAILS (empty `diags` instead of one
1963    /// `E063`) against `ShapeTable::resolve` reverted to always return the
1964    /// std candidate (i.e. simulating a resolution that ignores the
1965    /// referrer's own module) — restored before committing.
1966    #[test]
1967    fn check_assignments_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
1968        let project_src = "STRUCT Cue = #{x: float}\n\
1969             VAR p: Cue = Cue#{x: 0.0}\n\
1970             === main ===\n~ p.x = \"wrong\"\n-> DONE\n";
1971        let std_src = "STRUCT Cue = #{x: string}\nHello.\n";
1972
1973        let (project_file, project_hir, std_file, std_hir, index, _resolutions, inference) =
1974            build_project_with_std_homonym(project_src, std_src);
1975
1976        let files = [(project_file, &project_hir), (std_file, &std_hir)];
1977        let diags = check_assignments(&files, &index, &inference);
1978
1979        assert_eq!(diags.len(), 1, "{diags:?}");
1980        assert_eq!(diags[0].code, DiagnosticCode::E063);
1981        assert!(
1982            diags[0].message.contains("float"),
1983            "the mismatch must be reported against the PROJECT's own `float`-declared `x`, not \
1984             std's `string`-declared one — which would silently accept the identically-typed \
1985             \"wrong\" RHS and produce zero diagnostics: {diags:?}"
1986        );
1987    }
1988}